This commit is contained in:
star7th
2025-04-10 18:06:45 +08:00
commit 54650cd944
154 changed files with 29090 additions and 0 deletions
+581
View File
@@ -0,0 +1,581 @@
import { NextRequest, NextResponse } from 'next/server';
import iconv from 'iconv-lite';
// 定义API源
const API_SOURCES = [
{
name: '太平洋电脑网',
url: 'https://whois.pconline.com.cn/ipJson.jsp?ip={ip}&json=true',
encoding: 'gbk' // 使用GBK编码
},
{
name: 'IP.CN',
url: 'https://www.ip.cn/api/index?ip={ip}&type=0',
encoding: 'utf-8'
},
{
name: 'ip-api.com',
url: 'http://ip-api.com/json/{ip}?lang=zh-CN',
encoding: 'utf-8'
},
{
name: '百度IP',
url: 'https://opendata.baidu.com/api.php?co=&resource_id=6006&oe=utf8&query={ip}',
encoding: 'utf-8'
},
{
name: '淘宝IP',
url: 'https://ip.taobao.com/outGetIpInfo?ip={ip}&accessKey=alibaba-inc',
encoding: 'utf-8'
},
{
name: '新浪IP',
url: 'https://int.dpool.sina.com.cn/iplookup/iplookup.php?format=json&ip={ip}',
encoding: 'utf-8'
},
{
name: '美图IP',
url: 'https://webapi-pc.meitu.com/common/ip_location?ip={ip}',
encoding: 'utf-8'
},
{
name: 'Vore',
url: 'https://api.vore.top/api/IPdata?ip={ip}',
encoding: 'utf-8'
},
{
name: 'IPApi.is',
url: 'https://api.ipapi.is/?ip={ip}',
encoding: 'utf-8'
},
{
name: 'GeoJS',
url: 'https://get.geojs.io/v1/ip/geo/{ip}.json',
encoding: 'utf-8'
},
{
name: '顺为API',
url: 'https://api.itapi.cn/api/ip/ipv4?ip={ip}',
encoding: 'utf-8'
},
// 添加新的API源
{
name: 'IPInfoDB',
url: 'https://api.ipinfodb.com/v3/ip-city/?key=free&ip={ip}&format=json',
encoding: 'utf-8'
},
{
name: 'IP-API',
url: 'https://ip-api.com/json/{ip}?lang=zh-CN&fields=status,country,regionName,city,district,isp,org,as,mobile,proxy,hosting,query',
encoding: 'utf-8'
},
{
name: 'IPWHOIS',
url: 'https://ipwhois.app/json/{ip}?lang=zh',
encoding: 'utf-8'
},
{
name: 'IPGeolocation',
url: 'https://api.ipgeolocation.io/ipgeo?apiKey=free&ip={ip}',
encoding: 'utf-8'
}
];
// 安全配置
const SECURITY_CONFIG = {
// 每个IP每天允许的最大请求次数
maxDailyRequests: 1000000,
// 每个IP每小时允许的最大请求次数
maxHourlyRequests: 3000,
// 允许查询的最大IP数量(用于批量查询防护)
maxBatchSize: 5,
// IP地址格式验证的正则表达式
ipRegex: /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/,
// 黑名单IP前缀(用于防止查询敏感IP)
blacklistedPrefixes: [
'192.168.',
'10.',
'172.16.',
'172.17.',
'172.18.',
'172.19.',
'172.20.',
'172.21.',
'172.22.',
'172.23.',
'172.24.',
'172.25.',
'172.26.',
'172.27.',
'172.28.',
'172.29.',
'172.30.',
'172.31.',
'127.',
'0.'
],
};
// 定义类型
interface CacheEntry {
data: Record<string, unknown>;
timestamp: number;
source?: string;
}
interface RequestLimitRecord {
hourly: { count: number, resetTime: number };
daily: { count: number, resetTime: number };
lastRequestTime: number;
}
// 内存缓存,用于存储IP查询结果和速率限制
const ipCache = new Map<string, CacheEntry>();
const requestLimits = new Map<string, RequestLimitRecord>();
// 处理特定编码的响应
async function handleEncodedResponse(response: Response, apiSource: typeof API_SOURCES[0]): Promise<unknown> {
try {
// 对于GBK编码的API,需要特殊处理
if (apiSource.encoding === 'gbk') {
try {
const buffer = await response.arrayBuffer();
const text = iconv.decode(Buffer.from(buffer), 'gbk');
console.log(`GBK解码结果: ${text.substring(0, 100)}...`);
// 处理可能的JSONP响应
if (apiSource.name === '太平洋电脑网') {
const jsonStart = text.indexOf('{');
const jsonEnd = text.lastIndexOf('}') + 1;
if (jsonStart >= 0 && jsonEnd > jsonStart) {
const jsonText = text.substring(jsonStart, jsonEnd);
try {
return JSON.parse(jsonText);
} catch (error) {
console.error('解析JSON失败:', error);
throw new Error('解析GBK编码的JSON失败');
}
}
}
try {
return JSON.parse(text);
} catch (error) {
console.error('解析GBK响应失败:', error);
throw new Error('无法解析GBK编码的响应');
}
} catch (decodingError) {
console.error('GBK解码失败:', decodingError);
throw new Error('GBK编码处理失败');
}
} else {
// UTF-8或其他编码,尝试直接解析为JSON
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
try {
return await response.json();
} catch (error) {
console.error('JSON解析失败:', error);
throw new Error('JSON解析错误');
}
} else {
try {
const text = await response.text();
console.log(`响应文本开头: ${text.substring(0, 100)}...`);
// 尝试解析为JSON
try {
return JSON.parse(text);
} catch {
// 尝试提取JSONP响应中的JSON部分
const jsonpMatch = text.match(/\w+\((.*)\)/);
if (jsonpMatch && jsonpMatch[1]) {
try {
return JSON.parse(jsonpMatch[1]);
} catch (e) {
console.error('JSONP解析失败:', e);
}
}
// 尝试提取任何JSON对象
const jsonMatch = text.match(/\{.*\}/);
if (jsonMatch) {
try {
return JSON.parse(jsonMatch[0]);
} catch (e) {
console.error('JSON提取失败:', e);
}
}
// 处理纯文本IP
const ipMatch = text.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/);
if (ipMatch) {
return { ip: ipMatch[0] };
}
console.error('所有解析方法都失败,响应内容:', text.substring(0, 200));
throw new Error('无法解析API响应');
}
} catch (textError) {
console.error('获取响应文本失败:', textError);
throw new Error('读取响应文本失败');
}
}
}
} catch (error) {
console.error('处理API响应时发生未捕获的错误:', error);
throw error;
}
}
/**
* 验证IP地址格式是否有效
*/
function isValidIpAddress(ip: string): boolean {
return SECURITY_CONFIG.ipRegex.test(ip);
}
/**
* 检查IP是否在黑名单中
*/
function isBlacklistedIp(ip: string): boolean {
return SECURITY_CONFIG.blacklistedPrefixes.some(prefix => ip.startsWith(prefix));
}
/**
* 检查请求限制
*/
function checkRequestLimit(clientIp: string): boolean {
const now = Date.now();
const hourMs = 60 * 60 * 1000;
const dayMs = 24 * hourMs;
// 获取或初始化请求限制记录
let record = requestLimits.get(clientIp);
if (!record) {
record = {
hourly: { count: 0, resetTime: now + hourMs },
daily: { count: 0, resetTime: now + dayMs },
lastRequestTime: 0
};
requestLimits.set(clientIp, record);
}
// 重置过期的计数器
if (now > record.hourly.resetTime) {
record.hourly = { count: 0, resetTime: now + hourMs };
}
if (now > record.daily.resetTime) {
record.daily = { count: 0, resetTime: now + dayMs };
}
// 增加计数并检查限制
record.hourly.count++;
record.daily.count++;
record.lastRequestTime = now;
// 检查是否超出限制
return (
record.hourly.count <= SECURITY_CONFIG.maxHourlyRequests &&
record.daily.count <= SECURITY_CONFIG.maxDailyRequests
);
}
/**
* 从缓存获取IP信息或标记为需要刷新
*/
function getCachedIpInfo(ip: string): { data: Record<string, unknown> | null; needsRefresh: boolean } {
const cacheEntry = ipCache.get(ip);
const now = Date.now();
// 如果缓存不存在或已过期(超过1小时),需要刷新
if (!cacheEntry || now - cacheEntry.timestamp > 60 * 60 * 1000) {
return { data: null, needsRefresh: true };
}
return { data: cacheEntry.data, needsRefresh: false };
}
/**
* 更新IP缓存
*/
function updateIpCache(ip: string, data: Record<string, unknown>): void {
ipCache.set(ip, {
data,
timestamp: Date.now()
});
// 清理缓存(如果缓存大小超过1000条记录)
if (ipCache.size > 1000) {
// 找出最旧的100条记录并删除
const entries = Array.from(ipCache.entries());
entries
.sort((a, b) => a[1].timestamp - b[1].timestamp)
.slice(0, 100)
.forEach(([key]) => ipCache.delete(key));
}
}
/**
* 获取客户端的真实IP地址
*/
function getClientIP(request: NextRequest): string {
const forwardedFor = request.headers.get('x-forwarded-for');
if (forwardedFor) {
// x-forwarded-for可能包含多个IP,取第一个
return forwardedFor.split(',')[0].trim();
}
// 尝试从其他标头获取
const realIP = request.headers.get('x-real-ip');
if (realIP) {
return realIP;
}
// 如果都没有,尝试从远程地址获取
const remoteAddr = request.headers.get('remote-addr') || '';
return remoteAddr;
}
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
let ip = searchParams.get('ip') || '';
const source = searchParams.get('source') || 'auto';
const checkSelf = ip === 'self' || ip === '';
console.log(`开始IP查询请求: ${ip ? ip : '请求自身IP'}, 源: ${source}`);
// 获取客户端IP
const clientIp = getClientIP(request);
console.log(`客户端IP: ${clientIp}`);
// 检查请求频率限制
if (!checkRequestLimit(clientIp)) {
console.log(`请求频率限制: IP ${clientIp} 超出限制`);
return NextResponse.json(
{ error: '请求次数过多,请稍后再试' },
{ status: 429 }
);
}
// 如果是查询自己的IP或未提供IP
if (checkSelf) {
ip = clientIp;
console.log(`自查询模式,使用客户端IP: ${ip}`);
if (!ip || ip === '127.0.0.1' || ip === 'localhost') {
// 无法获取客户端IP,返回错误
console.log('无法获取有效的客户端IP');
return NextResponse.json(
{
data: {
ip: '无法获取您的IP地址',
country: '未知',
region: '未知',
city: '未知',
isp: '未知'
},
source: '本地分析'
}
);
}
} else {
// 验证IP地址格式
if (!ip || !isValidIpAddress(ip)) {
console.log(`无效的IP地址格式: ${ip}`);
return NextResponse.json(
{ error: '无效的IP地址格式' },
{ status: 400 }
);
}
// 检查是否尝试查询黑名单IP(内网IP等)
if (isBlacklistedIp(ip)) {
console.log(`黑名单IP: ${ip}`);
return NextResponse.json({
data: {
ip: ip,
country: '本地网络',
region: '私有网络',
city: '内部网络',
isp: '本地连接'
},
source: '本地分析'
});
}
}
try {
// 检查缓存
const { data: cachedData, needsRefresh } = getCachedIpInfo(ip);
if (cachedData && !needsRefresh) {
console.log(`从缓存中获取IP信息: ${ip}`);
return NextResponse.json({
data: cachedData,
source: cachedData.source || '缓存数据',
requestType: checkSelf ? 'self' : 'query',
cached: true
});
}
// 检查是否为本地IP或保留IP,直接显示特殊信息
if (ip === '127.0.0.1' || ip.startsWith('192.168.') || ip.startsWith('10.') ||
(ip.startsWith('172.') && parseInt(ip.split('.')[1]) >= 16 && parseInt(ip.split('.')[1]) <= 31)) {
let ipType = '本地网络';
if (ip === '127.0.0.1') {
ipType = '环回地址';
} else if (ip.startsWith('192.168.')) {
ipType = '私有网络 (C类)';
} else if (ip.startsWith('10.')) {
ipType = '私有网络 (A类)';
} else if (ip.startsWith('172.')) {
ipType = '私有网络 (B类)';
}
console.log(`检测到本地/保留IP: ${ip}, 类型: ${ipType}`);
const localNetworkData = {
ip: ip,
country: '本地网络',
region: ipType,
city: '内部网络',
isp: '本地连接'
};
// 更新缓存
updateIpCache(ip, localNetworkData);
return NextResponse.json({
data: localNetworkData,
source: '本地分析',
requestType: checkSelf ? 'self' : 'query'
});
}
// 根据source选择单一API源或尝试所有API源
const apiSources = source === 'auto'
? API_SOURCES
: API_SOURCES.filter(s => s.name === source);
if (apiSources.length === 0) {
console.log(`未找到API源: ${source}`);
return NextResponse.json(
{ error: '指定的API源不存在' },
{ status: 400 }
);
}
console.log(`准备查询IP: ${ip}, 将尝试 ${apiSources.length} 个API源`);
// 逐个尝试API源
for (const apiSource of apiSources) {
try {
const url = apiSource.url.replace('{ip}', ip);
console.log(`尝试API源: ${apiSource.name}, URL: ${url}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
console.log(`发送请求到: ${url}`);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Referer': 'https://jisuxiang.com/'
},
cache: 'no-store', // 不使用缓存
next: { revalidate: 0 } // Next.js特定配置,确保不缓存
});
clearTimeout(timeoutId);
console.log(`API ${apiSource.name} 响应状态: ${response.status}`);
if (!response.ok) {
console.error(`API ${apiSource.name} 响应状态不正常: ${response.status}`);
continue; // 尝试下一个API源
}
// 处理响应数据,考虑编码
try {
console.log(`处理API ${apiSource.name} 响应,编码: ${apiSource.encoding}`);
const data = await handleEncodedResponse(response, apiSource);
// 额外记录API响应,方便调试
console.log(`API ${apiSource.name} 响应成功:`,
JSON.stringify(data).substring(0, 200) +
(JSON.stringify(data).length > 200 ? '...' : ''));
// 检查数据是否有效(不是空对象或没有有效字段)
if (!data || (typeof data === 'object' && Object.keys(data).length === 0)) {
console.log(`API ${apiSource.name} 返回了空数据`);
continue; // 尝试下一个API源
}
// 更新缓存
if (data && typeof data === 'object') {
updateIpCache(ip, { ...data as Record<string, unknown>, source: apiSource.name });
} else {
// 如果不是对象,创建一个新对象存储
updateIpCache(ip, { rawData: data, source: apiSource.name });
}
// 返回原始数据和API源信息
console.log(`返回API ${apiSource.name} 的数据`);
return NextResponse.json({
data,
source: apiSource.name,
// 如果是请求自己的IP,添加标记
requestType: checkSelf ? 'self' : 'query'
});
} catch (parseError) {
console.error(`API ${apiSource.name} 解析失败:`, parseError);
continue; // 解析失败,尝试下一个API
}
} catch (fetchError) {
clearTimeout(timeoutId);
console.error(`API ${apiSource.name} 的fetch操作失败:`, fetchError);
continue; // fetch失败,尝试下一个API
}
} catch (error) {
console.error(`API ${apiSource.name} 请求过程中发生未知错误:`, error);
// 继续尝试下一个API
}
}
console.log(`所有API源都失败,返回基本IP信息: ${ip}`);
// 所有API都失败,但仍然返回一个基本的IP信息
const fallbackData = {
ip: ip,
country: '未知',
region: '未知',
city: '未知',
isp: '未知'
} as Record<string, unknown>;
// 更新缓存,但设置为短期缓存(10分钟)
updateIpCache(ip, {
...fallbackData,
source: 'IP解析失败'
});
return NextResponse.json({
data: fallbackData,
source: 'IP解析失败',
requestType: checkSelf ? 'self' : 'query'
});
} catch (error) {
console.error('IP查询出错:', error);
return NextResponse.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
+206
View File
@@ -0,0 +1,206 @@
import { NextRequest, NextResponse } from 'next/server';
// API密钥,实际应用中应该从环境变量获取
const API_KEY = 'markitdown-api-key-hyrtjhyt464h5346vt3453y34534tsfsf';
const API_URL = 'http://jisuxiang-markitdown:8000/convert';
// 文件大小限制(50MB
const MAX_FILE_SIZE = 50 * 1024 * 1024;
// 支持的文件格式
const SUPPORTED_FILE_FORMATS = ['.docx', '.pdf', '.pptx', '.xlsx', '.html', '.htm', '.rtf', '.txt', '.csv', '.json', '.xml', '.epub', '.md'];
// 安全配置
const SECURITY_CONFIG = {
// 可接受的内容类型
allowedContentTypes: [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // docx
'application/pdf', // pdf
'application/vnd.openxmlformats-officedocument.presentationml.presentation', // pptx
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // xlsx
'text/html', // html/htm
'application/rtf', // rtf
'text/plain', // txt
'text/csv', // csv
'application/json', // json
'application/xml', 'text/xml', // xml
'application/epub+zip', // epub
'text/markdown', // md
],
// 文件内容类型与扩展名映射(简化)
contentTypeExtMap: {
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
'application/pdf': '.pdf',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
'text/html': '.html',
'application/rtf': '.rtf',
'text/plain': '.txt',
'text/csv': '.csv',
'application/json': '.json',
'application/xml': '.xml',
'text/xml': '.xml',
'application/epub+zip': '.epub',
'text/markdown': '.md',
},
};
/**
* 检查文件类型是否安全
*/
async function isFileSafe(file: File): Promise<boolean> {
try {
// 检查MIME类型
const contentType = file.type;
// 检查内容类型是否在允许列表中
if (!SECURITY_CONFIG.allowedContentTypes.includes(contentType)) {
return false;
}
// 对于没有MIME类型的文件进行额外检查
if (!contentType && file.name) {
const ext = '.' + (file.name.split('.').pop() || '').toLowerCase();
return SUPPORTED_FILE_FORMATS.includes(ext);
}
// 进行基本的内容头检查(仅对某些格式)
// 这需要访问文件二进制数据
// 对于简化版可以跳过,实际应用中可以读取文件头几个字节做进一步检验
return true;
} catch (error) {
console.error('文件安全检查失败:', error);
return false;
}
}
/**
* 处理文件转Markdown的请求
*/
export async function POST(request: NextRequest) {
try {
// 只接受multipart/form-data请求
const contentType = request.headers.get('content-type') || '';
if (!contentType.includes('multipart/form-data')) {
return NextResponse.json(
{ error: '不支持的请求格式,请使用multipart/form-data' },
{ status: 415 }
);
}
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json(
{ error: '没有提供文件' },
{ status: 400 }
);
}
// 检查文件格式
const fileName = file.name.toLowerCase();
const fileExtension = '.' + (fileName.split('.').pop() || '');
if (fileExtension === '.doc' || fileExtension === '.ppt' || fileExtension === '.xls') {
return NextResponse.json(
{ error: `不支持旧版Office文档(${fileExtension})格式,请先使用Microsoft Office或WPS等软件将文档另存为新格式后再上传` },
{ status: 400 }
);
}
if (!SUPPORTED_FILE_FORMATS.includes(fileExtension)) {
return NextResponse.json(
{ error: `不支持的文件格式:${fileExtension}。支持的格式包括:${SUPPORTED_FILE_FORMATS.join(', ')}` },
{ status: 400 }
);
}
// 检查文件大小
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ error: `文件过大,最大支持50MB(当前文件大小: ${(file.size / (1024 * 1024)).toFixed(2)}MB` },
{ status: 400 }
);
}
// 进行文件安全检查
if (!(await isFileSafe(file))) {
return NextResponse.json(
{ error: '文件类型不安全或与扩展名不匹配' },
{ status: 400 }
);
}
// 创建一个新的FormData对象,用于发送到目标API
const apiFormData = new FormData();
apiFormData.append('file', file);
// 发送请求到目标API
console.log('发送请求到:', API_URL);
console.log('文件名:', file.name, '文件类型:', file.type, '文件大小:', file.size);
let response;
try {
response = await fetch(API_URL, {
method: 'POST',
headers: {
'X-API-Key': API_KEY,
'User-Agent': 'JiSuXiang-API/1.0',
// 注意:不要手动设置Content-Type,让浏览器自动设置
},
body: apiFormData,
});
console.log('响应状态:', response.status, response.statusText);
} catch (error) {
console.error('发送请求失败:', error);
return NextResponse.json(
{ error: '连接到文档转换服务失败' },
{ status: 502 }
);
}
// 检查响应状态
if (!response.ok) {
// 尝试获取错误信息
let errorMessage;
try {
const errorData = await response.json();
errorMessage = errorData.error || `服务器返回错误: ${response.status}`;
} catch {
errorMessage = `服务器返回错误: ${response.status}`;
}
return NextResponse.json(
{ error: errorMessage },
{ status: response.status }
);
}
// 返回API的响应
const result = await response.json();
return NextResponse.json(result);
} catch (error) {
console.error('文件转换错误:', error);
return NextResponse.json(
{ error: '服务器处理请求时出错' },
{ status: 500 }
);
}
}
/**
* 配置请求体大小限制 (50MB)
*/
export const config = {
api: {
bodyParser: {
sizeLimit: '50mb',
},
},
};
+386
View File
@@ -0,0 +1,386 @@
import { NextRequest, NextResponse } from 'next/server';
// 定义支持的HTTP方法
const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
// 安全限制配置
const SECURITY_CONFIG = {
// 请求体大小限制(2MB
maxBodySize: 2 * 1024 * 1024,
// URL黑名单(正则表达式)
urlBlacklist: [
/localhost/i,
/127(\.|%2E|%2e|-|%2D|%2d)0(\.|%2E|%2e|-|%2D|%2d)0(\.|%2E|%2e|-|%2D|%2d)1/i, // 127.0.0.1 及变种
/127(\.|%2E|%2e)([0-9]{1,3})(\.|%2E|%2e)([0-9]{1,3})(\.|%2E|%2e)([0-9]{1,3})/i, // 所有127.0.0.0/8
/^0\./i, // 0.0.0.0
/192(\.|%2E|%2e)168(\.|%2E|%2e)/i, // 192.168.x.x
/10(\.|%2E|%2e)([0-9]{1,3})(\.|%2E|%2e)([0-9]{1,3})(\.|%2E|%2e)([0-9]{1,3})/i, // 10.x.x.x
/172(\.|%2E|%2e)(1[6-9]|2[0-9]|3[0-1])(\.|%2E|%2e)([0-9]{1,3})(\.|%2E|%2e)([0-9]{1,3})/i, // 172.16-31.x.x
/169(\.|%2E|%2e)254(\.|%2E|%2e)/i, // 链路本地地址
/::1/i, // IPv6 localhost
/fc00::/i, // IPv6 私有地址
/fe80::/i, // IPv6 链路本地地址
/file:/i, // 本地文件
/ftp:/i, // FTP协议
/internal\./i, // internal域名
/intranet\./i, // intranet域名
/private\./i, // private域名
/corp\./i, // 企业内网域名
/\.local$/i, // .local域名
/\.internal$/i, // .internal域名
/\.localhost$/i, // .localhost域名
/nip\.io$/i, // nip.io域名服务,常用于内网绕过
/sslip\.io$/i, // sslip.io域名,类似nip.io
/xip\.io$/i, // xip.io域名,类似nip.io
/lvh\.me$/i, // lvh.me,指向127.0.0.1
/localtest\.me$/i, // localtest.me,指向127.0.0.1
],
// 域名解析安全检查
domainChecks: [
// 屏蔽包含内网IP数字的域名
/((^|\.)127\.|\.0\.0\.|\.(192\.168|10\.|172\.(1[6-9]|2[0-9]|3[0-1]))\.|\.254\.)/i,
],
// 敏感端口黑名单
portBlacklist: [
// 系统和服务端口
'21', '22', '23', '25', '53', '69', '110', '111', '119', '123', '135', '137', '138', '139', '143', '161', '162',
'389', '445', '465', '514', '515', '587', '631', '636', '989', '990', '993', '995',
// 数据库端口
'1433', '1434', '1521', '1522', '3306', '5000', '5432', '5433', '6379', '9042', '27017', '27018', '27019', '28017',
// 中间件和缓存端口
'8161', '9000', '9092', '9200', '9300', '11211', '50000', '50070', '50075', '50090',
// Web服务和开发端口
'4000', '4001', '8001', '8008', '8088', '8443', '8888', '9001', '9090',"8016","8017","9443",
// 代理和VPN端口
'1080', '3128', '8118', '9091',
// 远程管理和监控端口
'3389', '5900', '5901', '5902', '5903', '8834', '10000',
// 其他常见的敏感服务端口
'873', '2049', '2181', '2375', '2376', '3690', '4369', '4444', '4505', '4506', '5601', '5672', '5984', '6000', '6001',
'7001', '7002', '7077', '8009', '8983', '9990', '15672', '49152', '49153', '49154', '49155'
],
// 允许的最大响应大小(5MB
maxResponseSize: 5 * 1024 * 1024,
// 重定向安全检查
maxRedirects: 5, // 最大重定向次数
checkRedirects: true, // 是否检查重定向
};
// 请求频率限制存储
const rateLimits = new Map<string, {count: number, timestamp: number}>();
const RATE_LIMIT = 20; // 每分钟最大请求数
const RATE_WINDOW = 60 * 1000; // 1分钟窗口期
/**
* 安全检查URL
*/
function isUrlSafe(urlString: string): boolean {
try {
const url = new URL(urlString);
// 检查协议
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return false;
}
// 检查URL黑名单
if (SECURITY_CONFIG.urlBlacklist.some(pattern => pattern.test(url.hostname))) {
return false;
}
// 检查端口黑名单
if (url.port && SECURITY_CONFIG.portBlacklist.includes(url.port)) {
return false;
}
// 检查域名特征
if (SECURITY_CONFIG.domainChecks.some(pattern => pattern.test(url.hostname))) {
return false;
}
// 检查IP格式的主机名
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
const ipMatch = url.hostname.match(ipv4Regex);
if (ipMatch) {
const octets = ipMatch.slice(1, 5).map(Number);
// 检查是否为内网IP地址
if (
octets[0] === 10 || // 10.0.0.0/8
(octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || // 172.16.0.0/12
(octets[0] === 192 && octets[1] === 168) || // 192.168.0.0/16
(octets[0] === 127) || // 127.0.0.0/8
(octets[0] === 0) || // 0.0.0.0/8
(octets[0] === 169 && octets[1] === 254) || // 169.254.0.0/16
(octets[0] >= 224) // 组播和保留地址
) {
return false;
}
}
return true;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_) {
return false;
}
}
/**
* 检查请求频率限制
*/
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const userLimit = rateLimits.get(ip);
if (!userLimit) {
rateLimits.set(ip, { count: 1, timestamp: now });
return true;
}
// 如果时间窗口已过,重置计数
if (now - userLimit.timestamp > RATE_WINDOW) {
rateLimits.set(ip, { count: 1, timestamp: now });
return true;
}
// 增加计数并检查是否超过限制
userLimit.count++;
if (userLimit.count > RATE_LIMIT) {
return false;
}
return true;
}
/**
* 安全检查重定向URL
*/
async function checkRedirectSafety(url: string, redirectCount = 0): Promise<boolean> {
if (redirectCount >= SECURITY_CONFIG.maxRedirects) {
return false; // 超过最大重定向次数
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(url, {
method: 'HEAD',
redirect: 'manual',
signal: controller.signal
});
clearTimeout(timeoutId);
// 检查是否有重定向
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (location) {
// 构建完整的重定向URL
const redirectUrl = new URL(location, url).toString();
// 检查重定向URL安全性
if (!isUrlSafe(redirectUrl)) {
return false;
}
// 递归检查下一个重定向
return await checkRedirectSafety(redirectUrl, redirectCount + 1);
}
}
return true;
} catch (error) {
console.error('重定向检查失败:', error);
return false; // 出错时保守处理,拒绝请求
}
}
/**
* 处理所有HTTP请求的代理路由
*/
export async function POST(request: NextRequest) {
// 获取客户端IP
const ip = request.headers.get('x-forwarded-for') || 'unknown';
// 检查请求频率限制
if (!checkRateLimit(ip)) {
return NextResponse.json(
{ error: '请求频率过高,请稍后再试' },
{ status: 429 }
);
}
try {
// 获取请求体中的请求配置
const requestConfig = await request.json();
const { url, method, headers = {}, body } = requestConfig;
// 验证请求参数
if (!url) {
return NextResponse.json(
{ error: '缺少必要的URL参数' },
{ status: 400 }
);
}
if (!method || !ALLOWED_METHODS.includes(method)) {
return NextResponse.json(
{ error: '不支持的HTTP方法' },
{ status: 400 }
);
}
// 检查URL安全性
if (!isUrlSafe(url)) {
return NextResponse.json(
{ error: '请求URL不安全或不被允许' },
{ status: 403 }
);
}
// 检查重定向安全性
if (SECURITY_CONFIG.checkRedirects) {
const isRedirectSafe = await checkRedirectSafety(url);
if (!isRedirectSafe) {
return NextResponse.json(
{ error: '重定向目标不安全或不被允许' },
{ status: 403 }
);
}
}
// 检查请求体大小
if (body && typeof body === 'string' && body.length > SECURITY_CONFIG.maxBodySize) {
return NextResponse.json(
{ error: '请求体超过大小限制' },
{ status: 413 }
);
}
// 移除或净化敏感请求头
const sanitizedHeaders: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
// 跳过敏感请求头
if (['host', 'origin', 'referer', 'cookie', 'authorization'].includes(key.toLowerCase())) {
continue;
}
sanitizedHeaders[key] = value as string;
}
// 设置安全的请求头
sanitizedHeaders['X-Forwarded-By'] = 'JiSuXiang-Proxy';
sanitizedHeaders['User-Agent'] = sanitizedHeaders['User-Agent'] || 'JiSuXiang-Proxy/1.0';
// 准备请求选项
const fetchOptions: RequestInit = {
method,
headers: sanitizedHeaders,
// 只在适用的方法中添加请求体
...(method !== 'GET' && method !== 'HEAD' && body ? { body } : {}),
redirect: 'follow',
};
// 发送请求
const startTime = performance.now();
// 设置超时
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000); // 15秒超时
try {
const response = await fetch(url, {
...fetchOptions,
signal: controller.signal
});
clearTimeout(timeoutId);
const endTime = performance.now();
const responseTime = Math.round(endTime - startTime);
// 获取响应头
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
// 根据Content-Type处理响应
const contentType = response.headers.get('content-type') || '';
let responseData;
let responseText;
// 检查响应大小
const contentLength = response.headers.get('content-length');
if (contentLength && parseInt(contentLength) > SECURITY_CONFIG.maxResponseSize) {
return NextResponse.json({
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
data: '响应内容过大,已被截断',
time: responseTime,
size: parseInt(contentLength),
});
}
if (contentType.includes('application/json')) {
responseData = await response.json();
responseText = JSON.stringify(responseData);
} else {
responseText = await response.text();
responseData = responseText;
}
// 检查响应体大小
if (responseText.length > SECURITY_CONFIG.maxResponseSize) {
return NextResponse.json({
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
data: '响应内容过大,已被截断',
time: responseTime,
size: responseText.length,
});
}
// 计算响应大小(近似值)
const responseSize = Buffer.from(responseText || '').length;
// 返回代理响应结果
return NextResponse.json({
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
data: responseData,
time: responseTime,
size: responseSize,
});
} catch (fetchError) {
clearTimeout(timeoutId);
throw fetchError;
}
} catch (error) {
console.error('代理请求错误:', error);
// 提供友好的错误信息,不泄露详细的错误细节
let errorMessage = '代理请求失败';
if (error instanceof Error) {
if (error.name === 'AbortError') {
errorMessage = '请求超时';
} else {
errorMessage = '请求失败:' + (error.message.includes('fetch') ? '无法连接到目标服务器' : '未知错误');
}
}
return NextResponse.json(
{ error: errorMessage },
{ status: 500 }
);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+501
View File
@@ -0,0 +1,501 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 添加字体图标占位,防止布局闪烁 */
.fontawesome-i2svg-active body .icon-container .icon {
opacity: 1;
transition: opacity 0.2s ease-in;
}
.fontawesome-i2svg-pending body .icon-container .icon {
opacity: 0;
}
:root {
/* 主题色 */
--color-primary: 99, 102, 241; /* #6366F1 - 深邃紫色 */
--color-primary-hover: 139, 92, 246; /* #8B5CF6 - 紫罗兰 */
--color-primary-light: 129, 140, 248; /* #818CF8 - 亮紫色 */
/* 背景色系 */
--color-bg-main: 18, 24, 39; /* #121827 - 深炭黑 */
--color-bg-card: 30, 41, 59; /* #1E293B - 暗灰蓝 */
--color-bg-secondary: 45, 55, 72; /* #2D3748 - 深靛蓝 */
/* 文字颜色 */
--color-text-primary: 241, 245, 249; /* #F1F5F9 - 银月白 */
--color-text-secondary: 203, 213, 225; /* #CBD5E1 - 淡灰蓝 */
--color-text-tertiary: 148, 163, 184; /* #94A3B8 - 冷钢灰 */
/* 点缀色 */
--color-success: 16, 185, 129; /* #10B981 - 暗翠绿 */
--color-warning: 245, 158, 11; /* #F59E0B - 暗琥珀 */
--color-error: 239, 68, 68; /* #EF4444 - 暗珊瑚 */
/* 块级元素背景色 */
--color-block: 38, 53, 72; /* #263548 - 深灰蓝色块 */
--color-block-strong: 45, 55, 72; /* #2D3748 - 更深的块色 */
--color-block-hover: 61, 74, 92; /* #3D4A5C - 悬停态块色 */
/* 边框和阴影 */
--color-border: rgba(99, 102, 241, 0.15);
--color-shadow: rgba(0, 0, 0, 0.25);
--purple-glow: rgba(99, 102, 241, 0.15);
}
/* 浅色主题变量 */
[data-theme='light'] {
/* 主题色保持不变,以保持品牌一致性 */
--color-primary: 99, 102, 241; /* #6366F1 - 深邃紫色 */
--color-primary-hover: 139, 92, 246; /* #8B5CF6 - 紫罗兰 */
--color-primary-light: 124, 58, 237; /* #7C3AED - 更深的紫色,提高对比度 */
/* 背景色系 */
--color-bg-main: 249, 250, 251; /* #F9FAFB - 浅灰背景 */
--color-bg-card: 255, 255, 255; /* #FFFFFF - 纯白卡片 */
--color-bg-secondary: 243, 244, 246; /* #F3F4F6 - 次级灰背景 */
/* 文字颜色 */
--color-text-primary: 17, 24, 39; /* #111827 - 近黑色 */
--color-text-secondary: 55, 65, 81; /* #374151 - 深灰色 */
--color-text-tertiary: 107, 114, 128; /* #6B7280 - 中灰色 */
/* 点缀色保持不变 */
--color-success: 16, 185, 129; /* #10B981 - 暗翠绿 */
--color-warning: 245, 158, 11; /* #F59E0B - 暗琥珀 */
--color-error: 239, 68, 68; /* #EF4444 - 暗珊瑚 */
/* 块级元素背景色 */
--color-block: 241, 245, 249; /* #F1F5F9 - 浅灰色块 */
--color-block-strong: 226, 232, 240; /* #E2E8F0 - 更强调的块色 */
--color-block-hover: 203, 213, 225; /* #CBD5E1 - 悬停态块色 */
/* 边框和阴影 */
--color-border: rgba(99, 102, 241, 0.2);
--color-shadow: rgba(0, 0, 0, 0.1);
--purple-glow: rgba(99, 102, 241, 0.2);
}
/* 全局样式 */
body {
color: rgb(var(--color-text-primary));
background: rgb(var(--color-bg-main));
min-height: 100vh;
transition: color 0.3s ease, background-color 0.3s ease;
}
/* 自定义滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(var(--color-bg-secondary), 0.3);
}
::-webkit-scrollbar-thumb {
background: rgba(var(--color-primary), 0.4);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(var(--color-primary-hover), 0.6);
}
/* Firefox滚动条样式 */
* {
scrollbar-width: thin;
scrollbar-color: rgba(var(--color-primary), 0.4) rgba(var(--color-bg-secondary), 0.3);
}
/* 主题通用文本颜色类 */
.text-primary {
color: rgb(var(--color-text-primary));
}
.text-secondary {
color: rgb(var(--color-text-secondary));
}
.text-tertiary {
color: rgb(var(--color-text-tertiary));
}
.text-purple {
color: rgb(var(--color-primary-light));
}
.text-error {
color: rgb(var(--color-error));
}
.text-success {
color: rgb(var(--color-success));
}
.text-warning {
color: rgb(var(--color-warning));
}
/* 主题通用背景色类 */
.bg-main {
background-color: rgb(var(--color-bg-main));
}
.bg-card {
background-color: rgb(var(--color-bg-card));
}
.bg-secondary {
background-color: rgb(var(--color-bg-secondary));
}
.bg-block {
background-color: rgb(var(--color-block));
}
.bg-block-strong {
background-color: rgb(var(--color-block-strong));
}
.bg-block-hover {
background-color: rgb(var(--color-block-hover));
}
.bg-purple-glow {
background-color: var(--purple-glow);
}
/* 通用组件样式 */
@layer components {
/* 卡片基础样式 */
.card {
@apply rounded-lg border shadow-lg transition-all duration-300;
background-color: rgb(var(--color-bg-card));
border-color: rgba(var(--color-primary), 0.15);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
/* 卡片悬停效果 */
.card:hover {
border-color: rgba(var(--color-primary), 0.3);
background-color: color-mix(in srgb, rgb(var(--color-bg-card)) 95%, rgb(var(--color-primary)) 5%);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
/* 主按钮样式 */
.btn-primary {
@apply bg-gradient-to-r text-white rounded-md px-4 py-2 font-medium transition-all;
background-image: linear-gradient(to right, rgb(var(--color-primary)), rgb(var(--color-primary-hover)));
}
/* 次按钮样式 */
.btn-secondary {
@apply rounded-md px-4 py-2 font-medium transition-all border;
background-color: rgb(var(--color-bg-secondary));
color: rgb(var(--color-primary-light));
border-color: rgb(var(--color-primary));
}
/* 按钮悬停效果 */
.btn-primary:hover, .btn-secondary:hover {
@apply shadow-md scale-[1.02];
box-shadow: 0 4px 6px rgba(var(--color-primary), 0.25);
border-color: rgb(var(--color-primary-light));
}
/* 分类标签样式 */
.category-tag {
@apply px-2 py-1 text-xs rounded-full inline-block border;
background-color: rgba(var(--color-primary), 0.15);
color: rgb(var(--color-primary-light));
border-color: rgba(var(--color-primary), 0.2);
}
/* 搜索框样式 */
.search-input {
@apply w-full px-4 py-2.5 pl-10 text-sm focus:outline-none focus:ring-1 rounded-md shadow-sm transition-all;
background-color: rgb(var(--color-bg-secondary));
color: rgb(var(--color-text-primary));
border: 1px solid rgba(var(--color-primary), 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.search-input:focus {
border-color: rgb(var(--color-primary));
--tw-ring-color: rgb(var(--color-primary));
}
/* 导航栏样式 */
.nav-bar {
@apply backdrop-blur-md border-b p-4 rounded-lg;
background-color: rgba(var(--color-bg-secondary), 0.7);
border-color: rgba(var(--color-primary), 0.2);
}
/* 图标容器 */
.icon-container {
@apply w-10 h-10 rounded-lg flex items-center justify-center;
background-color: rgba(var(--color-primary), 0.1);
}
/* 图标样式 */
.icon {
@apply text-lg;
color: rgb(var(--color-primary-light));
filter: drop-shadow(0 0 3px rgba(var(--color-primary), 0.3));
}
/* 圆角按钮 */
.rounded-button {
@apply rounded-md;
}
[data-theme='light'] .rounded-button {
@apply rounded-lg;
}
}
/* 淡入动画 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-fadeIn {
animation: fadeIn 0.2s ease-in-out forwards;
}
/* 缩放动画 */
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-scaleIn {
animation: scaleIn 0.2s ease-out forwards;
}
/* 脉冲动画 */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.animate-pulse {
animation: pulse 2s infinite;
}
/* JSON编辑器基础样式 */
.vanilla-jsoneditor-react {
width: 100%;
height: 100%;
overflow: hidden;
}
/* 添加Markdown预览样式 */
.prose {
color: rgb(var(--color-text-primary));
max-width: 65ch;
font-size: 1rem;
line-height: 1.75;
position: relative;
z-index: 0;
overflow-wrap: break-word;
}
.prose a {
color: rgb(var(--color-primary-light));
text-decoration: underline;
text-underline-offset: 2px;
}
.prose a:hover {
color: rgb(var(--color-primary));
}
.prose strong {
color: rgb(var(--color-text-primary));
font-weight: 600;
}
.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
color: rgb(var(--color-text-primary));
font-weight: 600;
margin-top: 1.5em;
margin-bottom: 0.75em;
line-height: 1.25;
}
.prose h1 {
font-size: 2em;
margin-top: 0;
}
.prose h2 {
font-size: 1.5em;
}
.prose h3 {
font-size: 1.25em;
}
.prose ul, .prose ol {
padding-left: 1.5em;
}
.prose ul li, .prose ol li {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
.prose blockquote {
border-left: 4px solid #6366F1;
padding-left: 1em;
margin-left: 0;
color: #CBD5E1;
}
.prose code {
background-color: rgba(99, 102, 241, 0.1);
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.9em;
}
.prose pre {
background-color: #2D3748;
padding: 1em;
border-radius: 6px;
overflow-x: auto;
margin: 1em 0;
}
.prose pre code {
background-color: transparent;
padding: 0;
font-size: 0.9em;
color: #CBD5E1;
}
.prose table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
}
.prose th, .prose td {
border: 1px solid #4B5563;
padding: 0.5em 0.75em;
text-align: left;
}
.prose th {
background-color: #2D3748;
font-weight: 600;
}
.prose hr {
border: 0;
border-top: 1px solid #4B5563;
margin: 2em 0;
}
.prose img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.prose-invert {
color: #F1F5F9;
}
/* 改进Markdown预览样式隔离 */
.markdown-preview-container {
all: revert;
font-family: inherit;
color: inherit;
line-height: inherit;
}
.markdown-preview-container * {
max-width: 100%;
}
.markdown-preview-container a {
color: #818CF8;
text-decoration: underline;
text-underline-offset: 2px;
}
.markdown-preview-container img {
max-width: 100%;
height: auto;
}
.markdown-preview-container pre,
.markdown-preview-container code {
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
.markdown-preview-container table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
overflow-x: auto;
display: block;
}
.markdown-preview-container th,
.markdown-preview-container td {
border: 1px solid #4B5563;
padding: 0.5em;
}
.markdown-preview-container th {
background-color: #2D3748;
}
.markdown-preview-container iframe,
.markdown-preview-container embed,
.markdown-preview-container object {
max-width: 100%;
border: none;
}
.markdown-preview-container blockquote {
margin-left: 0;
padding-left: 1em;
border-left: 4px solid #6366F1;
color: #CBD5E1;
}
/* 限制预览区域的内容交互 */
.markdown-preview-container form,
.markdown-preview-container button,
.markdown-preview-container input,
.markdown-preview-container textarea,
.markdown-preview-container select {
pointer-events: none;
}
+51
View File
@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import "./globals.css";
import { config } from '@fortawesome/fontawesome-svg-core';
import '@fortawesome/fontawesome-svg-core/styles.css';
import { LanguageProvider } from '@/context/LanguageContext';
// 阻止Font Awesome自动插入CSS,避免闪烁
config.autoAddCss = false;
export const metadata: Metadata = {
title: {
default: "极速箱 | Jisuxiang - 高效开发工具集成平台 | 程序员必备在线工具箱",
template: "%s | 极速箱 | Jisuxiang"
},
description: "提供高效开发工具集成平台,程序员必备在线工具箱,包含JSON处理、编码转换、加密解密、时间转换等提升编程效率的神器",
openGraph: {
title: "极速箱 | Jisuxiang - 高效开发工具集成平台 | 程序员必备在线工具箱",
description: "提供高效开发工具集成平台,程序员必备在线工具箱,包含JSON处理、编码转换、加密解密、时间转换等提升编程效率的神器",
type: "website",
locale: "zh_CN",
alternateLocale: "en_US",
},
alternates: {
languages: {
'zh-CN': '/',
'en-US': '/'
}
}
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<head>
{/* 预加载字体和图标资源 */}
<link rel="preload" href="/_next/static/media/fa-brands-400.ttf" as="font" type="font/ttf" crossOrigin="anonymous" />
<link rel="preload" href="/_next/static/media/fa-solid-900.ttf" as="font" type="font/ttf" crossOrigin="anonymous" />
<link rel="preload" href="/_next/static/media/fa-regular-400.ttf" as="font" type="font/ttf" crossOrigin="anonymous" />
</head>
<body>
<LanguageProvider>
{children}
</LanguageProvider>
</body>
</html>
);
}
+769
View File
@@ -0,0 +1,769 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faStar, faSearch, faTimes, faChevronDown, faBook, faCode, faCloud, faBell, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons';
import { faStar as farStar } from '@fortawesome/free-regular-svg-icons';
import { faGithub } from '@fortawesome/free-brands-svg-icons';
import categories from '@/config/categories';
import tools from '@/config/tools';
import { useRouter } from 'next/navigation';
import ThemeToggle from '@/components/ThemeToggle';
import LanguageToggle from '@/components/LanguageToggle';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
export default function Home() {
const { t, language } = useLanguage();
const [activeCategory, setActiveCategory] = useState(() => {
// 从 sessionStorage 获取上次选择的分类,如果没有则默认为 "all"
if (typeof window !== 'undefined') {
return sessionStorage.getItem('lastActiveCategory') || "all";
}
return "all";
});
const [searchTerm, setSearchTerm] = useState("");
const [iconsLoaded, setIconsLoaded] = useState(false);
const [favoriteTools, setFavoriteTools] = useState<string[]>([]);
const [showFavorites, setShowFavorites] = useState(false);
const [showNotification, setShowNotification] = useState(false);
const [firstFavoriteAdded, setFirstFavoriteAdded] = useState(false);
const [prefetchedTools, setPrefetchedTools] = useState<Set<string>>(new Set());
const [loadingProgress, setLoadingProgress] = useState<{ current: number, total: number }>({ current: 0, total: 0 });
const [showProductsDropdown, setShowProductsDropdown] = useState(false);
const router = useRouter();
// 产品推荐列表
const recommendedProducts = [
{ title: "ShowDoc", url: "https://www.showdoc.com.cn/", description: "API文档、技术文档工具", icon: faBook },
{ title: "RunApi", url: "https://www.runapi.com.cn/", description: "接口管理与测试平台", icon: faCode },
{ title: "大风云", url: "https://www.dfyun.com.cn/", description: "性价比巨高的CDN服务", icon: faCloud },
{ title: "Push", url: "https://push.showdoc.com.cn/", description: "消息推送服务", icon: faBell }
];
// 点击其他区域关闭产品推荐下拉菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
if (!target.closest('#products-dropdown-container')) {
setShowProductsDropdown(false);
}
};
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
}, []);
// 切换产品推荐下拉菜单的显示状态
const toggleProductsDropdown = (e: React.MouseEvent) => {
e.stopPropagation();
setShowProductsDropdown(prev => !prev);
};
// 批量工具预加载管理
useEffect(() => {
let isMounted = true;
// 页面完全加载后开始批量预加载
const startBatchPrefetch = () => {
// 获取收藏的工具列表
const savedFavorites = localStorage.getItem('favoriteTools');
const userFavorites = savedFavorites ? JSON.parse(savedFavorites) : [];
// 按优先级排序工具:1. 收藏的工具 2. 常用工具 3. 其他工具
const favoriteTools = tools.filter(tool => userFavorites.includes(tool.code));
const commonTools = tools.filter(tool =>
tool.category.includes('common') && !userFavorites.includes(tool.code)
);
const otherTools = tools.filter(tool =>
!tool.category.includes('common') && !userFavorites.includes(tool.code)
);
// 按优先级顺序合并工具列表
const orderedTools = [...favoriteTools, ...commonTools, ...otherTools];
if (!isMounted) return;
// 设置总数
setLoadingProgress({ current: 0, total: orderedTools.length });
// 创建预加载队列
const prefetchQueue = async () => {
const newPrefetchedSet = new Set(prefetchedTools);
for (let i = 0; i < orderedTools.length; i++) {
if (!isMounted) return;
const tool = orderedTools[i];
// 如果已经预加载过,跳过
if (newPrefetchedSet.has(tool.code)) {
setLoadingProgress(prev => ({ ...prev, current: prev.current + 1 }));
continue;
}
try {
// 计算优先级:收藏 > 常用 > 其他
const isPriority = userFavorites.includes(tool.code) ||
(i < favoriteTools.length + commonTools.length);
// 使用 fetch 预加载页面内容
const prefetchUrl = `/tools/${tool.code}`;
const response = await fetch(prefetchUrl, {
priority: isPriority ? 'high' : 'low',
method: 'GET',
cache: 'default'
});
if (!isMounted) return;
if (response.ok) {
// 预解析响应以确保它被缓存
await response.text();
// 更新已预加载的工具集合
newPrefetchedSet.add(tool.code);
// 更新进度
setLoadingProgress(prev => ({ ...prev, current: prev.current + 1 }));
// 更新预加载状态
if (isMounted) {
setPrefetchedTools(prev => {
const updatedSet = new Set(prev);
updatedSet.add(tool.code);
return updatedSet;
});
}
} else {
// 即使请求不成功也更新进度
setLoadingProgress(prev => ({ ...prev, current: prev.current + 1 }));
}
} catch (error) {
console.warn(`批量预加载工具 ${tool.code} 失败:`, error);
// 即使失败也更新进度
setLoadingProgress(prev => ({ ...prev, current: prev.current + 1 }));
}
// 调整不同优先级工具的预加载延迟
if (i < orderedTools.length - 1) {
// 收藏与常用工具间隔短一些,其他工具间隔长一些
const delay = i < favoriteTools.length + commonTools.length ? 150 : 300;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
// 预加载完成后,在开发环境打印信息
if (process.env.NODE_ENV === 'development') {
console.log(`预加载完成! 收藏工具: ${favoriteTools.length}, 常用工具: ${commonTools.length}, 其他工具: ${otherTools.length}`);
}
};
// 开始预加载队列
prefetchQueue();
};
// 等待页面完全加载后开始预加载
if (document.readyState === 'complete') {
// 给页面内容完全渲染一些时间,然后开始批量预加载
setTimeout(startBatchPrefetch, 2000);
} else {
window.addEventListener('load', () => {
// 页面加载完成后延迟一段时间再开始预加载
setTimeout(startBatchPrefetch, 2000);
});
}
// 清理函数
return () => {
isMounted = false;
};
}, []);
// 添加预加载工具页面的函数
const prefetchTool = (toolCode: string) => {
// 如果已经预加载过,则不再重复预加载
if (prefetchedTools.has(toolCode)) {
return;
}
// 使用 fetch 预加载页面内容
const prefetchUrl = `/tools/${toolCode}`;
fetch(prefetchUrl, { priority: 'low' })
.then(response => {
if (!response.ok) {
throw new Error(`预加载失败: ${response.status}`);
}
return response.text();
})
.then(() => {
// 成功预加载后更新状态
setPrefetchedTools(prev => {
const newSet = new Set(prev);
newSet.add(toolCode);
return newSet;
});
})
.catch(error => {
console.warn(`工具 ${toolCode} 预加载失败:`, error);
});
};
// 处理工具导航并显示加载指示器
const navigateToTool = (toolCode: string) => {
// 检查是否已预加载 - 使用最新状态重新检查一次
const isPrefetched = prefetchedTools.has(toolCode);
// 检查是否是首次访问该工具
const isFirstVisit = !sessionStorage.getItem(`visited-${toolCode}`);
// 记录已访问工具
sessionStorage.setItem(`visited-${toolCode}`, 'true');
// 打印调试信息 - 开发环境
if (process.env.NODE_ENV === 'development') {
console.log(`导航到工具: ${toolCode}, 预加载状态: ${isPrefetched ? '已预加载' : '未预加载'}`);
}
// 如果已预加载,直接导航,不显示loading
if (isPrefetched) {
// 在导航前存储一个标志,表示这是从主页导航过来的
sessionStorage.setItem('from_homepage', 'true');
router.push(`/tools/${toolCode}`);
return;
}
// 在导航前存储一个标志,表示这是从主页导航过来的
sessionStorage.setItem('from_homepage', 'true');
const loadingId = 'loading-indicator-' + Date.now();
const loadingEl = document.createElement('div');
loadingEl.id = loadingId;
loadingEl.className = 'fixed inset-0 flex items-center justify-center bg-[rgba(0,0,0,0.3)] z-50 backdrop-blur-sm transition-opacity duration-300';
// 使用自适应主题的加载指示器
const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--color-bg-card').trim();
const borderColor = getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim();
const textColor = getComputedStyle(document.documentElement).getPropertyValue('--color-text-primary').trim();
loadingEl.innerHTML = `<div style="background-color: rgb(${bgColor}); border: 1px solid rgba(${borderColor}, 0.3);" class="p-4 rounded-lg shadow-lg">
<div class="flex items-center gap-3">
<div style="border-color: rgb(${borderColor}); border-top-color: transparent;" class="w-6 h-6 border-2 rounded-full animate-spin"></div>
<div style="color: rgb(${textColor});">加载中...</div>
</div>
</div>`;
document.body.appendChild(loadingEl);
const removeLoadingIndicator = () => {
const indicator = document.getElementById(loadingId);
if (indicator) {
indicator.classList.add('opacity-0');
setTimeout(() => {
indicator.remove();
}, 200);
}
};
// 立即导航
router.push(`/tools/${toolCode}`);
// 超短超时检测,针对缓存页面快速检测
setTimeout(() => {
// 如果页面已变化并且不是首次访问,很可能是使用了缓存,可以快速移除loading
if (window.location.pathname.includes(`/tools/${toolCode}`) && !isFirstVisit) {
removeLoadingIndicator();
return;
}
// 否则继续进行DOM检测
startDomCheck();
}, 20);
// 启动DOM监测
const startDomCheck = () => {
let checkCount = 0;
const maxChecks = isFirstVisit ? 20 : 10; // 首次访问检查更多次
const baseDelay = isFirstVisit ? 50 : 30; // 首次访问间隔更长
const checkForPageLoad = () => {
// 检查是否已经导航到新页面
if (window.location.pathname.includes(`/tools/${toolCode}`)) {
// 检查页面内容是否已经渲染
const content = document.querySelector('main') || document.querySelector('#tool-content');
if (content) {
removeLoadingIndicator();
return;
}
}
checkCount++;
if (checkCount < maxChecks) {
// 最开始快速检查,逐渐减慢检查频率
const delay = Math.min(baseDelay + checkCount * 5, isFirstVisit ? 100 : 60);
setTimeout(checkForPageLoad, delay);
}
};
// 立即开始检查
checkForPageLoad();
};
// 兜底保障,确保加载指示器一定会被移除
// 对于首次访问等待更长时间,后续访问缩短时间
setTimeout(removeLoadingIndicator, isFirstVisit ? 800 : 300);
};
// 图标加载处理
useEffect(() => {
// 设置小延迟确保图标已加载
const timer = setTimeout(() => {
setIconsLoaded(true);
}, 200);
return () => clearTimeout(timer);
}, []);
// 加载收藏工具和首次收藏状态
useEffect(() => {
const savedFavorites = localStorage.getItem('favoriteTools');
const hasSeenFirstFavoriteNotification = localStorage.getItem('hasSeenFirstFavoriteNotification') === 'true';
setFirstFavoriteAdded(hasSeenFirstFavoriteNotification);
if (savedFavorites) {
setFavoriteTools(JSON.parse(savedFavorites));
setShowFavorites(true);
}
}, []);
// 收藏/取消收藏工具
const toggleFavorite = (toolCode: string, e: React.MouseEvent) => {
e.stopPropagation();
// 检查是否要添加到收藏
const isAdding = !favoriteTools.includes(toolCode);
const updatedFavorites = isAdding
? [...favoriteTools, toolCode]
: favoriteTools.filter(code => code !== toolCode);
setFavoriteTools(updatedFavorites);
localStorage.setItem('favoriteTools', JSON.stringify(updatedFavorites));
// 更新收藏分类的显示状态
setShowFavorites(updatedFavorites.length > 0);
// 如果是添加收藏且是第一次收藏,显示通知
if (isAdding && !firstFavoriteAdded) {
setShowNotification(true);
setFirstFavoriteAdded(true);
localStorage.setItem('hasSeenFirstFavoriteNotification', 'true');
// 5秒后自动关闭通知
setTimeout(() => {
setShowNotification(false);
}, 5000);
}
};
// 关闭通知
const closeNotification = () => {
setShowNotification(false);
};
// 切换到收藏分类
const viewFavorites = () => {
setActiveCategory('favorites');
};
// 过滤工具列表
const filteredTools = () => tools.filter(tool => {
// 根据搜索词过滤
if (searchTerm && !t(`tools.${tool.code}.title`).toLowerCase().includes(searchTerm.toLowerCase()) &&
!t(`tools.${tool.code}.description`).toLowerCase().includes(searchTerm.toLowerCase()) &&
!tool.keywords?.some(keyword => keyword.toLowerCase().includes(searchTerm.toLowerCase()))) {
return false;
}
// 过滤收藏分类
if (activeCategory === 'favorites') {
return favoriteTools.includes(tool.code);
}
// 根据类别过滤
if (activeCategory !== "all" && !tool.category.includes(activeCategory)) {
return false;
}
return true;
}).sort((a, b) => {
// 搜索优先级排序
if (searchTerm) {
// 计算匹配得分 (越高越靠前)
const getScore = (tool: typeof tools[0]) => {
let score = 0;
const term = searchTerm.toLowerCase();
const title = t(`tools.${tool.code}.title`).toLowerCase();
const description = t(`tools.${tool.code}.description`).toLowerCase();
// 标题匹配权重最高
if (title.includes(term)) {
score += 100;
// 标题精确匹配给额外加分
if (title === term) {
score += 50;
}
}
// 关键词匹配权重次之
if (tool.keywords?.some(keyword => keyword.toLowerCase() === term)) {
// 关键词精确匹配
score += 80;
} else if (tool.keywords?.some(keyword => keyword.toLowerCase().includes(term))) {
// 关键词部分匹配
score += 60;
}
// 描述匹配权重稍低
if (description.includes(term)) {
score += 40;
}
return score;
};
const scoreA = getScore(a);
const scoreB = getScore(b);
// 按分数降序排列
if (scoreA !== scoreB) {
return scoreB - scoreA;
}
}
// 在全部工具视图中,将常用工具排在前面
if (activeCategory === "all") {
const aIsCommon = a.category.includes('common');
const bIsCommon = b.category.includes('common');
if (aIsCommon && !bIsCommon) return -1;
if (!aIsCommon && bIsCommon) return 1;
}
return 0;
});
// 构建分类列表(添加动态的"我的收藏"分类)
const allCategories = [
...categories.slice(0, 2), // 全部工具和常用工具
...(showFavorites ? [{ code: "favorites", name: t('common.favorites'), active: false }] : []),
...categories.slice(2) // 其余分类
];
// 更新分类选择处理函数
const handleCategoryChange = (categoryCode: string) => {
setActiveCategory(categoryCode);
// 保存到 sessionStorage
sessionStorage.setItem('lastActiveCategory', categoryCode);
};
return (
<div className={`min-h-screen flex flex-col max-w-[1440px] mx-auto px-4 sm:px-8 py-4 sm:py-6 ${!iconsLoaded ? 'opacity-90' : 'opacity-100 transition-opacity duration-300'}`}>
{/* 通知提示 */}
{showNotification && (
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 flex items-center gap-2 bg-gradient-to-r from-[rgb(var(--color-primary))] to-[rgb(var(--color-primary-hover))] text-white px-4 py-3 rounded-lg shadow-lg animate-fadeIn">
<span>&quot;&quot;</span>
<button
className="ml-2 text-white hover:text-[#F1F5F9] transition-colors"
onClick={closeNotification}
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
)}
{/* 添加预加载进度提示(仅在开发环境显示) */}
{process.env.NODE_ENV === 'development' && loadingProgress.total > 0 && (
<div className="fixed bottom-4 left-4 z-40 bg-[rgb(var(--color-bg-card))] border border-[rgba(var(--color-primary),0.3)] rounded-lg p-2 text-xs opacity-80">
<div className="flex items-center gap-2">
<div>: {loadingProgress.current}/{loadingProgress.total}</div>
<div className="w-20 h-1.5 bg-[rgb(var(--color-bg-secondary))] rounded-full overflow-hidden">
<div
className="h-full bg-[rgb(var(--color-primary))]"
style={{width: `${(loadingProgress.current / loadingProgress.total) * 100}%`}}
></div>
</div>
</div>
</div>
)}
{/* 头部 */}
<header className="sticky top-0 z-10 backdrop-blur-md border-b border-[rgba(var(--color-primary),0.1)] bg-[rgba(var(--color-bg-secondary),0.8)]">
<div className="container mx-auto px-4 py-3 flex flex-col md:flex-row md:items-center md:justify-between">
<div className="flex items-center justify-between">
<div className="flex items-center">
<h1 className="text-2xl font-bold text-[rgb(var(--color-primary))]">
{t('common.siteName')}
</h1>
<span className="mx-2 text-sm text-[rgb(var(--color-text-secondary))]">|</span>
<p className="text-sm text-[rgb(var(--color-text-secondary))]">{t('common.siteDesc')}</p>
</div>
<div className="flex items-center space-x-2 md:hidden">
<button
className="btn-primary w-8 h-8 md:w-10 md:h-10 rounded-full flex items-center justify-center"
onClick={viewFavorites}
title={t('common.favorites')}
>
<FontAwesomeIcon icon={faStar} className="text-sm md:text-base" />
</button>
<div className="w-px h-6 bg-[rgba(var(--color-text-secondary),0.2)]"></div>
<LanguageToggle />
<ThemeToggle />
{language === 'zh' && (
<>
<div className="w-px h-6 bg-[rgba(var(--color-text-secondary),0.2)]"></div>
<div className="relative">
<button
className="btn-secondary w-8 h-8 md:w-10 md:h-10 rounded-full flex items-center justify-center relative"
onClick={toggleProductsDropdown}
title={t('common.productRecommend')}
>
<FontAwesomeIcon icon={faExternalLinkAlt} className="text-sm md:text-base" />
<FontAwesomeIcon icon={faChevronDown} className="absolute text-[0.5rem] md:text-[0.6rem] bottom-0.5 md:bottom-1 right-0.5 md:right-1" />
</button>
</div>
</>
)}
<a
href="https://github.com/star7th/jisuxiang"
target="_blank"
rel="noopener noreferrer"
className="btn-secondary w-8 h-8 md:w-10 md:h-10 rounded-full flex items-center justify-center"
title="GitHub"
>
<FontAwesomeIcon icon={faGithub} className="text-sm md:text-base" />
</a>
</div>
</div>
<div className="mt-3 md:mt-0 relative w-full md:w-auto flex-1 md:max-w-md">
<div className="relative">
<input
type="text"
placeholder={t('common.search')}
className="w-full bg-[rgb(var(--color-bg-card))] rounded-full pl-10 pr-4 py-2 text-[rgb(var(--color-text-primary))] outline-none focus:ring-2 ring-[rgb(var(--color-primary))] transition-shadow shadow-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<FontAwesomeIcon
icon={faSearch}
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[rgb(var(--color-text-secondary))]"
/>
{searchTerm && (
<button
className="absolute right-3 top-1/2 transform -translate-y-1/2"
onClick={() => setSearchTerm('')}
>
<FontAwesomeIcon
icon={faTimes}
className="text-[rgb(var(--color-text-secondary))] hover:text-[rgb(var(--color-text-primary))] transition-colors"
/>
</button>
)}
</div>
</div>
<div className="hidden md:flex items-center space-x-3">
<button
className="btn-primary w-10 h-10 rounded-full flex items-center justify-center group relative"
onClick={viewFavorites}
title={t('common.favorites')}
>
<FontAwesomeIcon icon={faStar} />
<span className="absolute left-1/2 -translate-x-1/2 top-full mt-2 px-2 py-1 rounded-md whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity duration-300 text-sm z-10"
style={{
backgroundColor: 'rgb(var(--color-bg-secondary))',
color: 'rgb(var(--color-text-primary))'
}}>
{t('common.favorites')}
</span>
</button>
<div className="w-px h-6 bg-[rgba(var(--color-text-secondary),0.2)]"></div>
<LanguageToggle />
<ThemeToggle />
<div className="w-px h-6 bg-[rgba(var(--color-text-secondary),0.2)]"></div>
{/* GitHub链接按钮 */}
<a
href="https://github.com/star7th/jisuxiang"
target="_blank"
rel="noopener noreferrer"
className="btn-secondary w-10 h-10 rounded-full flex items-center justify-center group relative"
title="GitHub"
>
<FontAwesomeIcon icon={faGithub} />
<span className="absolute left-1/2 -translate-x-1/2 top-full mt-2 px-2 py-1 rounded-md whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity duration-300 text-sm z-10"
style={{
backgroundColor: 'rgb(var(--color-bg-secondary))',
color: 'rgb(var(--color-text-primary))'
}}>
GitHub
</span>
</a>
{/* 产品推荐下拉菜单 - 仅在中文语言下显示 */}
{language === 'zh' && (
<div id="products-dropdown-container" className="relative">
<button
className="btn-secondary w-10 h-10 rounded-full flex items-center justify-center group relative"
onClick={toggleProductsDropdown}
title={t('common.productRecommend')}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<FontAwesomeIcon icon={faChevronDown} className={`absolute text-[0.6rem] bottom-1 right-1 transition-transform duration-200 ${showProductsDropdown ? 'rotate-180' : ''}`} />
<span className="absolute left-1/2 -translate-x-1/2 top-full mt-2 px-2 py-1 rounded-md whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity duration-300 text-sm z-10"
style={{
backgroundColor: 'rgb(var(--color-bg-secondary))',
color: 'rgb(var(--color-text-primary))'
}}>
{t('common.productRecommend')}
</span>
</button>
{showProductsDropdown && (
<div className="absolute right-0 top-full mt-1 w-56 rounded-md shadow-lg border z-50 animate-fadeIn overflow-hidden"
style={{
backgroundColor: 'rgb(var(--color-bg-card))',
borderColor: 'rgba(var(--color-primary), 0.2)'
}}>
<div className="py-1">
{recommendedProducts.map((product, index) => (
<a
key={index}
href={product.url}
target="_blank"
rel="noopener noreferrer"
className="block px-4 py-3 hover:bg-[rgba(var(--color-primary),0.1)] transition-colors"
style={{color: 'rgb(var(--color-text-primary))'}}
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center" style={{backgroundColor: 'rgba(var(--color-primary), 0.1)'}}>
<FontAwesomeIcon icon={product.icon} style={{color: 'rgb(var(--color-primary))'}} />
</div>
<div>
<div className="font-medium">{product.title}</div>
<div className="text-xs mt-1" style={{color: 'rgb(var(--color-text-tertiary))'}}>{product.description}</div>
</div>
</div>
</a>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
</header>
{/* 主内容区域 */}
<main className="flex-1 container mx-auto">
{/* 分类导航 - 独立导航栏 */}
<div className="flex flex-wrap nav-bar gap-2 sm:gap-4 mb-6 sm:mb-8 mt-6">
{allCategories.map((category, index) => (
<button
key={index}
className={`px-4 py-2 rounded-button whitespace-nowrap transition-all ${
activeCategory === category.code
? 'bg-gradient-to-r from-[rgb(var(--color-primary))] to-[rgb(var(--color-primary-hover))] text-white shadow-sm'
: 'btn-secondary'
}`}
onClick={() => handleCategoryChange(category.code)}
>
{t(`categories.${category.code}`)}
</button>
))}
</div>
{/* 工具列表 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6">
{filteredTools().map((tool, index) => (
<div
key={index}
className="card p-6 flex flex-col cursor-pointer min-h-[170px]"
onClick={() => navigateToTool(tool.code)}
onMouseEnter={() => prefetchTool(tool.code)}
>
{/* 添加不可见的 Link 组件用于 Next.js 原生预加载 */}
<Link
href={`/tools/${tool.code}`}
prefetch={true}
className="hidden"
aria-hidden="true"
/>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<div className="icon-container w-10 h-10 flex-shrink-0">
<FontAwesomeIcon icon={tool.icon} className="icon" />
</div>
<div>
<h3 className="text-lg font-medium" style={{color: 'rgb(var(--color-text-primary))'}}>{t(`tools.${tool.code}.title`)}</h3>
<div className="flex flex-wrap gap-1 mt-2">
{tool.category.map((catCode, catIndex) => (
<span
key={catIndex}
className="category-tag"
>
{t(`categories.${catCode}`)}
</span>
))}
</div>
</div>
</div>
<button
className="transition-colors"
style={{color: 'rgb(var(--color-text-tertiary))'}}
onClick={(e) => toggleFavorite(tool.code, e)}
>
<FontAwesomeIcon
icon={favoriteTools.includes(tool.code) ? faStar : farStar}
className={favoriteTools.includes(tool.code) ? 'text-[rgb(var(--color-primary-light))]' : ''}
/>
</button>
</div>
<p className="text-sm mt-auto" style={{color: 'rgb(var(--color-text-secondary))'}}>{t(`tools.${tool.code}.description`)}</p>
</div>
))}
</div>
</main>
{/* 底部 */}
<footer className="mt-auto py-6 sm:py-8 border-t" style={{borderColor: 'rgb(var(--color-bg-secondary))'}}>
<div className="flex justify-center items-center text-sm" style={{color: 'rgb(var(--color-text-tertiary))'}}>
<span> </span>
<a
href="https://github.com/star7th/jisuxiang"
target="_blank"
rel="noopener noreferrer"
className="mx-1 text-[rgb(var(--color-primary-light))] hover:underline transition-colors"
>
</a>
<span> </span>
</div>
</footer>
</div>
);
}
+744
View File
@@ -0,0 +1,744 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFileCode, faCopy, faCheck, faRedo, faDownload, faExclamationTriangle, faInfoCircle, faCode } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import { useLanguage } from '@/context/LanguageContext';
// 删除未使用的导入
// import BackToTop from '@/components/BackToTop';
// import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
// import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
// import tools from '@/config/tools';
// 添加CSS变量样式
const _styles = {
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
card: "card p-6",
textarea: "w-full h-64 p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple transition-all font-mono resize-y",
label: "text-sm text-secondary font-medium",
error: "p-3 bg-red-900/20 border border-red-700/30 rounded-lg text-error",
info: "text-sm text-tertiary",
success: "p-3 bg-green-900/20 border border-green-700/30 rounded-lg text-success",
actionBtn: "btn-secondary flex items-center gap-2",
actionBtnPrimary: "btn-primary flex items-center gap-2",
loading: "text-purple animate-pulse",
tabButton: "px-3 py-2 text-sm font-medium rounded-md transition-all",
tabButtonActive: "bg-gradient-to-r from-[rgb(var(--color-primary))] to-[rgb(var(--color-primary-hover))] text-white shadow-sm",
tabButtonInactive: "btn-secondary",
tabContainer: "flex items-center rounded-md p-1 bg-block-strong",
flexBetween: "flex flex-col sm:flex-row gap-4 justify-between items-center",
highlighter: "rounded-lg overflow-hidden text-sm font-mono border border-purple-glow/10",
};
// 代码语言类型
type CodeLanguage =
| 'javascript'
| 'typescript'
| 'jsx'
| 'tsx'
| 'html'
| 'css'
| 'json'
| 'markdown'
| 'yaml'
| 'graphql'
| 'sql';
// 语言配置
const languages: { [key in CodeLanguage]: { name: string; parser: string; tabWidth: number } } = {
javascript: { name: 'JavaScript', parser: 'babel', tabWidth: 2 },
typescript: { name: 'TypeScript', parser: 'typescript', tabWidth: 2 },
jsx: { name: 'JSX', parser: 'babel', tabWidth: 2 },
tsx: { name: 'TSX', parser: 'typescript', tabWidth: 2 },
html: { name: 'HTML', parser: 'html', tabWidth: 2 },
css: { name: 'CSS', parser: 'css', tabWidth: 2 },
json: { name: 'JSON', parser: 'json', tabWidth: 2 },
markdown: { name: 'Markdown', parser: 'markdown', tabWidth: 2 },
yaml: { name: 'YAML', parser: 'yaml', tabWidth: 2 },
graphql: { name: 'GraphQL', parser: 'graphql', tabWidth: 2 },
sql: { name: 'SQL', parser: 'sql', tabWidth: 2 },
};
// 格式化选项
interface FormatOptions {
printWidth: number;
tabWidth: number;
useTabs: boolean;
semi: boolean;
singleQuote: boolean;
trailingComma: 'none' | 'es5' | 'all';
bracketSpacing: boolean;
arrowParens: 'avoid' | 'always';
proseWrap: 'always' | 'never' | 'preserve';
}
// 添加全局接口,使prettier可以在window上使用
declare global {
interface Window {
prettier: {
format: (source: string, options: Record<string, unknown>) => string;
[key: string]: unknown;
};
prettierPlugins: {
[key: string]: unknown;
};
}
}
// prettier解析器映射 (这是实际会用到的映射)
const _parserMapping: Record<string, string> = {
javascript: 'babel',
typescript: 'typescript',
html: 'html',
css: 'css',
json: 'json',
xml: 'xml',
sql: 'sql',
java: 'java',
python: 'python',
cpp: 'cpp',
csharp: 'csharp',
php: 'php',
yaml: 'yaml',
markdown: 'markdown',
jsx: 'babel',
golang: 'go',
bash: 'bash',
rust: 'rust'
};
// 加载prettier和对应插件的脚本映射
const _scriptMapping: Record<string, string[]> = {
javascript: ['/lib/prettier/standalone.js', '/lib/prettier/parser-babel.js'],
typescript: ['/lib/prettier/standalone.js', '/lib/prettier/parser-typescript.js'],
html: ['/lib/prettier/standalone.js', '/lib/prettier/parser-html.js'],
css: ['/lib/prettier/standalone.js', '/lib/prettier/parser-postcss.js'],
json: ['/lib/prettier/standalone.js', '/lib/prettier/parser-babel.js'],
xml: ['/lib/prettier/standalone.js', '/lib/prettier/parser-xml.js'],
sql: ['/lib/prettier/standalone.js', '/lib/prettier/parser-sql.js'],
java: ['/lib/prettier/standalone.js', '/lib/prettier/parser-java.js'],
python: ['/lib/prettier/standalone.js', '/lib/prettier/parser-python.js'],
cpp: ['/lib/prettier/standalone.js', '/lib/prettier/parser-cpp.js'],
csharp: ['/lib/prettier/standalone.js', '/lib/prettier/parser-csharp.js'],
php: ['/lib/prettier/standalone.js', '/lib/prettier/parser-php.js'],
yaml: ['/lib/prettier/standalone.js', '/lib/prettier/parser-yaml.js'],
markdown: ['/lib/prettier/standalone.js', '/lib/prettier/parser-markdown.js'],
jsx: ['/lib/prettier/standalone.js', '/lib/prettier/parser-babel.js'],
golang: ['/lib/prettier/standalone.js', '/lib/prettier/parser-go.js'],
bash: ['/lib/prettier/standalone.js', '/lib/prettier/parser-bash.js'],
rust: ['/lib/prettier/standalone.js', '/lib/prettier/parser-rust.js']
};
// CDN备选地址 (保留,以便后续按需加载)
const _cdnScriptMapping: Record<string, string[]> = {
javascript: [
'https://cdn.jsdelivr.net/npm/[email protected]/standalone.js',
'https://cdn.jsdelivr.net/npm/[email protected]/parser-babel.js'
],
typescript: [
'https://cdn.jsdelivr.net/npm/[email protected]/standalone.js',
'https://cdn.jsdelivr.net/npm/[email protected]/parser-typescript.js'
],
html: [
'https://cdn.jsdelivr.net/npm/[email protected]/standalone.js',
'https://cdn.jsdelivr.net/npm/[email protected]/parser-html.js'
],
css: [
'https://cdn.jsdelivr.net/npm/[email protected]/standalone.js',
'https://cdn.jsdelivr.net/npm/[email protected]/parser-postcss.js'
],
json: [
'https://cdn.jsdelivr.net/npm/[email protected]/standalone.js',
'https://cdn.jsdelivr.net/npm/[email protected]/parser-babel.js'
],
yaml: [
'https://cdn.jsdelivr.net/npm/[email protected]/standalone.js',
'https://cdn.jsdelivr.net/npm/[email protected]/parser-yaml.js'
],
markdown: [
'https://cdn.jsdelivr.net/npm/[email protected]/standalone.js',
'https://cdn.jsdelivr.net/npm/[email protected]/parser-markdown.js'
],
jsx: [
'https://cdn.jsdelivr.net/npm/[email protected]/standalone.js',
'https://cdn.jsdelivr.net/npm/[email protected]/parser-babel.js'
],
tsx: [
'https://cdn.jsdelivr.net/npm/[email protected]/standalone.js',
'https://cdn.jsdelivr.net/npm/[email protected]/parser-typescript.js'
],
graphql: [
'https://cdn.jsdelivr.net/npm/[email protected]/standalone.js',
'https://cdn.jsdelivr.net/npm/[email protected]/parser-graphql.js'
]
};
export default function CodeFormatter() {
const { t } = useLanguage();
// 输入与输出
const [inputCode, setInputCode] = useState('');
const [outputCode, setOutputCode] = useState('');
const [selectedLanguage, setSelectedLanguage] = useState<CodeLanguage>('javascript');
// 格式化选项 - 使用默认值,不再提供UI界面修改
const [formatOptions, setFormatOptions] = useState<FormatOptions>({
printWidth: 80,
tabWidth: 2,
useTabs: false,
semi: true,
singleQuote: false,
trailingComma: 'es5',
bracketSpacing: true,
arrowParens: 'always',
proseWrap: 'preserve',
});
// 其他状态
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [isFormatting, setIsFormatting] = useState(false);
const [loadingModules, setLoadingModules] = useState(false);
const [prettierLoaded, setPrettierLoaded] = useState(false);
const [fileMissingWarning, setFileMissingWarning] = useState<string | null>(null);
// 检查Prettier所需的文件是否存在
useEffect(() => {
// 检查核心库
fetch('/lib/prettier/standalone.js')
.then(res => {
if (!res.ok) {
setFileMissingWarning(t('tools.code_formatter.warning_missing_files'));
}
})
.catch(() => {
setFileMissingWarning(t('tools.code_formatter.warning_missing_files'));
});
}, [t]);
// 动态加载Prettier库
useEffect(() => {
if (typeof window === 'undefined' || prettierLoaded) return;
setLoadingModules(true);
// 检查Prettier全局对象是否已存在
if (window.prettier && window.prettierPlugins) {
console.log('Prettier已加载,使用已有实例');
setPrettierLoaded(true);
setLoadingModules(false);
return;
}
// 动态创建脚本标签加载prettier库
const loadPrettier = () => {
// 创建prettier核心脚本
const prettierScript = document.createElement('script');
prettierScript.src = '/lib/prettier/standalone.js';
prettierScript.async = true;
prettierScript.onload = () => {
console.log(t('tools.code_formatter.prettier_core_loaded'));
// 创建babel解析器脚本(也包含estree插件)
const babelScript = document.createElement('script');
babelScript.src = '/lib/prettier/parser-babel.js';
babelScript.async = true;
babelScript.onload = () => {
console.log(t('tools.code_formatter.babel_parser_loaded'));
// 创建html解析器脚本
const htmlScript = document.createElement('script');
htmlScript.src = '/lib/prettier/parser-html.js';
htmlScript.async = true;
htmlScript.onload = () => {
console.log(t('tools.code_formatter.html_parser_loaded'));
// 创建postcss解析器脚本
const cssScript = document.createElement('script');
cssScript.src = '/lib/prettier/parser-postcss.js';
cssScript.async = true;
cssScript.onload = () => {
console.log(t('tools.code_formatter.css_parser_loaded'));
// 创建typescript解析器脚本
const tsScript = document.createElement('script');
tsScript.src = '/lib/prettier/parser-typescript.js';
tsScript.async = true;
tsScript.onload = () => {
console.log(t('tools.code_formatter.typescript_parser_loaded'));
// 创建markdown解析器脚本
const mdScript = document.createElement('script');
mdScript.src = '/lib/prettier/parser-markdown.js';
mdScript.async = true;
mdScript.onload = () => {
console.log(t('tools.code_formatter.markdown_parser_loaded'));
// 创建yaml解析器脚本
const yamlScript = document.createElement('script');
yamlScript.src = '/lib/prettier/parser-yaml.js';
yamlScript.async = true;
yamlScript.onload = () => {
console.log(t('tools.code_formatter.yaml_parser_loaded'));
// 创建graphql解析器脚本
const graphqlScript = document.createElement('script');
graphqlScript.src = '/lib/prettier/parser-graphql.js';
graphqlScript.async = true;
graphqlScript.onload = () => {
console.log(t('tools.code_formatter.graphql_parser_loaded'));
// 所有脚本加载完成
console.log(t('tools.code_formatter.all_modules_loaded'));
setPrettierLoaded(true);
setLoadingModules(false);
};
document.body.appendChild(graphqlScript);
};
document.body.appendChild(yamlScript);
};
document.body.appendChild(mdScript);
};
document.body.appendChild(tsScript);
};
document.body.appendChild(cssScript);
};
document.body.appendChild(htmlScript);
};
document.body.appendChild(babelScript);
};
prettierScript.onerror = (error) => {
console.error(t('tools.code_formatter.load_error'), error);
setError(t('tools.code_formatter.load_failed'));
setLoadingModules(false);
};
document.body.appendChild(prettierScript);
}
// 调用加载函数
loadPrettier();
return () => {
// 清理函数不需要移除脚本,因为它们会一直被缓存和重用
};
}, [prettierLoaded, t]);
// 语言改变时更新 tabWidth
useEffect(() => {
// 根据语言自动设置tabWidth
setFormatOptions(prev => ({
...prev,
tabWidth: languages[selectedLanguage].tabWidth,
}));
}, [selectedLanguage]);
// 格式化代码函数
const formatCode = async () => {
if (!inputCode.trim()) {
setError(t('tools.code_formatter.error_empty_input'));
setOutputCode('');
return;
}
setIsFormatting(true);
setError(null);
try {
// 确保在客户端环境
if (typeof window === 'undefined') {
throw new Error(t('tools.code_formatter.error_browser_only'));
}
// 确保prettier已加载
if (!window.prettier || !window.prettierPlugins) {
throw new Error(t('tools.code_formatter.error_library_loading'));
}
// 获取当前语言的解析器
const parser = languages[selectedLanguage]?.parser;
if (!parser) {
throw new Error(t('tools.code_formatter.error_unsupported_language').replace('{language}', selectedLanguage));
}
// 处理特殊情况
let actualParser = parser;
if (parser === 'json') {
actualParser = 'json';
}
// SQL格式化特殊处理,使用babel解析器
if (parser === 'sql') {
try {
// 简单的SQL格式化处理
const sqlFormatter = inputCode
.replace(/\s+/g, ' ')
.replace(/\(\s+/g, '(')
.replace(/\s+\)/g, ')')
.replace(/\s*,\s*/g, ', ')
.replace(/\s*=\s*/g, ' = ')
.replace(/\s*>\s*/g, ' > ')
.replace(/\s*<\s*/g, ' < ')
.replace(/\s*>\s*=\s*/g, ' >= ')
.replace(/\s*<\s*=\s*/g, ' <= ')
.replace(/\s*!=\s*/g, ' != ')
.replace(/\s*<>\s*/g, ' <> ')
.replace(/SELECT/gi, 'SELECT\n ')
.replace(/FROM/gi, '\nFROM\n ')
.replace(/WHERE/gi, '\nWHERE\n ')
.replace(/GROUP BY/gi, '\nGROUP BY\n ')
.replace(/HAVING/gi, '\nHAVING\n ')
.replace(/ORDER BY/gi, '\nORDER BY\n ')
.replace(/LIMIT/gi, '\nLIMIT ')
.replace(/JOIN/gi, '\nJOIN\n ')
.replace(/UNION/gi, '\n\nUNION\n\n')
.replace(/INSERT INTO/gi, 'INSERT INTO\n ')
.replace(/VALUES/gi, '\nVALUES\n ')
.replace(/UPDATE/gi, 'UPDATE\n ')
.replace(/SET/gi, '\nSET\n ')
.replace(/DELETE FROM/gi, 'DELETE FROM\n ')
.replace(/CREATE TABLE/gi, 'CREATE TABLE\n ')
.replace(/ALTER TABLE/gi, 'ALTER TABLE\n ')
.replace(/DROP TABLE/gi, 'DROP TABLE\n ')
.replace(/AND/gi, '\n AND')
.replace(/OR/gi, '\n OR')
.replace(/ON/gi, '\n ON')
.replace(/\n\s*\n/g, '\n')
.trim();
setOutputCode(sqlFormatter);
return;
} catch (sqlError) {
console.error('SQL格式化错误:', sqlError);
}
}
try {
console.log(t('tools.code_formatter.using_parser'), actualParser);
console.log(t('tools.code_formatter.available_plugins'), Object.keys(window.prettierPlugins));
// 使用全局prettier对象格式化代码
let formattedCode = '';
// 格式化选项
const options = {
parser: actualParser,
plugins: window.prettierPlugins,
printWidth: formatOptions.printWidth,
tabWidth: formatOptions.tabWidth,
useTabs: formatOptions.useTabs,
semi: formatOptions.semi,
singleQuote: formatOptions.singleQuote,
trailingComma: formatOptions.trailingComma,
bracketSpacing: formatOptions.bracketSpacing,
arrowParens: formatOptions.arrowParens,
proseWrap: formatOptions.proseWrap
};
formattedCode = window.prettier.format(inputCode, options);
// 更新输出
setOutputCode(formattedCode);
} catch (prettierError) {
console.error(t('tools.code_formatter.prettier_error_log'), prettierError);
throw new Error(t('tools.code_formatter.error_prettier').replace('{message}', (prettierError as Error)?.message || t('tools.code_formatter.error_initialization')));
}
} catch (err) {
console.error(t('tools.code_formatter.error_formatting').replace('{message}', 'Error'), err);
setError(t('tools.code_formatter.error_formatting').replace('{message}', (err as Error).message || t('tools.code_formatter.error_unknown')));
setOutputCode('');
} finally {
setIsFormatting(false);
}
};
// 复制格式化后的代码
const copyFormattedCode = () => {
if (!outputCode) return;
navigator.clipboard.writeText(outputCode)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => console.error(t('tools.code_formatter.copy_failed'), err));
};
// 下载格式化后的代码
const downloadFormattedCode = () => {
if (!outputCode) return;
// 确定文件扩展名
let extension = '.txt';
switch (selectedLanguage) {
case 'javascript':
extension = '.js';
break;
case 'typescript':
extension = '.ts';
break;
case 'jsx':
extension = '.jsx';
break;
case 'tsx':
extension = '.tsx';
break;
case 'html':
extension = '.html';
break;
case 'css':
extension = '.css';
break;
case 'json':
extension = '.json';
break;
case 'markdown':
extension = '.md';
break;
case 'yaml':
extension = '.yaml';
break;
case 'graphql':
extension = '.graphql';
break;
case 'sql':
extension = '.sql';
break;
}
// 创建并下载文件
const blob = new Blob([outputCode], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `formatted_code${extension}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// 清除所有内容
const clearAll = () => {
setInputCode('');
setOutputCode('');
setError(null);
};
// 示例代码
const getExampleCode = () => {
switch (selectedLanguage) {
case 'javascript':
return `function add(a,b) {return a+b;}\nconst x={foo:"bar",baz:42,qux:true};\nconsole.log(add(1,2));`;
case 'typescript':
return `function greet(name: string): string {return "Hello, " + name;}\ninterface User {id: number; name: string; isActive: boolean;}\nconst user: User = {id: 1,name: "John",isActive: true};`;
case 'jsx':
return `function App() {return (<div className="container"><header><h1>Hello World</h1></header><main><p>Welcome to my app</p></main></div>);}`;
case 'tsx':
return `interface Props {name: string;}\nfunction Greeting({name}: Props) {return <h1>Hello, {name}!</h1>;}\nconst App = () => (<div><Greeting name="World" /><p>Welcome to TypeScript and React</p></div>);`;
case 'html':
return `<!DOCTYPE html><html><head><title>Document</title></head><body><div><h1>Hello World</h1><p>This is a paragraph</p></div></body></html>`;
case 'css':
return `.container { width: 100%; max-width: 1200px; margin: 0 auto; }\n.header { background-color: #f0f0f0; padding: 20px; }\n.button { display: inline-block; padding: 10px 15px; background: #4285f4; color: white; border-radius: 4px; }`;
case 'json':
return `{"name":"John","age":30,"isStudent":false,"courses":["Math","English","Science"],"address":{"street":"123 Main St","city":"Anytown","zip":"12345"}}`;
case 'markdown':
return `# Heading\n## Subheading\nThis is a paragraph with **bold** and *italic* text.\n- List item 1\n- List item 2\n> This is a blockquote.\n\`\`\`\ncode block\n\`\`\``;
case 'yaml':
return `server:\n port: 8080\n host: localhost\ndatabase:\n url: jdbc:mysql://localhost:3306/mydb\n username: root\n password: secret\nlogging:\n level: INFO`;
case 'graphql':
return `type Query {\n user(id: ID!): User\n users: [User!]!\n}\n\ntype User {\n id: ID!\n name: String!\n email: String\n posts: [Post!]\n}\n\ntype Post {\n id: ID!\n title: String!\n content: String\n author: User!\n}`;
case 'sql':
return `SELECT u.id, u.name, u.email, COUNT(o.id) as order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.status = 'active' AND o.created_at >= '2023-01-01' GROUP BY u.id, u.name, u.email HAVING COUNT(o.id) > 0 ORDER BY order_count DESC LIMIT 10;`;
default:
return `// 请输入要格式化的代码`;
}
};
// 加载示例代码
const loadExample = () => {
setInputCode(getExampleCode());
setOutputCode('');
setError(null);
};
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
<ToolHeader
title={t('tools.code_formatter.title')}
description={t('tools.code_formatter.description')}
icon={faFileCode}
toolCode="code_formatter"
/>
{/* 主内容区 */}
<div className="space-y-6">
{/* 控制面板 */}
<div className="card p-4 overflow-hidden">
<div className="flex flex-wrap items-center justify-between gap-4 mb-4">
<div className="flex items-center gap-4">
<h2 className="text-md font-medium text-primary">{t('tools.code_formatter.title')}</h2>
{/* 语言选择 */}
<div className="relative">
<select
value={selectedLanguage}
onChange={(e) => setSelectedLanguage(e.target.value as CodeLanguage)}
className="bg-block text-primary border border-purple-glow/30 rounded-md px-3 py-1.5 text-sm appearance-none pr-8"
>
{Object.entries(languages).map(([key, { name }]) => (
<option key={key} value={key}>{name}</option>
))}
</select>
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-tertiary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 11l-4-4h8l-4 4z" />
</svg>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
className="btn-secondary text-xs px-3 py-1.5"
onClick={loadExample}
>
{t('tools.code_formatter.load_example')}
</button>
<button
className="btn-secondary text-xs px-3 py-1.5"
onClick={clearAll}
>
{t('tools.code_formatter.clear')}
</button>
</div>
</div>
{/* 移除格式化选项面板 */}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 输入区域 */}
<div className="flex flex-col h-full">
<h2 className="text-md font-medium text-primary mb-2">{t('tools.code_formatter.input_code')}</h2>
<div className="relative flex-grow">
<textarea
value={inputCode}
onChange={(e) => setInputCode(e.target.value)}
placeholder={t('tools.code_formatter.input_placeholder').replace('{language}', languages[selectedLanguage].name)}
className="bg-block text-primary border border-purple-glow/30 rounded-md p-4 w-full h-[400px] font-mono text-sm resize-none"
/>
</div>
</div>
{/* 输出区域 */}
<div className="flex flex-col h-full">
<div className="flex items-center justify-between mb-2">
<h2 className="text-md font-medium text-primary">{t('tools.code_formatter.formatted_result')}</h2>
<div className="flex items-center gap-2">
<button
className="text-tertiary hover:text-purple transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={copyFormattedCode}
disabled={!outputCode}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} className="mr-1" />
{copied ? t('tools.code_formatter.copied') : t('tools.code_formatter.copy')}
</button>
<button
className="text-tertiary hover:text-purple transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={downloadFormattedCode}
disabled={!outputCode}
>
<FontAwesomeIcon icon={faDownload} className="mr-1" />
{t('tools.code_formatter.download')}
</button>
</div>
</div>
<div className="relative flex-grow">
{error ? (
<div className="bg-red-900/20 border border-red-700/30 rounded-md p-4 text-sm text-error flex items-start gap-2">
<FontAwesomeIcon icon={faExclamationTriangle} className="mt-0.5" />
<div>
<p className="font-medium mb-1">{t('tools.code_formatter.formatting_error_title')}</p>
<p>{error}</p>
</div>
</div>
) : outputCode ? (
<pre className="bg-block text-primary border border-purple-glow/30 rounded-md p-4 w-full h-[400px] font-mono text-sm overflow-auto whitespace-pre">{outputCode}</pre>
) : (
<div className="bg-block text-tertiary border border-purple-glow/30 rounded-md p-4 w-full h-[400px] flex flex-col items-center justify-center text-center">
<FontAwesomeIcon icon={faCode} className="text-3xl mb-2" />
<p>{t('tools.code_formatter.result_placeholder')}</p>
<p className="text-xs mt-2">{t('tools.code_formatter.click_format')}</p>
</div>
)}
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="flex justify-center mt-6">
<button
className="btn-primary px-6 py-2 flex items-center"
onClick={formatCode}
disabled={isFormatting || !inputCode || !prettierLoaded}
>
{isFormatting ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{t('tools.code_formatter.processing')}
</>
) : loadingModules ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{t('tools.code_formatter.loading_library')}
</>
) : (
<>
<FontAwesomeIcon icon={faRedo} className="mr-2" />
{t('tools.code_formatter.format')}
</>
)}
</button>
</div>
{loadingModules && !error && (
<div className="mt-4 text-center">
<p className="text-sm text-tertiary">{t('tools.code_formatter.first_time_loading')}</p>
</div>
)}
{fileMissingWarning && (
<div className="mt-4 bg-yellow-900/10 border border-yellow-700/30 rounded-md p-3 text-sm text-warning">
<p className="flex items-start gap-2">
<FontAwesomeIcon icon={faExclamationTriangle} className="mt-0.5" />
<span>{fileMissingWarning}</span>
</p>
</div>
)}
{/* 帮助信息 */}
<div className="bg-block-strong bg-opacity-60 rounded-md p-4 border border-purple-glow/15">
<h3 className="text-sm font-medium text-primary flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faInfoCircle} />
{t('tools.code_formatter.usage_guide')}
</h3>
<ul className="text-xs text-secondary space-y-1">
<li> {t('tools.code_formatter.usage_step1')}</li>
<li> {t('tools.code_formatter.usage_step2')}</li>
<li> {t('tools.code_formatter.usage_step3')}</li>
<li> {t('tools.code_formatter.usage_step4')}</li>
</ul>
</div>
</div>
</div>
);
}
+604
View File
@@ -0,0 +1,604 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPalette, faCopy, faCheck, faPlus, faTrash, faRandom, faInfoCircle } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import { useLanguage } from '@/context/LanguageContext';
// 颜色格式类型
type ColorFormat = 'hex' | 'rgb' | 'hsl';
// 调色板颜色类型
interface PaletteColor {
id: string;
hex: string;
name: string;
}
// 添加CSS变量样式
const styles = {
card: "card p-6",
heading: "text-lg font-medium text-primary mb-4",
subheading: "text-md font-medium text-primary mb-4",
secondaryText: "text-sm text-tertiary",
iconButton: "text-secondary hover:text-primary transition-colors",
deleteBtn: "text-secondary hover:text-error transition-colors",
colorInput: "w-16 h-16 cursor-pointer rounded-md overflow-hidden border-0",
colorTextInput: "w-full px-3 py-2 bg-block border border-purple-glow rounded-md text-primary pr-10",
formatButton: (active: boolean) => `text-xs px-3 py-1 ${active ? 'bg-purple-glow/20 text-purple' : 'bg-block-strong text-secondary'}`,
paletteItem: "flex items-center justify-between p-2 rounded-md hover:bg-block-hover transition-colors",
paletteColorBox: "w-8 h-8 rounded-md cursor-pointer border border-block-strong",
colorLabel: "text-sm text-primary",
colorValue: "text-xs text-secondary font-mono",
colorContrastBox: "p-3 rounded-md text-center",
colorPreview: "h-32 rounded-md mb-4 flex items-center justify-center relative overflow-hidden",
colorPreviewText: "bg-black bg-opacity-40 px-4 py-2 rounded-md text-white",
colorShadesBox: "h-16 rounded-md flex items-center justify-center transition-all duration-200 cursor-pointer hover:transform hover:scale-105",
harmonicColorBox: "h-24 rounded-md flex items-center justify-center transition-transform cursor-pointer hover:scale-105",
}
export default function ColorTools() {
const { t, language } = useLanguage();
// 主颜色输入
const [mainColor, setMainColor] = useState('#6366F1');
const [mainColorFormat, setMainColorFormat] = useState<ColorFormat>('hex');
// 颜色展示
const [colorValues, setColorValues] = useState({
hex: '#6366F1',
rgb: 'rgb(99, 102, 241)',
hsl: 'hsl(239, 84%, 67%)',
});
// 亮暗变体
const [colorShades, setColorShades] = useState<string[]>([]);
// 互补和谐色
const [complementaryColors, setComplementaryColors] = useState<string[]>([]);
const [analogousColors, setAnalogousColors] = useState<string[]>([]);
// 调色板
const [palette, setPalette] = useState<PaletteColor[]>([]);
const [showPaletteInput, setShowPaletteInput] = useState(false);
const [newPaletteName, setNewPaletteName] = useState('');
// 复制状态
const [copiedFormat, setCopiedFormat] = useState<string | null>(null);
// 默认示例调色板
const getExamplePalette = () => [
{ id: 'primary', hex: '#6366F1', name: t('tools.color_tools.example_palette.primary') },
{ id: 'secondary', hex: '#8B5CF6', name: t('tools.color_tools.example_palette.secondary') },
{ id: 'accent', hex: '#EC4899', name: t('tools.color_tools.example_palette.accent') },
{ id: 'dark', hex: '#1E293B', name: t('tools.color_tools.example_palette.dark') },
{ id: 'light', hex: '#F1F5F9', name: t('tools.color_tools.example_palette.light') },
];
// 初始化
useEffect(() => {
// 从本地存储加载调色板或使用示例
const savedPalette = localStorage.getItem('colorPalette');
if (savedPalette) {
setPalette(JSON.parse(savedPalette));
} else {
setPalette(getExamplePalette());
}
// 初始化颜色计算
updateColorValues(mainColor);
}, [language]);
// 当主颜色改变时,更新所有颜色值
useEffect(() => {
updateColorValues(mainColor);
}, [mainColor]);
// 使用特性的展示 - 在组件顶部定义可能用到的功能列表
const features = [
'HEX、RGB、HSL三种颜色格式的转换和复制',
'亮度色阶展示,快速选择不同亮度的相同颜色',
'互补色和邻近色展示,帮助设计和谐的配色方案',
'个性化调色板保存,支持添加和删除自定义颜色'
];
// 更新颜色值
const updateColorValues = (hexColor: string) => {
try {
// 验证颜色格式
if (!/^#[0-9A-Fa-f]{6}$/.test(hexColor)) {
return;
}
// 计算 RGB
const r = parseInt(hexColor.slice(1, 3), 16);
const g = parseInt(hexColor.slice(3, 5), 16);
const b = parseInt(hexColor.slice(5, 7), 16);
// RGB 格式
const rgbValue = `rgb(${r}, ${g}, ${b})`;
// 计算 HSL
const rNorm = r / 255;
const gNorm = g / 255;
const bNorm = b / 255;
const max = Math.max(rNorm, gNorm, bNorm);
const min = Math.min(rNorm, gNorm, bNorm);
const diff = max - min;
let h = 0;
if (max === min) {
h = 0;
} else if (max === rNorm) {
h = ((gNorm - bNorm) / diff + (gNorm < bNorm ? 6 : 0)) * 60;
} else if (max === gNorm) {
h = ((bNorm - rNorm) / diff + 2) * 60;
} else {
h = ((rNorm - gNorm) / diff + 4) * 60;
}
const l = (max + min) / 2;
const s = diff === 0 ? 0 : diff / (1 - Math.abs(2 * l - 1));
// HSL 格式
const hslValue = `hsl(${Math.round(h)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`;
// 更新所有颜色值
setColorValues({
hex: hexColor,
rgb: rgbValue,
hsl: hslValue,
});
// 计算颜色变体
calculateColorShades(hexColor);
// 计算互补和谐色
calculateComplementary(h, s, l);
calculateAnalogous(h, s, l);
} catch (error) {
console.error(t('tools.color_tools.copy_failed'), error);
}
};
// 计算颜色的亮度变体
const calculateColorShades = (hexColor: string) => {
const shades: string[] = [];
// 解析颜色
const r = parseInt(hexColor.slice(1, 3), 16);
const g = parseInt(hexColor.slice(3, 5), 16);
const b = parseInt(hexColor.slice(5, 7), 16);
// 创建9个亮度变体(从10%到90%)
for (let i = 0.1; i <= 0.9; i += 0.1) {
// 亮度变化
const factor = i < 0.5 ? i * 2 : 1; // 暗色处理
// 调整颜色
const newR = Math.round(r * factor);
const newG = Math.round(g * factor);
const newB = Math.round(b * factor);
// 转换回十六进制
const newHex = `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
shades.push(newHex);
}
setColorShades(shades.reverse()); // 从暗到亮排序
};
// 计算互补色
const calculateComplementary = (h: number, s: number, l: number) => {
const complementaryH = (h + 180) % 360;
const complementaryRgb = hslToRgb(complementaryH / 360, s, l);
const complementaryHex = rgbToHex(complementaryRgb[0], complementaryRgb[1], complementaryRgb[2]);
setComplementaryColors([complementaryHex]);
};
// 计算邻近和谐色
const calculateAnalogous = (h: number, s: number, l: number) => {
const analogous: string[] = [];
// 计算邻近色(-30°, +30°)
const angles = [-30, 30];
for (const angle of angles) {
const newH = (h + angle + 360) % 360;
const analogousRgb = hslToRgb(newH / 360, s, l);
const analogousHex = rgbToHex(analogousRgb[0], analogousRgb[1], analogousRgb[2]);
analogous.push(analogousHex);
}
setAnalogousColors(analogous);
};
// HSL 转 RGB 辅助函数
const hslToRgb = (h: number, s: number, l: number): [number, number, number] => {
let r, g, b;
if (s === 0) {
r = g = b = l; // 灰色
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
return [
Math.round(r * 255),
Math.round(g * 255),
Math.round(b * 255)
];
};
// RGB转Hex辅助函数
const rgbToHex = (r: number, g: number, b: number): string => {
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
};
// 处理主颜色输入变化
const handleColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setMainColor(e.target.value);
};
// 处理格式选择
const handleFormatChange = (format: ColorFormat) => {
setMainColorFormat(format);
};
// 复制颜色值
const copyColorValue = (format: string) => {
const textToCopy = colorValues[format as keyof typeof colorValues];
try {
navigator.clipboard.writeText(textToCopy).then(() => {
setCopiedFormat(format);
setTimeout(() => setCopiedFormat(null), 2000);
});
} catch (err) {
console.error(t('tools.color_tools.copy_failed'), err);
}
};
// 添加到调色板
const addToPalette = () => {
if (!newPaletteName.trim()) return;
const newColor: PaletteColor = {
id: Date.now().toString(),
hex: mainColor,
name: newPaletteName.trim()
};
const updatedPalette = [...palette, newColor];
setPalette(updatedPalette);
// 保存到本地存储
try {
localStorage.setItem('colorPalette', JSON.stringify(updatedPalette));
} catch (err) {
console.error(t('tools.color_tools.save_palette_error'), err);
}
setNewPaletteName('');
setShowPaletteInput(false);
};
// 从调色板删除颜色
const removeFromPalette = (id: string) => {
const updatedPalette = palette.filter(color => color.id !== id);
setPalette(updatedPalette);
// 更新本地存储
localStorage.setItem('colorPalette', JSON.stringify(updatedPalette));
};
// 从调色板选择颜色
const selectFromPalette = (hex: string) => {
setMainColor(hex);
};
// 生成随机颜色
const generateRandomColor = () => {
const randomHex = `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0')}`;
setMainColor(randomHex);
};
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
<ToolHeader
toolCode="color_tools"
icon={faPalette}
title=""
description=""
/>
{/* 主内容区 */}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* 左侧面板 - 调色板 */}
<div className="lg:col-span-2 space-y-6">
{/* 颜色选择和格式 */}
<div className={styles.card}>
<div className="flex items-center justify-between mb-4">
<h2 className={styles.subheading}>{t('tools.color_tools.color_selection')}</h2>
<button
className={styles.iconButton}
onClick={generateRandomColor}
>
<FontAwesomeIcon icon={faRandom} className="mr-1" />
{t('tools.color_tools.random_color')}
</button>
</div>
<div className="flex flex-row gap-4">
<input
type="color"
value={mainColor}
onChange={handleColorChange}
className={styles.colorInput}
/>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<div className="flex rounded-md overflow-hidden border border-block-strong">
{(['hex', 'rgb', 'hsl'] as ColorFormat[]).map((format) => (
<button
key={format}
className={styles.formatButton(mainColorFormat === format)}
onClick={() => handleFormatChange(format)}
>
{t(`tools.color_tools.color_formats.${format}`)}
</button>
))}
</div>
</div>
<div className="relative">
<input
type="text"
value={colorValues[mainColorFormat]}
readOnly
className={styles.colorTextInput}
/>
<button
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary hover:text-primary transition-colors"
onClick={() => copyColorValue(mainColorFormat)}
>
<FontAwesomeIcon icon={copiedFormat === mainColorFormat ? faCheck : faCopy} />
</button>
</div>
</div>
</div>
</div>
{/* 调色板 */}
<div className={styles.card}>
<div className="flex items-center justify-between mb-4">
<h2 className={styles.subheading}>{t('tools.color_tools.color_palette')}</h2>
<div className="flex items-center gap-2">
<button
className={styles.iconButton}
onClick={() => setShowPaletteInput(true)}
>
<FontAwesomeIcon icon={faPlus} className="mr-1" />
{t('tools.color_tools.palette_actions.add_color')}
</button>
</div>
</div>
{/* 添加新颜色 */}
{showPaletteInput && (
<div className="bg-block-hover rounded-md p-3 mb-4">
<div className="flex items-start gap-3">
<div
className="w-10 h-10 rounded-md border border-block-strong"
style={{ backgroundColor: mainColor }}
></div>
<div className="flex-1">
<label className="block text-sm text-secondary mb-1">
{t('tools.color_tools.palette_color_name')}
</label>
<input
type="text"
className="w-full px-2 py-1 bg-block border border-block-strong rounded-md text-primary text-sm"
placeholder={t('tools.color_tools.palette_color_name_input')}
value={newPaletteName}
onChange={(e) => setNewPaletteName(e.target.value)}
autoFocus
/>
</div>
</div>
<div className="flex justify-end mt-3 gap-2">
<button
className="text-xs px-3 py-1 bg-block-strong text-secondary rounded-md"
onClick={() => {
setShowPaletteInput(false);
setNewPaletteName('');
}}
>
{t('tools.color_tools.cancel')}
</button>
<button
className="text-xs px-3 py-1 bg-purple-glow/20 text-purple rounded-md"
onClick={addToPalette}
>
{t('tools.color_tools.add')}
</button>
</div>
</div>
)}
{/* 颜色列表 */}
<div className="space-y-1 max-h-[300px] overflow-y-auto">
{palette.map((color) => (
<div key={color.id} className={styles.paletteItem}>
<div className="flex items-center gap-2">
<div
className={styles.paletteColorBox}
style={{ backgroundColor: color.hex }}
onClick={() => selectFromPalette(color.hex)}
></div>
<div>
<div className={styles.colorLabel}>{color.name}</div>
<div className={styles.colorValue}>{color.hex}</div>
</div>
</div>
<button
className={styles.deleteBtn}
onClick={() => removeFromPalette(color.id)}
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
))}
</div>
</div>
</div>
{/* 右侧面板 - 颜色变体 */}
<div className="lg:col-span-3 space-y-6">
{/* 颜色预览 */}
<div className={styles.card}>
<h2 className={styles.subheading}>{t('tools.color_tools.color_preview')}</h2>
<div
className={styles.colorPreview}
style={{ backgroundColor: mainColor }}
>
<div className={styles.colorPreviewText}>
{colorValues[mainColorFormat]}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<h3 className="text-sm text-primary mb-2">{t('tools.color_tools.contrast_effects')}</h3>
<div className="space-y-2">
<div
className={styles.colorContrastBox}
style={{ backgroundColor: mainColor, color: '#FFFFFF' }}
>
{t('tools.color_tools.white_text')}
</div>
<div
className={styles.colorContrastBox}
style={{ backgroundColor: mainColor, color: '#000000' }}
>
{t('tools.color_tools.black_text')}
</div>
</div>
</div>
<div>
<h3 className="text-sm text-primary mb-2">{t('tools.color_tools.border_background')}</h3>
<div className="space-y-2">
<div
className={styles.colorContrastBox + " bg-block"}
style={{ border: `2px solid ${mainColor}` }}
>
<span className="text-primary">{t('tools.color_tools.border_effect')}</span>
</div>
<div
className={styles.colorContrastBox}
style={{ backgroundColor: `${mainColor}40` }}
>
<span className="text-primary">{t('tools.color_tools.transparent_background')}</span>
</div>
</div>
</div>
</div>
</div>
{/* 颜色变体 */}
<div className={styles.card}>
<h2 className={styles.subheading}>{t('tools.color_tools.color_variants')}</h2>
<div className="grid grid-cols-9 gap-1 mb-6">
{colorShades.map((shade, index) => (
<div
key={index}
className={styles.colorShadesBox}
style={{ backgroundColor: shade }}
onClick={() => setMainColor(shade)}
title={shade}
>
<span className="text-xs font-mono text-white bg-black bg-opacity-30 px-1 rounded">
{index * 10 + 10}%
</span>
</div>
))}
</div>
<div className="mb-6">
<h3 className="text-sm text-primary mb-2">{t('tools.color_tools.complementary_color')}</h3>
<div className="grid grid-cols-1 gap-2">
{complementaryColors.map((color, index) => (
<div
key={index}
className={styles.harmonicColorBox}
style={{ backgroundColor: color }}
onClick={() => setMainColor(color)}
>
<span className="text-sm font-mono text-white bg-black bg-opacity-30 px-2 py-1 rounded">
{color}
</span>
</div>
))}
</div>
</div>
<div>
<h3 className="text-sm text-primary mb-2">{t('tools.color_tools.analogous_harmony')}</h3>
<div className="grid grid-cols-2 gap-2">
{analogousColors.map((color, index) => (
<div
key={index}
className={styles.harmonicColorBox}
style={{ backgroundColor: color }}
onClick={() => setMainColor(color)}
>
<span className="text-sm font-mono text-white bg-black bg-opacity-30 px-2 py-1 rounded">
{color}
</span>
</div>
))}
</div>
</div>
</div>
{/* 说明 */}
<div className={styles.card}>
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faInfoCircle} className="text-purple" />
<h2 className="text-primary font-medium">{t('tools.color_tools.usage_guide.title')}</h2>
</div>
<p className={styles.secondaryText}>
{t('tools.color_tools.usage_guide.content')}
</p>
<ul className="list-disc pl-5 text-sm text-tertiary">
{features.map((feature, index) => (
<li key={index}>{t(`tools.color_tools.usage_guide.features.${index}`) || feature}</li>
))}
</ul>
</div>
</div>
</div>
{/* 回到顶部按钮 */}
<BackToTop position="bottom-right" offset={30} size="medium" />
</div>
);
}
+448
View File
@@ -0,0 +1,448 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faLock,
faCopy,
faCheck,
faInfoCircle,
faRedo,
faEraser
} from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import * as CryptoJS from 'crypto-js';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
card: "card p-6",
heading: "text-lg font-medium text-primary mb-1",
label: "block text-sm text-secondary font-medium mb-1",
input: "w-full p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple transition-all",
textarea: "w-full h-36 p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple transition-all font-mono resize-y",
actionBtn: "btn-secondary flex items-center gap-2",
actionBtnPrimary: "btn-primary flex items-center gap-2",
secondaryText: "text-sm text-tertiary",
error: "p-3 bg-red-900/20 border border-red-700/30 rounded-lg text-error",
success: "p-3 bg-green-900/20 border border-green-700/30 rounded-lg text-success",
tabButton: "px-3 py-2 text-sm font-medium transition-all",
activeTab: "bg-block text-primary shadow-sm",
inactiveTab: "text-tertiary hover:text-secondary",
flexBetween: "flex flex-col sm:flex-row gap-4 justify-between items-center",
twoColumns: "grid grid-cols-1 md:grid-cols-2 gap-6",
};
// 加密算法类型
type CryptoType = 'md5' | 'sha1' | 'sha256' | 'sha512' | 'aes' | 'base64';
export default function CryptoTools() {
// 使用多语言支持
const { t } = useLanguage();
// 状态管理
const [activeAlgorithm, setActiveAlgorithm] = useState<CryptoType>('md5');
const [inputText, setInputText] = useState('');
const [secretKey, setSecretKey] = useState('');
const [output, setOutput] = useState('');
const [isDecoding, setIsDecoding] = useState(false);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
// 清除状态提示的定时器
useEffect(() => {
let timer: NodeJS.Timeout;
if (error || success) {
timer = setTimeout(() => {
setError(null);
setSuccess(null);
}, 3000);
}
return () => {
if (timer) clearTimeout(timer);
};
}, [error, success]);
// 当算法更改时,重置解码状态
useEffect(() => {
if (!algorithms[activeAlgorithm].isEncodeDecode) {
setIsDecoding(false);
}
setOutput('');
setError(null);
}, [activeAlgorithm]);
// 处理加密或解密操作
const processOperation = () => {
setError(null);
setSuccess(null);
setOutput('');
if (!inputText.trim()) {
setError(t('tools.crypto_tools.input_required'));
return;
}
if (algorithms[activeAlgorithm].needsKey && !secretKey.trim()) {
setError(t('tools.crypto_tools.key_required'));
return;
}
try {
let result = '';
switch (activeAlgorithm) {
case 'md5':
result = CryptoJS.MD5(inputText).toString();
break;
case 'sha1':
result = CryptoJS.SHA1(inputText).toString();
break;
case 'sha256':
result = CryptoJS.SHA256(inputText).toString();
break;
case 'sha512':
result = CryptoJS.SHA512(inputText).toString();
break;
case 'aes':
if (isDecoding) {
// 解密操作
try {
const decrypted = CryptoJS.AES.decrypt(inputText, secretKey);
result = decrypted.toString(CryptoJS.enc.Utf8);
if (!result) {
throw new Error(t('tools.crypto_tools.decryption_failed'));
}
} catch {
throw new Error(t('tools.crypto_tools.decryption_failed'));
}
} else {
// 加密操作
result = CryptoJS.AES.encrypt(inputText, secretKey).toString();
}
break;
case 'base64':
if (isDecoding) {
// Base64解码
try {
result = CryptoJS.enc.Base64.parse(inputText).toString(CryptoJS.enc.Utf8);
} catch {
throw new Error(t('tools.crypto_tools.base64_decode_failed'));
}
} else {
// Base64编码
result = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(inputText));
}
break;
}
setOutput(result);
setSuccess(isDecoding ? t('tools.crypto_tools.decryption_success') : t('tools.crypto_tools.encryption_success'));
} catch (err) {
console.error('处理错误:', err);
setError(`${isDecoding ? t('tools.crypto_tools.decrypt') : t('tools.crypto_tools.encrypt')}失败: ${err instanceof Error ? err.message : '未知错误'}`);
}
};
// 复制结果到剪贴板
const copyToClipboard = () => {
if (!output) return;
navigator.clipboard.writeText(output)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => {
console.error('复制失败:', err);
setError(t('tools.crypto_tools.copy_failed'));
});
};
// 清空所有内容
const clearAll = () => {
setInputText('');
setSecretKey('');
setOutput('');
setError(null);
setSuccess(null);
};
// 加载示例
const loadExample = () => {
const examples: Record<CryptoType, { input: string; key?: string }> = {
md5: { input: 'Hello, World!' },
sha1: { input: 'Hello, World!' },
sha256: { input: 'Hello, World!' },
sha512: { input: 'Hello, World!' },
aes: { input: 'Hello, World!', key: 'secret-key-12345' },
base64: { input: 'Hello, World!' }
};
const example = examples[activeAlgorithm];
setInputText(example.input);
if (example.key) {
setSecretKey(example.key);
}
setOutput('');
setError(null);
setSuccess(null);
};
// 加密算法信息映射
const algorithms = {
md5: {
name: t('tools.crypto_tools.algorithms.md5.name'),
description: t('tools.crypto_tools.algorithms.md5.description'),
needsKey: false,
isEncodeDecode: false,
},
sha1: {
name: t('tools.crypto_tools.algorithms.sha1.name'),
description: t('tools.crypto_tools.algorithms.sha1.description'),
needsKey: false,
isEncodeDecode: false,
},
sha256: {
name: t('tools.crypto_tools.algorithms.sha256.name'),
description: t('tools.crypto_tools.algorithms.sha256.description'),
needsKey: false,
isEncodeDecode: false,
},
sha512: {
name: t('tools.crypto_tools.algorithms.sha512.name'),
description: t('tools.crypto_tools.algorithms.sha512.description'),
needsKey: false,
isEncodeDecode: false,
},
aes: {
name: t('tools.crypto_tools.algorithms.aes.name'),
description: t('tools.crypto_tools.algorithms.aes.description'),
needsKey: true,
isEncodeDecode: true,
},
base64: {
name: t('tools.crypto_tools.algorithms.base64.name'),
description: t('tools.crypto_tools.algorithms.base64.description'),
needsKey: false,
isEncodeDecode: true,
}
};
return (
<div className={styles.container}>
{/* 工具头部 */}
<ToolHeader
icon={faLock}
toolCode="crypto_tools"
title=""
description=""
/>
{/* 主内容区域 */}
<div className={styles.card}>
<div className="space-y-6">
{/* 算法选择 */}
<div className="bg-block-strong p-1 rounded-md flex flex-wrap">
{Object.entries(algorithms).map(([key, algo]) => (
<button
key={key}
className={`${styles.tabButton} ${activeAlgorithm === key ? styles.activeTab : styles.inactiveTab}`}
onClick={() => setActiveAlgorithm(key as CryptoType)}
>
{algo.name}
</button>
))}
</div>
{/* 算法描述 */}
<div className={styles.secondaryText}>
{algorithms[activeAlgorithm].description}
</div>
{/* 操作按钮和状态切换 */}
<div className={styles.flexBetween}>
{/* 编码/解码切换 */}
{algorithms[activeAlgorithm].isEncodeDecode && (
<div className="flex items-center bg-block-strong rounded-md p-1">
<button
className={`${styles.tabButton} ${!isDecoding ? styles.activeTab : styles.inactiveTab}`}
onClick={() => setIsDecoding(false)}
>
{activeAlgorithm === 'base64' ? t('tools.crypto_tools.encode') : t('tools.crypto_tools.encrypt')}
</button>
<button
className={`${styles.tabButton} ${isDecoding ? styles.activeTab : styles.inactiveTab}`}
onClick={() => setIsDecoding(true)}
>
{activeAlgorithm === 'base64' ? t('tools.crypto_tools.decode') : t('tools.crypto_tools.decrypt')}
</button>
</div>
)}
<div className="flex gap-2">
<button
onClick={loadExample}
className={styles.actionBtn}
>
<FontAwesomeIcon icon={faRedo} />
{t('tools.crypto_tools.load_example')}
</button>
<button
onClick={clearAll}
className={styles.actionBtn}
disabled={!inputText}
>
<FontAwesomeIcon icon={faEraser} />
{t('tools.crypto_tools.clear')}
</button>
</div>
</div>
{/* 输入输出部分 */}
<div className={styles.twoColumns}>
{/* 输入区域 */}
<div className="space-y-4">
{/* 输入文本 */}
<div>
<label className={styles.label}>
{isDecoding
? (activeAlgorithm === 'base64' ? t('tools.crypto_tools.base64_encoded') : t('tools.crypto_tools.encrypted_text'))
: t('tools.crypto_tools.input_text')}
</label>
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder={isDecoding
? (activeAlgorithm === 'base64' ? t('tools.crypto_tools.base64_decode_placeholder') : t('tools.crypto_tools.decrypt_placeholder'))
: t('tools.crypto_tools.input_placeholder')}
className={styles.textarea}
/>
</div>
{/* 密钥输入 */}
{algorithms[activeAlgorithm].needsKey && (
<div>
<label className={styles.label}>{t('tools.crypto_tools.secret_key')}</label>
<input
type="text"
value={secretKey}
onChange={(e) => setSecretKey(e.target.value)}
placeholder={t('tools.crypto_tools.key_placeholder')}
className={styles.input}
/>
</div>
)}
<div>
<button
onClick={processOperation}
className={styles.actionBtnPrimary}
disabled={!inputText}
>
<FontAwesomeIcon icon={faLock} />
{isDecoding
? (activeAlgorithm === 'base64' ? t('tools.crypto_tools.decode') : t('tools.crypto_tools.decrypt'))
: (activeAlgorithm === 'base64' ? t('tools.crypto_tools.encode') : (algorithms[activeAlgorithm].isEncodeDecode ? t('tools.crypto_tools.encrypt') : t('tools.crypto_tools.calculate')))}
</button>
</div>
</div>
{/* 输出区域 */}
<div className="space-y-4">
<div>
<label className={styles.label}>
{isDecoding
? t('tools.crypto_tools.decoded_result')
: (algorithms[activeAlgorithm].isEncodeDecode ? t('tools.crypto_tools.encrypted_result') : t('tools.crypto_tools.hash_result'))}
</label>
<textarea
value={output}
readOnly
placeholder={t('tools.crypto_tools.result_placeholder')}
className={styles.textarea}
/>
</div>
{output && (
<div className="flex justify-end">
<button
onClick={copyToClipboard}
className={styles.actionBtn}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
{copied ? t('tools.crypto_tools.copied') : t('tools.crypto_tools.copy_result')}
</button>
</div>
)}
</div>
</div>
{/* 状态消息 */}
{error && (
<div className={styles.error}>
<FontAwesomeIcon icon={faInfoCircle} className="mr-2" />
{error}
</div>
)}
{success && (
<div className={styles.success}>
<FontAwesomeIcon icon={faCheck} className="mr-2" />
{success}
</div>
)}
{/* 算法信息说明 */}
<div className="p-4 bg-block rounded-lg">
<h3 className="text-primary font-medium mb-2">{t('tools.crypto_tools.algorithm_info')}</h3>
<div className={styles.secondaryText}>
<p className="mb-2">
<strong>{algorithms[activeAlgorithm].name}:</strong>{' '}
{algorithms[activeAlgorithm].description}
</p>
{activeAlgorithm === 'md5' && (
<p>{t('tools.crypto_tools.algorithms.md5.additional_info')}</p>
)}
{activeAlgorithm === 'sha1' && (
<p>{t('tools.crypto_tools.algorithms.sha1.additional_info')}</p>
)}
{activeAlgorithm === 'sha256' && (
<p>{t('tools.crypto_tools.algorithms.sha256.additional_info')}</p>
)}
{activeAlgorithm === 'sha512' && (
<p>{t('tools.crypto_tools.algorithms.sha512.additional_info')}</p>
)}
{activeAlgorithm === 'aes' && (
<p>{t('tools.crypto_tools.algorithms.aes.additional_info')}</p>
)}
{activeAlgorithm === 'base64' && (
<p>{t('tools.crypto_tools.algorithms.base64.additional_info')}</p>
)}
</div>
</div>
</div>
</div>
{/* 回到顶部按钮 */}
<BackToTop />
</div>
);
}
@@ -0,0 +1,63 @@
import { render, screen, fireEvent } from '@testing-library/react';
import CssGradientGenerator from '../page';
// 模拟 FontAwesomeIcon 组件,因为它可能在测试环境中无法正确加载
jest.mock('@fortawesome/react-fontawesome', () => ({
FontAwesomeIcon: () => <span data-testid="mock-icon" />
}));
describe('CSS渐变生成器', () => {
test('渲染所有主要组件', () => {
render(<CssGradientGenerator />);
// 检查标题是否正确显示
expect(screen.getByText('CSS渐变生成器')).toBeInTheDocument();
// 检查所有主要控制按钮是否存在
expect(screen.getByText('线性渐变')).toBeInTheDocument();
expect(screen.getByText('径向渐变')).toBeInTheDocument();
// 检查渐变预览是否存在
expect(screen.getByText('渐变预览')).toBeInTheDocument();
// 检查颜色停止点编辑区是否存在
expect(screen.getByText('颜色停止点')).toBeInTheDocument();
expect(screen.getByText('添加色标')).toBeInTheDocument();
// 检查CSS代码区是否存在
expect(screen.getByText('CSS代码')).toBeInTheDocument();
expect(screen.getByText('复制')).toBeInTheDocument();
});
test('切换渐变类型', () => {
render(<CssGradientGenerator />);
// 初始应该是线性渐变
expect(screen.getByText('渐变方向')).toBeInTheDocument();
// 切换到径向渐变
fireEvent.click(screen.getByText('径向渐变'));
// 应该显示径向渐变的选项
expect(screen.getByText('渐变形状与位置')).toBeInTheDocument();
expect(screen.getByText('形状:')).toBeInTheDocument();
expect(screen.getByText('圆形')).toBeInTheDocument();
expect(screen.getByText('椭圆')).toBeInTheDocument();
expect(screen.getByText('位置:')).toBeInTheDocument();
});
test('添加和删除颜色停止点', () => {
render(<CssGradientGenerator />);
// 初始应该有2个颜色输入
const initialColorInputs = screen.getAllByRole('textbox');
expect(initialColorInputs.length).toBe(2);
// 添加一个新的颜色停止点
fireEvent.click(screen.getByText('添加色标'));
// 现在应该有3个颜色输入
const updatedColorInputs = screen.getAllByRole('textbox');
expect(updatedColorInputs.length).toBe(3);
});
});
@@ -0,0 +1,559 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCogs, faCopy, faCheck, faRandom, faTrash, faPlus, faAngleRight, faCircle } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import tools from '@/config/tools';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
card: "card p-6",
smallCard: "card p-4",
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
heading: "text-md font-medium text-primary mb-4",
subheading: "text-sm font-medium text-secondary mb-2",
label: "text-sm text-secondary mb-1",
previewBox: "relative rounded-lg h-48 overflow-hidden shadow-lg mb-4 transition-all duration-300 hover:shadow-xl border border-purple-glow/20",
optionBtn: (active: boolean) => `flex-1 px-3 py-2 rounded-md text-sm transition-colors ${active ? 'btn-primary' : 'btn-secondary'}`,
directionBtn: (active: boolean) => `w-8 h-8 rounded-md flex items-center justify-center ${active ? 'bg-purple-glow/30 text-purple' : 'bg-block-strong text-secondary'}`,
colorStopItem: "flex items-center gap-2 mb-3 relative",
colorInput: "w-8 h-8 rounded-md overflow-hidden cursor-pointer border border-purple-glow/20",
positionInput: "bg-block border border-purple-glow/20 rounded-md px-2 py-1 max-w-[4rem] text-primary text-center",
deleteBtn: "text-secondary hover:text-error transition-colors",
presetBtn: "w-8 h-8 rounded-md cursor-pointer border border-purple-glow/20 transition-all hover:scale-110",
codeBlock: "bg-block rounded-md p-4 text-sm font-mono text-primary overflow-auto",
codeComment: "text-tertiary",
copyBtn: "absolute top-2 right-2 bg-block-strong px-2 py-1 rounded text-xs text-secondary flex items-center gap-1 hover:bg-block-hover transition-colors",
rangeInput: "w-full bg-block-strong h-2 rounded-full appearance-none cursor-pointer",
inputGroup: "mb-4",
grid: "grid grid-cols-1 lg:grid-cols-3 gap-6",
flexRow: "flex space-x-2",
flexCenter: "flex items-center justify-center",
directionsGrid: "grid grid-cols-3 gap-2 mb-4",
};
// 渐变类型
type GradientType = 'linear' | 'radial';
// 渐变方向(线性)
type LinearDirection = '0deg' | '45deg' | '90deg' | '135deg' | '180deg' | '225deg' | '270deg' | '315deg' | 'custom';
// 渐变形状(径向)
type RadialShape = 'circle' | 'ellipse';
// 渐变位置(径向)
type RadialPosition = 'center' | 'top' | 'top right' | 'right' | 'bottom right' | 'bottom' | 'bottom left' | 'left' | 'top left';
// 渐变色标
interface ColorStop {
id: string;
color: string;
position: number;
}
export default function CssGradientGenerator() {
// 从工具配置中获取当前工具信息
const toolConfig = tools.find(tool => tool.code === 'css_gradient_generator');
// 使用多语言支持
const { t } = useLanguage();
// 渐变类型
const [gradientType, setGradientType] = useState<GradientType>('linear');
// 线性渐变方向
const [linearDirection, setLinearDirection] = useState<LinearDirection>('90deg');
const [customAngle, setCustomAngle] = useState<number>(90);
// 径向渐变设置
const [radialShape, setRadialShape] = useState<RadialShape>('circle');
const [radialPosition, setRadialPosition] = useState<RadialPosition>('center');
// 颜色停止点
const [colorStops, setColorStops] = useState<ColorStop[]>([
{ id: '1', color: '#6366F1', position: 0 },
{ id: '2', color: '#8B5CF6', position: 100 }
]);
// CSS 代码
const [cssCode, setCssCode] = useState<string>('');
// 复制状态
const [copied, setCopied] = useState<boolean>(false);
// 常用颜色组合
const presetColors = [
['#6366F1', '#8B5CF6'], // 极速箱默认紫色渐变
['#F472B6', '#EC4899'], // 粉红
['#10B981', '#059669'], // 绿色
['#3B82F6', '#2563EB'], // 蓝色
['#F59E0B', '#F97316'], // 橙色
['#6B7280', '#374151'], // 灰色
['#1E293B', '#0F172A'], // 深蓝灰
];
// 初始化效果
useEffect(() => {
generateCssCode();
}, [gradientType, linearDirection, customAngle, radialShape, radialPosition, colorStops]);
// 生成 CSS 代码
const generateCssCode = () => {
let cssText = '';
// 构建色标字符串
const stopsStr = colorStops
.sort((a, b) => a.position - b.position)
.map(stop => `${stop.color} ${stop.position}%`)
.join(', ');
// 根据渐变类型构建代码
if (gradientType === 'linear') {
const direction = linearDirection === 'custom' ? `${customAngle}deg` : linearDirection;
cssText = `background: ${colorStops[0].color};\n`;
cssText += `background: -webkit-linear-gradient(${direction}, ${stopsStr});\n`;
cssText += `background: linear-gradient(${direction}, ${stopsStr});`;
} else {
cssText = `background: ${colorStops[0].color};\n`;
cssText += `background: -webkit-radial-gradient(${radialPosition}, ${radialShape}, ${stopsStr});\n`;
cssText += `background: radial-gradient(${radialShape} at ${radialPosition}, ${stopsStr});`;
}
setCssCode(cssText);
};
// 添加新颜色停止点
const addColorStop = () => {
const id = Date.now().toString();
const colorCount = colorStops.length;
// 设置默认颜色和位置
const color = '#818CF8';
let position = 50;
// 如果有至少两个颜色,尝试在中间插入
if (colorCount >= 2) {
// 按位置排序
const sortedStops = [...colorStops].sort((a, b) => a.position - b.position);
// 找出最大间隔
let maxGap = 0;
let insertPosition = 50;
for (let i = 0; i < sortedStops.length - 1; i++) {
const gap = sortedStops[i + 1].position - sortedStops[i].position;
if (gap > maxGap) {
maxGap = gap;
insertPosition = sortedStops[i].position + gap / 2;
}
}
position = Math.round(insertPosition);
}
setColorStops([...colorStops, { id, color, position }]);
};
// 移除颜色停止点
const removeColorStop = (id: string) => {
// 确保至少保留2个颜色停止点
if (colorStops.length <= 2) {
return;
}
setColorStops(colorStops.filter(stop => stop.id !== id));
};
// 更新颜色停止点
const updateColorStop = (id: string, field: 'color' | 'position', value: string | number) => {
setColorStops(colorStops.map(stop => {
if (stop.id === id) {
if (field === 'position') {
// 确保位置在0-100范围内
const numValue = typeof value === 'string' ? parseInt(value, 10) : value;
return { ...stop, position: Math.max(0, Math.min(100, numValue)) };
}
return { ...stop, [field]: value as string };
}
return stop;
}));
};
// 应用预设颜色
const applyPreset = (colors: string[]) => {
const newStops = colorStops.map((stop, index) => {
// 只替换颜色,保持原有位置和ID
if (index < colors.length) {
return { ...stop, color: colors[index] };
}
return stop;
});
setColorStops(newStops);
};
// 生成随机渐变
const generateRandomGradient = () => {
// 生成随机颜色
const generateRandomColor = () => {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
};
// 更新颜色停止点
const newStops = colorStops.map(stop => ({
...stop,
color: generateRandomColor()
}));
// 随机渐变类型和方向
const newType = Math.random() > 0.5 ? 'linear' : 'radial';
setGradientType(newType);
if (newType === 'linear') {
const directions: LinearDirection[] = ['0deg', '45deg', '90deg', '135deg', '180deg', '225deg', '270deg', '315deg'];
const randomDirection = directions[Math.floor(Math.random() * directions.length)];
setLinearDirection(randomDirection);
} else {
const shapes: RadialShape[] = ['circle', 'ellipse'];
const positions: RadialPosition[] = ['center', 'top', 'right', 'bottom', 'left', 'top right', 'bottom right', 'bottom left', 'top left'];
setRadialShape(shapes[Math.floor(Math.random() * shapes.length)]);
setRadialPosition(positions[Math.floor(Math.random() * positions.length)]);
}
setColorStops(newStops);
};
// 复制 CSS 代码
const copyToClipboard = () => {
navigator.clipboard.writeText(cssCode)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => console.error(t('tools.css_gradient_generator.copy_failed'), err));
};
// 渐变预览样式
const gradientPreviewStyle = {
background: gradientType === 'linear'
? `linear-gradient(${linearDirection === 'custom' ? `${customAngle}deg` : linearDirection}, ${colorStops.sort((a, b) => a.position - b.position).map(stop => `${stop.color} ${stop.position}%`).join(', ')})`
: `radial-gradient(${radialShape} at ${radialPosition}, ${colorStops.sort((a, b) => a.position - b.position).map(stop => `${stop.color} ${stop.position}%`).join(', ')})`
};
return (
<div className={styles.container}>
{/* 工具头部 */}
{toolConfig && (
<ToolHeader
icon={toolConfig.icon || faCogs}
toolCode="css_gradient_generator"
title=""
description=""
/>
)}
{/* 主内容区 */}
<div className={styles.grid}>
{/* 左侧 - 控制面板 */}
<div className="lg:col-span-1 space-y-6">
{/* 渐变类型 */}
<div className={styles.smallCard}>
<h2 className={styles.heading}>{t('tools.css_gradient_generator.gradient_type')}</h2>
<div className={styles.flexRow}>
<button
className={styles.optionBtn(gradientType === 'linear')}
onClick={() => setGradientType('linear')}
>
{t('tools.css_gradient_generator.linear_gradient')}
</button>
<button
className={styles.optionBtn(gradientType === 'radial')}
onClick={() => setGradientType('radial')}
>
{t('tools.css_gradient_generator.radial_gradient')}
</button>
</div>
</div>
{/* 渐变参数 */}
<div className={styles.smallCard}>
<h2 className={styles.heading}>
{gradientType === 'linear'
? t('tools.css_gradient_generator.gradient_direction')
: t('tools.css_gradient_generator.gradient_shape_position')}
</h2>
{gradientType === 'linear' ? (
<div>
{/* 线性渐变方向 */}
<div className={styles.directionsGrid}>
<button
className={styles.directionBtn(linearDirection === '225deg')}
onClick={() => setLinearDirection('225deg')}
title={t('tools.css_gradient_generator.direction_titles.225deg')}
>
<FontAwesomeIcon icon={faAngleRight} className="transform rotate-[-135deg]" />
</button>
<button
className={styles.directionBtn(linearDirection === '270deg')}
onClick={() => setLinearDirection('270deg')}
title={t('tools.css_gradient_generator.direction_titles.270deg')}
>
<FontAwesomeIcon icon={faAngleRight} className="transform rotate-[-90deg]" />
</button>
<button
className={styles.directionBtn(linearDirection === '315deg')}
onClick={() => setLinearDirection('315deg')}
title={t('tools.css_gradient_generator.direction_titles.315deg')}
>
<FontAwesomeIcon icon={faAngleRight} className="transform rotate-[-45deg]" />
</button>
<button
className={styles.directionBtn(linearDirection === '180deg')}
onClick={() => setLinearDirection('180deg')}
title={t('tools.css_gradient_generator.direction_titles.180deg')}
>
<FontAwesomeIcon icon={faAngleRight} className="transform rotate-[180deg]" />
</button>
<div className={styles.flexCenter}>
<FontAwesomeIcon icon={faCircle} className="text-purple text-xs" />
</div>
<button
className={styles.directionBtn(linearDirection === '0deg')}
onClick={() => setLinearDirection('0deg')}
title={t('tools.css_gradient_generator.direction_titles.0deg')}
>
<FontAwesomeIcon icon={faAngleRight} />
</button>
<button
className={styles.directionBtn(linearDirection === '135deg')}
onClick={() => setLinearDirection('135deg')}
title={t('tools.css_gradient_generator.direction_titles.135deg')}
>
<FontAwesomeIcon icon={faAngleRight} className="transform rotate-[135deg]" />
</button>
<button
className={styles.directionBtn(linearDirection === '90deg')}
onClick={() => setLinearDirection('90deg')}
title={t('tools.css_gradient_generator.direction_titles.90deg')}
>
<FontAwesomeIcon icon={faAngleRight} className="transform rotate-[90deg]" />
</button>
<button
className={styles.directionBtn(linearDirection === '45deg')}
onClick={() => setLinearDirection('45deg')}
title={t('tools.css_gradient_generator.direction_titles.45deg')}
>
<FontAwesomeIcon icon={faAngleRight} className="transform rotate-[45deg]" />
</button>
</div>
{/* 自定义角度 */}
<div className={styles.inputGroup}>
<div className="flex items-center justify-between mb-2">
<label className={styles.label}>{t('tools.css_gradient_generator.custom_angle')}</label>
<span className="text-sm text-secondary">{customAngle}°</span>
</div>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="359"
value={customAngle}
onChange={(e) => {
setCustomAngle(parseInt(e.target.value, 10));
setLinearDirection('custom');
}}
className={styles.rangeInput}
/>
<button
className={styles.optionBtn(linearDirection === 'custom')}
onClick={() => setLinearDirection('custom')}
>
{t('tools.css_gradient_generator.apply')}
</button>
</div>
</div>
</div>
) : (
<div>
{/* 径向渐变形状 */}
<div className={styles.inputGroup}>
<label className={styles.label}>{t('tools.css_gradient_generator.gradient_shape')}</label>
<div className={styles.flexRow}>
<button
className={styles.optionBtn(radialShape === 'circle')}
onClick={() => setRadialShape('circle')}
>
{t('tools.css_gradient_generator.circle')}
</button>
<button
className={styles.optionBtn(radialShape === 'ellipse')}
onClick={() => setRadialShape('ellipse')}
>
{t('tools.css_gradient_generator.ellipse')}
</button>
</div>
</div>
{/* 径向渐变位置 */}
<div>
<label className={styles.label}>{t('tools.css_gradient_generator.gradient_position')}</label>
<div className="grid grid-cols-3 gap-2">
{['top left', 'top', 'top right', 'left', 'center', 'right', 'bottom left', 'bottom', 'bottom right'].map((pos) => (
<button
key={pos}
className={styles.directionBtn(radialPosition === pos)}
onClick={() => setRadialPosition(pos as RadialPosition)}
>
<div className="w-2 h-2 rounded-full bg-current"></div>
</button>
))}
</div>
</div>
</div>
)}
</div>
{/* 渐变颜色 */}
<div className={styles.smallCard}>
<div className="flex justify-between items-center mb-4">
<h2 className={styles.heading}>{t('tools.css_gradient_generator.gradient_colors')}</h2>
<div className="flex gap-2">
<button
className="btn-secondary text-xs px-3 py-1"
onClick={addColorStop}
title={t('tools.css_gradient_generator.add_color_stop')}
>
<FontAwesomeIcon icon={faPlus} />
</button>
<button
className="btn-secondary text-xs px-3 py-1"
onClick={generateRandomGradient}
title={t('tools.css_gradient_generator.random_gradient')}
>
<FontAwesomeIcon icon={faRandom} />
</button>
</div>
</div>
{/* 颜色停止点列表 */}
<div className="mb-4">
{colorStops.sort((a, b) => a.position - b.position).map((stop) => (
<div key={stop.id} className={styles.colorStopItem}>
<input
type="color"
value={stop.color}
onChange={(e) => updateColorStop(stop.id, 'color', e.target.value)}
title="选择颜色"
className={styles.colorInput}
/>
<div className="flex-1">
<input
type="text"
value={stop.color}
onChange={(e) => updateColorStop(stop.id, 'color', e.target.value)}
className="w-full bg-block border border-purple-glow/20 rounded-md px-2 py-1 text-sm text-primary"
/>
</div>
<input
type="number"
value={stop.position}
min="0"
max="100"
onChange={(e) => updateColorStop(stop.id, 'position', e.target.value)}
className={styles.positionInput}
/>
<span className="text-xs text-tertiary">%</span>
{colorStops.length > 2 && (
<button
onClick={() => removeColorStop(stop.id)}
title="删除"
className={styles.deleteBtn}
>
<FontAwesomeIcon icon={faTrash} />
</button>
)}
</div>
))}
</div>
{/* 预设颜色 */}
<div>
<h3 className={styles.subheading}>{t('tools.css_gradient_generator.preset_colors')}</h3>
<div className="flex flex-wrap gap-2">
{presetColors.map((colors, index) => (
<button
key={index}
className={styles.presetBtn}
onClick={() => applyPreset(colors)}
style={{
background: `linear-gradient(to right, ${colors[0]}, ${colors[1]})`
}}
title={t('tools.css_gradient_generator.apply_preset')}
></button>
))}
</div>
</div>
</div>
</div>
{/* 右侧 - 预览和代码 */}
<div className="lg:col-span-2 space-y-6">
{/* 渐变预览 */}
<div className={styles.card}>
<h2 className={styles.heading}>{t('tools.css_gradient_generator.gradient_preview')}</h2>
<div className={styles.previewBox} style={gradientPreviewStyle}>
</div>
<div className="text-sm text-tertiary">
<p>{t('tools.css_gradient_generator.preview_hint')}</p>
</div>
</div>
{/* CSS代码 */}
<div className={styles.card}>
<h2 className={styles.heading}>{t('tools.css_gradient_generator.css_code')}</h2>
<div className="relative">
<pre className={styles.codeBlock}>
<span className={styles.codeComment}>{t('tools.css_gradient_generator.css_comment')}</span>
<br />
{cssCode.split('\n').map((line, index) => (
<React.Fragment key={index}>
{line}
<br />
</React.Fragment>
))}
</pre>
<button
className={styles.copyBtn}
onClick={copyToClipboard}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
{copied ? t('tools.css_gradient_generator.copied') : t('tools.css_gradient_generator.copy_code')}
</button>
</div>
</div>
</div>
</div>
{/* 回到顶部按钮 */}
<BackToTop position="bottom-right" offset={30} size="medium" />
</div>
);
}
+609
View File
@@ -0,0 +1,609 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalendarAlt, faCopy, faCheck, faPlus, faMinus } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
card: "card p-6",
input: "search-input w-full",
textarea: "w-full p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-[#6366F1] focus:outline-none focus:ring-1 focus:ring-[#6366F1] transition-all",
label: "text-secondary font-medium",
secondaryText: "text-sm text-tertiary",
resultItem: "flex justify-between items-center py-2 border-b border-purple-glow/10",
resultLabel: "text-sm text-secondary",
resultValue: "text-sm text-primary font-semibold",
iconButton: "text-tertiary hover:text-purple transition-colors",
button: "text-left w-full px-3 py-2 rounded-md text-sm text-secondary hover:bg-block-hover transition-colors",
tabButton: "px-3 py-2 text-sm font-medium transition-all",
activeTab: "bg-block text-primary shadow-sm",
inactiveTab: "text-tertiary",
secondaryBtn: "flex items-center gap-1 text-sm px-2 py-1 rounded bg-block-strong hover:bg-block-hover text-secondary transition-colors",
formGroup: "mb-6",
dateInput: "search-input w-full text-primary bg-block border border-purple-glow rounded-md focus:border-purple focus:outline-none px-3 py-2",
resultBox: "p-3 bg-block rounded-md border border-purple-glow mb-4",
dateUnitSelector: "grid grid-cols-2 sm:grid-cols-4 gap-2 mb-4",
}
// 时间单位常量
enum TimeUnit {
YEARS = 'years',
MONTHS = 'months',
WEEKS = 'weeks',
DAYS = 'days',
HOURS = 'hours',
MINUTES = 'minutes',
}
// 格式化日期为显示格式
const formatDateForDisplay = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
};
// 格式化日期为输入框格式
const formatDateForInput = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
export default function DateCalculator() {
const { t } = useLanguage();
// 模式: 计算日期差值 or 添加/减去日期
const [mode, setMode] = useState<'diff' | 'add'>('diff');
// 日期差值计算的状态
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [diffResult, setDiffResult] = useState<{[key: string]: number}>({});
// 日期加减的状态
const [baseDate, setBaseDate] = useState<string>('');
const [timeAmount, setTimeAmount] = useState<number>(1);
const [timeUnit, setTimeUnit] = useState<TimeUnit>(TimeUnit.DAYS);
const [operation, setOperation] = useState<'add' | 'subtract'>('add');
const [addResult, setAddResult] = useState<string>('');
// 复制状态
const [copied, setCopied] = useState<string | null>(null);
// 初始化日期
useEffect(() => {
const now = new Date();
const oneWeekAgo = new Date(now);
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
setStartDate(formatDateForInput(oneWeekAgo));
setEndDate(formatDateForInput(now));
setBaseDate(formatDateForInput(now));
// 初始化时计算一次
calculateDateDiff(formatDateForInput(oneWeekAgo), formatDateForInput(now));
calculateDateAddition(formatDateForInput(now), timeAmount, timeUnit, operation);
}, []);
// 计算日期差值
const calculateDateDiff = (start: string, end: string) => {
if (!start || !end) return;
try {
const startDateTime = new Date(start);
const endDateTime = new Date(end);
if (isNaN(startDateTime.getTime()) || isNaN(endDateTime.getTime())) {
return;
}
// 计算毫秒差值
const diffMs = endDateTime.getTime() - startDateTime.getTime();
// 计算各个单位的差值
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
// 计算月份差
let months = (endDateTime.getFullYear() - startDateTime.getFullYear()) * 12;
months += endDateTime.getMonth() - startDateTime.getMonth();
// 计算年份差
const diffYears = Math.floor(months / 12);
// 设置结果
setDiffResult({
years: diffYears,
months: months,
weeks: diffWeeks,
days: diffDays,
hours: diffHours,
minutes: diffMinutes,
seconds: diffSeconds,
milliseconds: diffMs
});
} catch (error) {
console.error(t('tools.date_calculator.error.calculation_error'), error);
}
};
// 计算日期加减
const calculateDateAddition = (base: string, amount: number, unit: TimeUnit, op: 'add' | 'subtract') => {
if (!base || isNaN(amount)) return;
try {
const baseDateTime = new Date(base);
if (isNaN(baseDateTime.getTime())) {
return;
}
const resultDate = new Date(baseDateTime);
const sign = op === 'add' ? 1 : -1;
switch (unit) {
case TimeUnit.YEARS:
resultDate.setFullYear(resultDate.getFullYear() + sign * amount);
break;
case TimeUnit.MONTHS:
resultDate.setMonth(resultDate.getMonth() + sign * amount);
break;
case TimeUnit.WEEKS:
resultDate.setDate(resultDate.getDate() + sign * amount * 7);
break;
case TimeUnit.DAYS:
resultDate.setDate(resultDate.getDate() + sign * amount);
break;
case TimeUnit.HOURS:
resultDate.setHours(resultDate.getHours() + sign * amount);
break;
case TimeUnit.MINUTES:
resultDate.setMinutes(resultDate.getMinutes() + sign * amount);
break;
}
// 格式化结果
setAddResult(formatDateForDisplay(resultDate));
} catch (error) {
console.error(t('tools.date_calculator.error.calculation_error'), error);
}
};
// 处理开始日期变更
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setStartDate(value);
calculateDateDiff(value, endDate);
};
// 处理结束日期变更
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setEndDate(value);
calculateDateDiff(startDate, value);
};
// 处理基准日期变更
const handleBaseDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setBaseDate(value);
calculateDateAddition(value, timeAmount, timeUnit, operation);
};
// 处理时间数量变更
const handleTimeAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value);
if (!isNaN(value)) {
setTimeAmount(value);
calculateDateAddition(baseDate, value, timeUnit, operation);
}
};
// 处理时间单位变更
const handleTimeUnitChange = (unit: TimeUnit) => {
setTimeUnit(unit);
calculateDateAddition(baseDate, timeAmount, unit, operation);
};
// 处理操作变更
const handleOperationChange = (op: 'add' | 'subtract') => {
setOperation(op);
calculateDateAddition(baseDate, timeAmount, timeUnit, op);
};
// 复制结果
const copyToClipboard = (text: string, type: string) => {
navigator.clipboard.writeText(text)
.then(() => {
setCopied(type);
setTimeout(() => setCopied(null), 1500);
})
.catch(err => console.error(t('tools.date_calculator.error.copy_failed'), err));
};
// 设置开始日期为当前时间
const setStartDateToCurrent = () => {
const now = formatDateForInput(new Date());
setStartDate(now);
calculateDateDiff(now, endDate);
};
// 设置结束日期为当前时间
const setEndDateToCurrent = () => {
const now = formatDateForInput(new Date());
setEndDate(now);
calculateDateDiff(startDate, now);
};
// 设置基准日期为当前时间
const setBaseDateToCurrent = () => {
const now = formatDateForInput(new Date());
setBaseDate(now);
calculateDateAddition(now, timeAmount, timeUnit, operation);
};
// 交换开始和结束日期
const swapDates = () => {
const temp = startDate;
setStartDate(endDate);
setEndDate(temp);
calculateDateDiff(endDate, temp);
};
// 渲染时间单位选择器
const renderUnitSelector = () => {
const units = [
{ value: TimeUnit.YEARS, label: t('tools.date_calculator.diff_calculator.year_unit') },
{ value: TimeUnit.MONTHS, label: t('tools.date_calculator.diff_calculator.month_unit') },
{ value: TimeUnit.WEEKS, label: t('tools.date_calculator.diff_calculator.week_unit') },
{ value: TimeUnit.DAYS, label: t('tools.date_calculator.diff_calculator.day_unit') },
{ value: TimeUnit.HOURS, label: t('tools.date_calculator.diff_calculator.hour_unit') },
{ value: TimeUnit.MINUTES, label: t('tools.date_calculator.diff_calculator.minute_unit') },
];
return (
<div className={styles.dateUnitSelector}>
{units.map(unit => (
<button
key={unit.value}
className={`btn-secondary px-2 py-1 text-xs ${timeUnit === unit.value ? 'bg-purple-glow/20 border-purple text-primary' : ''}`}
onClick={() => handleTimeUnitChange(unit.value)}
>
{unit.label}
</button>
))}
</div>
);
};
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
<ToolHeader
icon={faCalendarAlt}
toolCode="date_calculator"
title=""
description=""
/>
<div className="flex flex-wrap gap-4 justify-between items-center mb-6">
<div className="flex items-center bg-block rounded-md p-1">
<button
className={`${styles.tabButton} ${mode === 'diff' ? styles.activeTab : styles.inactiveTab} rounded-l-md`}
onClick={() => setMode('diff')}
>
{t('tools.date_calculator.mode.diff')}
</button>
<button
className={`${styles.tabButton} ${mode === 'add' ? styles.activeTab : styles.inactiveTab} rounded-r-md`}
onClick={() => setMode('add')}
>
{t('tools.date_calculator.mode.add')}
</button>
</div>
</div>
{/* 日期差值计算 */}
{mode === 'diff' && (
<div className={styles.card}>
<h2 className="text-lg font-medium text-primary mb-4">{t('tools.date_calculator.diff_calculator.title')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 输入部分 */}
<div className="space-y-6">
<div>
<label className={styles.label}>{t('tools.date_calculator.diff_calculator.start_date')}</label>
<div className="mt-2 flex items-center gap-2">
<input
type="datetime-local"
className={styles.dateInput}
value={startDate}
onChange={handleStartDateChange}
/>
<button
className={styles.secondaryBtn}
onClick={setStartDateToCurrent}
>
{t('tools.date_calculator.diff_calculator.current')}
</button>
</div>
</div>
<div className="flex justify-center">
<button
className={styles.secondaryBtn}
onClick={swapDates}
>
<FontAwesomeIcon icon={faCalendarAlt} className="mr-2" />
{t('tools.date_calculator.diff_calculator.swap_dates')}
</button>
</div>
<div>
<label className={styles.label}>{t('tools.date_calculator.diff_calculator.end_date')}</label>
<div className="mt-2 flex items-center gap-2">
<input
type="datetime-local"
className={styles.dateInput}
value={endDate}
onChange={handleEndDateChange}
/>
<button
className={styles.secondaryBtn}
onClick={setEndDateToCurrent}
>
{t('tools.date_calculator.diff_calculator.current')}
</button>
</div>
</div>
</div>
{/* 结果部分 */}
<div className={styles.resultBox}>
<h3 className="text-primary font-medium mb-3">{t('tools.date_calculator.diff_calculator.result_title')}</h3>
<div className="space-y-2">
{Object.keys(diffResult).length > 0 ? (
<>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.date_calculator.diff_calculator.years')}</span>
<div className="flex items-center gap-2">
<span className={styles.resultValue}>{diffResult.years} {t('tools.date_calculator.diff_calculator.year_unit')}</span>
<button
onClick={() => copyToClipboard(diffResult.years.toString(), 'years')}
className={styles.iconButton}
>
<FontAwesomeIcon icon={copied === 'years' ? faCheck : faCopy} />
</button>
</div>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.date_calculator.diff_calculator.months')}</span>
<div className="flex items-center gap-2">
<span className={styles.resultValue}>{diffResult.months} {t('tools.date_calculator.diff_calculator.month_unit')}</span>
<button
onClick={() => copyToClipboard(diffResult.months.toString(), 'months')}
className={styles.iconButton}
>
<FontAwesomeIcon icon={copied === 'months' ? faCheck : faCopy} />
</button>
</div>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.date_calculator.diff_calculator.weeks')}</span>
<div className="flex items-center gap-2">
<span className={styles.resultValue}>{diffResult.weeks} {t('tools.date_calculator.diff_calculator.week_unit')}</span>
<button
onClick={() => copyToClipboard(diffResult.weeks.toString(), 'weeks')}
className={styles.iconButton}
>
<FontAwesomeIcon icon={copied === 'weeks' ? faCheck : faCopy} />
</button>
</div>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.date_calculator.diff_calculator.days')}</span>
<div className="flex items-center gap-2">
<span className={styles.resultValue}>{diffResult.days} {t('tools.date_calculator.diff_calculator.day_unit')}</span>
<button
onClick={() => copyToClipboard(diffResult.days.toString(), 'days')}
className={styles.iconButton}
>
<FontAwesomeIcon icon={copied === 'days' ? faCheck : faCopy} />
</button>
</div>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.date_calculator.diff_calculator.hours')}</span>
<div className="flex items-center gap-2">
<span className={styles.resultValue}>{diffResult.hours} {t('tools.date_calculator.diff_calculator.hour_unit')}</span>
<button
onClick={() => copyToClipboard(diffResult.hours.toString(), 'hours')}
className={styles.iconButton}
>
<FontAwesomeIcon icon={copied === 'hours' ? faCheck : faCopy} />
</button>
</div>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.date_calculator.diff_calculator.minutes')}</span>
<div className="flex items-center gap-2">
<span className={styles.resultValue}>{diffResult.minutes} {t('tools.date_calculator.diff_calculator.minute_unit')}</span>
<button
onClick={() => copyToClipboard(diffResult.minutes.toString(), 'minutes')}
className={styles.iconButton}
>
<FontAwesomeIcon icon={copied === 'minutes' ? faCheck : faCopy} />
</button>
</div>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.date_calculator.diff_calculator.seconds')}</span>
<div className="flex items-center gap-2">
<span className={styles.resultValue}>{diffResult.seconds} {t('tools.date_calculator.diff_calculator.second_unit')}</span>
<button
onClick={() => copyToClipboard(diffResult.seconds.toString(), 'seconds')}
className={styles.iconButton}
>
<FontAwesomeIcon icon={copied === 'seconds' ? faCheck : faCopy} />
</button>
</div>
</div>
</>
) : (
<div className="text-tertiary text-center py-4">
{t('tools.date_calculator.diff_calculator.no_valid_dates')}
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* 日期加减计算 */}
{mode === 'add' && (
<div className={styles.card}>
<h2 className="text-lg font-medium text-primary mb-4">{t('tools.date_calculator.add_calculator.title')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 输入部分 */}
<div className="space-y-6">
<div>
<label className={styles.label}>{t('tools.date_calculator.add_calculator.base_date')}</label>
<div className="mt-2 flex items-center gap-2">
<input
type="datetime-local"
className={styles.dateInput}
value={baseDate}
onChange={handleBaseDateChange}
/>
<button
className={styles.secondaryBtn}
onClick={setBaseDateToCurrent}
>
{t('tools.date_calculator.diff_calculator.current')}
</button>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.date_calculator.add_calculator.operation')}</label>
<div className="flex items-center gap-2 mt-2">
<button
className={`btn-secondary px-3 py-1 ${operation === 'add' ? 'bg-purple-glow/20 border-purple text-primary' : ''}`}
onClick={() => handleOperationChange('add')}
>
<FontAwesomeIcon icon={faPlus} className="mr-2" />
{t('tools.date_calculator.add_calculator.add')}
</button>
<button
className={`btn-secondary px-3 py-1 ${operation === 'subtract' ? 'bg-purple-glow/20 border-purple text-primary' : ''}`}
onClick={() => handleOperationChange('subtract')}
>
<FontAwesomeIcon icon={faMinus} className="mr-2" />
{t('tools.date_calculator.add_calculator.subtract')}
</button>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.date_calculator.add_calculator.time_amount')}</label>
<div className="mt-2">
<input
type="number"
min="1"
className={styles.dateInput}
value={timeAmount}
onChange={handleTimeAmountChange}
/>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.date_calculator.add_calculator.time_unit')}</label>
<div className="mt-2">
{renderUnitSelector()}
</div>
</div>
</div>
{/* 结果部分 */}
<div className={styles.resultBox}>
<h3 className="text-primary font-medium mb-4">{t('tools.date_calculator.add_calculator.result_title')}</h3>
{addResult ? (
<div className="space-y-4">
<div className="p-4 bg-block-strong rounded-md text-center">
<div className="text-tertiary text-sm mb-1">
{operation === 'add'
? t('tools.date_calculator.add_calculator.add_result').replace('{amount}', timeAmount.toString()).replace('{unit}',
timeUnit === TimeUnit.YEARS ? t('tools.date_calculator.diff_calculator.year_unit') :
timeUnit === TimeUnit.MONTHS ? t('tools.date_calculator.diff_calculator.month_unit') :
timeUnit === TimeUnit.WEEKS ? t('tools.date_calculator.diff_calculator.week_unit') :
timeUnit === TimeUnit.DAYS ? t('tools.date_calculator.diff_calculator.day_unit') :
timeUnit === TimeUnit.HOURS ? t('tools.date_calculator.diff_calculator.hour_unit') :
t('tools.date_calculator.diff_calculator.minute_unit'))
: t('tools.date_calculator.add_calculator.subtract_result').replace('{amount}', timeAmount.toString()).replace('{unit}',
timeUnit === TimeUnit.YEARS ? t('tools.date_calculator.diff_calculator.year_unit') :
timeUnit === TimeUnit.MONTHS ? t('tools.date_calculator.diff_calculator.month_unit') :
timeUnit === TimeUnit.WEEKS ? t('tools.date_calculator.diff_calculator.week_unit') :
timeUnit === TimeUnit.DAYS ? t('tools.date_calculator.diff_calculator.day_unit') :
timeUnit === TimeUnit.HOURS ? t('tools.date_calculator.diff_calculator.hour_unit') :
t('tools.date_calculator.diff_calculator.minute_unit'))}
</div>
<div className="text-xl font-medium text-primary">
{addResult}
</div>
</div>
<div className="flex justify-center">
<button
className="btn-secondary px-4 py-2"
onClick={() => copyToClipboard(addResult, 'result')}
>
<FontAwesomeIcon icon={copied === 'result' ? faCheck : faCopy} className="mr-2" />
{copied === 'result' ? t('tools.date_calculator.add_calculator.copied') : t('tools.date_calculator.add_calculator.copy_result')}
</button>
</div>
</div>
) : (
<div className="text-tertiary text-center py-4">
{t('tools.date_calculator.add_calculator.no_valid_input')}
</div>
)}
</div>
</div>
<div className="mt-6">
<h3 className="text-primary font-medium mb-2">{t('tools.date_calculator.add_calculator.notes_title')}</h3>
<ul className="list-disc pl-5 space-y-1 text-sm text-tertiary">
<li>{t('tools.date_calculator.add_calculator.note1')}</li>
<li>{t('tools.date_calculator.add_calculator.note2')}</li>
<li>{t('tools.date_calculator.add_calculator.note3')}</li>
</ul>
</div>
</div>
)}
</div>
);
}
+349
View File
@@ -0,0 +1,349 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExchangeAlt, faCopy, faCheck, faSyncAlt, faEraser } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import tools from '@/config/tools';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
card: "card p-6",
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
typeBtnActive: "px-4 py-2 rounded-md transition-all bg-gradient-to-r from-[rgb(var(--color-primary))] to-[rgb(var(--color-primary-hover))] text-white shadow-sm shadow-[rgba(var(--color-primary),0.3)]",
typeBtnInactive: "px-4 py-2 rounded-md transition-all btn-secondary",
toggleContainer: "flex items-center bg-block-strong rounded-md p-1",
toggleBtnActive: "px-4 py-2 rounded-md transition-all bg-block text-primary shadow-sm",
toggleBtnInactive: "px-4 py-2 rounded-md transition-all text-tertiary",
description: "text-sm text-tertiary",
textArea: "w-full h-48 p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple resize-y font-mono",
errorMsg: "py-2 px-3 bg-red-900/20 border border-red-700/30 text-error rounded-md",
flexRow: "flex flex-col sm:flex-row gap-4 justify-between items-center",
copyBtn: "flex items-center gap-1 px-3 py-1 text-sm rounded-md bg-block-strong hover:bg-block-hover text-secondary transition-colors",
swapBtn: "bg-purple-glow/10 text-purple p-2 rounded-full hover:bg-purple-glow/20 transition-colors"
};
export default function EncodingConverter() {
const { t } = useLanguage();
// 从工具配置中获取当前工具图标
const toolIcon = tools.find(tool => tool.code === 'encoding_converter')?.icon || faExchangeAlt;
// 编码类型选项
const encodingTypes = [
{ id: 'base64', name: 'Base64', description: t('tools.encoding_converter.base64_desc') },
{ id: 'url', name: 'URL', description: t('tools.encoding_converter.url_desc') },
{ id: 'unicode', name: 'Unicode', description: t('tools.encoding_converter.unicode_desc') },
{ id: 'html', name: 'HTML', description: t('tools.encoding_converter.html_desc') }
];
// 状态管理
const [inputText, setInputText] = useState('');
const [outputText, setOutputText] = useState('');
const [encodingType, setEncodingType] = useState('base64');
const [operation, setOperation] = useState('encode'); // 'encode' 或 'decode'
const [error, setError] = useState('');
const [copied, setCopied] = useState(false);
// 当输入文本、编码类型或操作变化时,自动执行转换
useEffect(() => {
if (inputText.trim() === '') {
setOutputText('');
setError('');
return;
}
try {
const result = processConversion(inputText, encodingType, operation);
setOutputText(result);
setError('');
} catch (err) {
if (err instanceof Error) {
setError(err.message);
} else {
setError(t('tools.encoding_converter.general_error'));
}
setOutputText('');
}
}, [inputText, encodingType, operation, t]);
// 执行编码或解码操作
const processConversion = (text: string, type: string, op: string): string => {
if (text.trim() === '') return '';
try {
if (type === 'base64') {
return op === 'encode'
? btoa(encodeURIComponent(text).replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(parseInt(p1, 16))))
: decodeURIComponent(Array.from(atob(text.replace(/\s/g, '')))
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join(''));
}
else if (type === 'url') {
return op === 'encode'
? encodeURIComponent(text)
: decodeURIComponent(text);
}
else if (type === 'unicode') {
if (op === 'encode') {
return Array.from(text)
.map(char => '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0'))
.join('');
} else {
return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) =>
String.fromCharCode(parseInt(hex, 16))
);
}
}
else if (type === 'html') {
if (op === 'encode') {
const el = document.createElement('div');
el.textContent = text;
return el.innerHTML;
} else {
const el = document.createElement('div');
el.innerHTML = text;
return el.textContent || '';
}
}
throw new Error(t('tools.encoding_converter.unsupported_type'));
} catch (err) {
if (type === 'base64' && op === 'decode') {
throw new Error(t('tools.encoding_converter.invalid_base64'));
} else if (type === 'url' && op === 'decode') {
throw new Error(t('tools.encoding_converter.invalid_url'));
} else if (type === 'unicode' && op === 'decode') {
throw new Error(t('tools.encoding_converter.invalid_unicode'));
} else if (type === 'html' && op === 'decode') {
throw new Error(t('tools.encoding_converter.invalid_html'));
}
throw err;
}
};
// 复制输出内容到剪贴板
const copyToClipboard = () => {
if (!outputText) return;
navigator.clipboard.writeText(outputText)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => {
console.error(t('tools.encoding_converter.copy_failed'), err);
setError(t('tools.encoding_converter.clipboard_error'));
});
};
// 清空输入和输出
const clearAll = () => {
setInputText('');
setOutputText('');
setError('');
};
// 切换操作类型(编码/解码)
const _toggleOperation = () => {
// 添加动画过渡效果
const container = document.getElementById('converter-container');
if (container) {
container.classList.add('animate-pulse');
setTimeout(() => {
container.classList.remove('animate-pulse');
}, 300);
}
// 交换输入和输出文本
const newOperation = operation === 'encode' ? 'decode' : 'encode';
// 使用输出文本替换输入文本
setInputText(outputText);
setOperation(newOperation);
setError('');
};
// 加载示例文本
const loadExample = () => {
const examples = {
base64: {
encode: t('tools.encoding_converter.example_text'),
decode: '5L2g5aW977yM5LiW55WM77yB'
},
url: {
encode: 'https://jisuxiang.com?query=' + t('tools.encoding_converter.hello') + '&lang=zh-CN',
decode: 'https%3A%2F%2Fjisuxiang.com%3Fquery%3D%E4%BD%A0%E5%A5%BD%26lang%3Dzh-CN'
},
unicode: {
encode: t('tools.encoding_converter.example_text'),
decode: '\\u4f60\\u597d\\uff0c\\u4e16\\u754c\\uff01'
},
html: {
encode: '<div class="example">' + t('tools.encoding_converter.html_example') + '</div>',
decode: '&lt;div class=&quot;example&quot;&gt;HTML示例 &amp; 特殊字符&lt;/div&gt;'
}
};
// 根据当前编码类型和操作选择示例
const exampleText = examples[encodingType as keyof typeof examples][operation as keyof typeof examples[keyof typeof examples]];
setInputText(exampleText);
};
return (
<div className={styles.container}>
{/* 使用 ToolHeader 组件 */}
<ToolHeader
toolCode="encoding_converter"
title=""
description=""
icon={toolIcon}
/>
{/* 主要内容区域 */}
<div className={styles.card} id="converter-container">
<div className="space-y-6">
{/* 编码类型选择 */}
<div className="flex flex-wrap gap-2">
{encodingTypes.map((type) => (
<button
key={type.id}
className={encodingType === type.id ? styles.typeBtnActive : styles.typeBtnInactive}
onClick={() => setEncodingType(type.id)}
>
{type.name}
</button>
))}
</div>
{/* 操作类型切换 */}
<div className={styles.flexRow}>
<div className="flex items-center gap-4">
<div className={styles.toggleContainer}>
<button
className={operation === 'encode' ? styles.toggleBtnActive : styles.toggleBtnInactive}
onClick={() => setOperation('encode')}
>
{t('tools.encoding_converter.encode')}
</button>
<button
className={operation === 'decode' ? styles.toggleBtnActive : styles.toggleBtnInactive}
onClick={() => setOperation('decode')}
>
{t('tools.encoding_converter.decode')}
</button>
</div>
<div className={styles.description}>
{encodingTypes.find(type => type.id === encodingType)?.description}
</div>
</div>
<div className="flex gap-2">
<button
className="btn-secondary"
onClick={loadExample}
>
<FontAwesomeIcon icon={faSyncAlt} className="mr-2" />
{t('tools.encoding_converter.load_example')}
</button>
<button
className="btn-secondary"
onClick={clearAll}
>
<FontAwesomeIcon icon={faEraser} className="mr-2" />
{t('tools.encoding_converter.clear_all')}
</button>
</div>
</div>
{/* 输入输出区域 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 输入框 */}
<div className="space-y-2">
<label className="block text-sm text-secondary font-medium">
{operation === 'encode' ? t('tools.encoding_converter.text_to_encode') : t('tools.encoding_converter.text_to_decode')}
</label>
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder={t('tools.encoding_converter.input_placeholder')}
className={styles.textArea}
/>
</div>
{/* 输出框 */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<label className="block text-sm text-secondary font-medium">
{operation === 'encode' ? t('tools.encoding_converter.encoded_result') : t('tools.encoding_converter.decoded_result')}
</label>
</div>
<div className="relative">
<textarea
value={outputText}
readOnly
placeholder={t('tools.encoding_converter.output_placeholder')}
className={styles.textArea}
/>
{outputText && (
<button
onClick={copyToClipboard}
className="absolute top-3 right-3 opacity-70 hover:opacity-100 transition-opacity"
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
</button>
)}
</div>
{/* 错误信息 */}
{error && (
<div className={styles.errorMsg}>
{error}
</div>
)}
{/* 复制按钮 */}
{outputText && (
<div className="flex justify-end">
<button
onClick={copyToClipboard}
className={styles.copyBtn}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
<span>{copied ? t('common.copySuccess') : t('tools.encoding_converter.copy')}</span>
</button>
</div>
)}
</div>
</div>
{/* 说明部分 */}
<div className="mt-6 p-4 bg-block rounded-lg">
<h3 className="text-md font-medium text-primary mb-2"></h3>
<p className="text-sm text-tertiary">
{encodingType === 'base64' && (
"Base64是一种基于64个可打印字符来表示二进制数据的表示方法,常用于在HTTP环境下传输二进制数据,如图片或其他媒体文件。"
)}
{encodingType === 'url' && (
"URL编码将字符转换为可在URL中安全传输的格式,例如将空格转换为%20,中文和特殊字符也会被转换为%后跟十六进制值。"
)}
{encodingType === 'unicode' && (
"Unicode编码使用\\u前缀后跟四位十六进制数字表示字符,可以表示几乎所有语言的字符,如中文、日文等。"
)}
{encodingType === 'html' && (
"HTML编码将特殊字符(如<、>、&等)转换为HTML实体,以防止它们被浏览器解释为HTML标签或特殊结构。"
)}
</p>
</div>
</div>
</div>
{/* 回到顶部按钮 */}
<BackToTop />
</div>
);
}
@@ -0,0 +1,484 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faFileCode,
faCopy,
faCheck,
faRedo,
faExchangeAlt,
faInfoCircle,
faEraser
} from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
card: "card p-6",
textarea: "w-full h-64 p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple transition-all font-mono resize-y",
label: "text-sm text-secondary font-medium",
error: "p-3 bg-red-900/20 border border-red-700/30 rounded-lg text-error",
info: "text-sm text-tertiary",
actionBtn: "btn-secondary flex items-center gap-2",
actionBtnPrimary: "btn-primary flex items-center gap-2",
loading: "text-purple animate-pulse",
moduleLoading: "p-3 bg-purple-glow/10 border border-purple-glow/30 rounded-lg text-secondary",
toggleBtn: "px-3 py-2 text-sm font-medium rounded-md transition-all",
toggleBtnActive: "bg-gradient-to-r from-[rgb(var(--color-primary))] to-[rgb(var(--color-primary-hover))] text-white shadow-sm",
toggleBtnInactive: "btn-secondary",
toggleContainer: "flex items-center rounded-md p-1 bg-block-strong",
flexBetween: "flex flex-col sm:flex-row gap-4 justify-between items-center",
exchangeBtn: "bg-purple-glow/10 text-purple p-2 rounded-full hover:bg-purple-glow/20 transition-colors",
};
// 添加全局接口,使marked和turndown可以在window上使用
declare global {
interface Window {
marked: {
parse: (markdown: string, options?: Record<string, unknown>) => string;
[key: string]: unknown;
};
TurndownService: {
new (options?: Record<string, unknown>): {
turndown: (html: string) => string;
[key: string]: unknown;
};
};
}
}
export default function HtmlMarkdownConverter() {
const { t } = useLanguage();
// 输入与输出
const [input, setInput] = useState('');
const [output, setOutput] = useState('');
const [mode, setMode] = useState<'html2md' | 'md2html'>('md2html');
// 其他状态
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [isConverting, setIsConverting] = useState(false);
const [loadingModules, setLoadingModules] = useState(false);
const [modulesLoaded, setModulesLoaded] = useState(false);
// 动态加载marked和turndown库
useEffect(() => {
if (typeof window !== 'undefined' && !modulesLoaded) {
setLoadingModules(true);
const loadScripts = async () => {
try {
// 加载Marked库
const markedScript = document.createElement('script');
markedScript.src = '/lib/markdown/marked.min.js';
markedScript.async = true;
const markedPromise = new Promise<void>((resolve, reject) => {
markedScript.onload = () => {
console.log('Marked库加载成功');
resolve();
};
markedScript.onerror = (error) => {
console.error('加载本地Marked库失败,尝试从CDN加载:', error);
// 从CDN加载失败时的备用方案
const cdnMarkedScript = document.createElement('script');
cdnMarkedScript.src = 'https://cdn.jsdelivr.net/npm/marked/marked.min.js';
cdnMarkedScript.async = true;
cdnMarkedScript.onload = () => {
console.log('从CDN加载Marked库成功');
resolve();
};
cdnMarkedScript.onerror = (cdnError) => {
console.error('从CDN加载Marked库失败:', cdnError);
reject(new Error('加载Marked库失败'));
};
document.body.appendChild(cdnMarkedScript);
};
});
document.body.appendChild(markedScript);
// 加载Turndown库
const turndownScript = document.createElement('script');
turndownScript.src = '/lib/markdown/turndown.js';
turndownScript.async = true;
const turndownPromise = new Promise<void>((resolve, reject) => {
turndownScript.onload = () => {
console.log('Turndown库加载成功');
resolve();
};
turndownScript.onerror = (error) => {
console.error('加载本地Turndown库失败,尝试从CDN加载:', error);
// 从CDN加载失败时的备用方案
const cdnTurndownScript = document.createElement('script');
cdnTurndownScript.src = 'https://cdn.jsdelivr.net/npm/turndown/dist/turndown.js';
cdnTurndownScript.async = true;
cdnTurndownScript.onload = () => {
console.log('从CDN加载Turndown库成功');
resolve();
};
cdnTurndownScript.onerror = (cdnError) => {
console.error('从CDN加载Turndown库失败:', cdnError);
reject(new Error('加载Turndown库失败'));
};
document.body.appendChild(cdnTurndownScript);
};
});
document.body.appendChild(turndownScript);
// 等待两个脚本都加载完成
await Promise.all([markedPromise, turndownPromise]);
console.log('所有模块加载完成!');
setModulesLoaded(true);
setLoadingModules(false);
} catch (error) {
console.error('加载库失败:', error);
setError(t('tools.html_markdown_converter.error_load'));
setLoadingModules(false);
}
};
loadScripts();
}
return () => {
// 清理函数不需要移除脚本,因为它们会一直被缓存和重用
};
}, [modulesLoaded, t]);
// 转换函数
const convertContent = () => {
if (!input.trim()) {
setError(t('tools.html_markdown_converter.error_empty'));
setOutput('');
return;
}
setIsConverting(true);
setError(null);
try {
if (mode === 'md2html') {
// Markdown 转 HTML
const html = window.marked.parse(input);
setOutput(html);
} else {
// HTML 转 Markdown
const turndownService = new window.TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced'
});
const markdown = turndownService.turndown(input);
setOutput(markdown);
}
setError(null);
} catch (err) {
console.error('转换错误:', err);
const errorMsg = t('tools.html_markdown_converter.error_convert').replace(
'{error}',
err instanceof Error ? err.message : t('tools.html_markdown_converter.error_unknown')
);
setError(errorMsg);
setOutput('');
} finally {
setIsConverting(false);
}
};
// 复制结果
const copyResult = () => {
if (!output) return;
navigator.clipboard.writeText(output)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => {
console.error(t('tools.html_markdown_converter.error_copy'), err);
setError(t('tools.html_markdown_converter.error_copy'));
});
};
// 切换转换模式
const toggleMode = () => {
// 切换模式时交换输入和输出
setMode(prevMode => prevMode === 'md2html' ? 'html2md' : 'md2html');
setInput(output);
setOutput(input);
setError(null);
};
// 清空所有内容
const clearAll = () => {
setInput('');
setOutput('');
setError(null);
};
// 加载示例
const loadExample = () => {
if (mode === 'md2html') {
setInput(`# 示例标题
这是一段**粗体**文字和*斜体*文字。
## 子标题
- 列表项1
- 列表项2
- 列表项3
[这是一个链接](https://example.com)
\`\`\`javascript
// 这是一段代码
function hello() {
console.log("Hello, world!");
}
\`\`\`
> 这是一段引用文字
---
| 表头1 | 表头2 |
|-------|-------|
| 单元格1 | 单元格2 |
| 单元格3 | 单元格4 |
`);
} else {
setInput(`<h1>示例标题</h1>
<p>这是一段<strong>粗体</strong>文字和<em>斜体</em>文字。</p>
<h2>子标题</h2>
<ul>
<li>列表项1</li>
<li>列表项2</li>
<li>列表项3</li>
</ul>
<p><a href="https://example.com">这是一个链接</a></p>
<pre><code class="language-javascript">// 这是一段代码
function hello() {
console.log("Hello, world!");
}
</code></pre>
<blockquote>
<p>这是一段引用文字</p>
</blockquote>
<hr />
<table>
<thead>
<tr>
<th>表头1</th>
<th>表头2</th>
</tr>
</thead>
<tbody>
<tr>
<td>单元格1</td>
<td>单元格2</td>
</tr>
<tr>
<td>单元格3</td>
<td>单元格4</td>
</tr>
</tbody>
</table>`);
}
setOutput('');
setError(null);
};
return (
<div className={styles.container}>
{/* 工具头部 */}
<ToolHeader
toolCode="html_markdown_converter"
icon={faFileCode}
title=""
description=""
/>
{/* 主内容区域 */}
<div className={styles.card}>
<div className="space-y-6">
{/* 转换模式切换 */}
<div className={styles.flexBetween}>
<div className={styles.toggleContainer}>
<button
className={`${styles.toggleBtn} ${mode === 'md2html' ? styles.toggleBtnActive : styles.toggleBtnInactive}`}
onClick={() => setMode('md2html')}
>
{t('tools.html_markdown_converter.md2html')}
</button>
<button
className={`${styles.toggleBtn} ${mode === 'html2md' ? styles.toggleBtnActive : styles.toggleBtnInactive}`}
onClick={() => setMode('html2md')}
>
{t('tools.html_markdown_converter.html2md')}
</button>
</div>
<div className="flex gap-2">
<button
onClick={loadExample}
className={styles.actionBtn}
disabled={loadingModules}
>
<FontAwesomeIcon icon={faRedo} />
{t('tools.html_markdown_converter.load_example')}
</button>
<button
onClick={clearAll}
className={styles.actionBtn}
disabled={!input && !output}
>
<FontAwesomeIcon icon={faEraser} />
{t('tools.html_markdown_converter.clear')}
</button>
</div>
</div>
{/* 加载状态提示 */}
{loadingModules && (
<div className={styles.moduleLoading}>
<FontAwesomeIcon icon={faInfoCircle} className="mr-2" />
{t('tools.html_markdown_converter.loading_modules')}
</div>
)}
{/* 输入输出区域 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 输入区域 */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<label className={styles.label}>
{mode === 'md2html'
? t('tools.html_markdown_converter.md_input')
: t('tools.html_markdown_converter.html_input')}
</label>
</div>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={mode === 'md2html'
? t('tools.html_markdown_converter.md_placeholder')
: t('tools.html_markdown_converter.html_placeholder')}
className={styles.textarea}
disabled={loadingModules}
/>
</div>
{/* 输出区域 */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<div className="flex items-center">
<label className={styles.label}>
{mode === 'md2html'
? t('tools.html_markdown_converter.html_output')
: t('tools.html_markdown_converter.md_output')}
</label>
<button
onClick={toggleMode}
className={styles.exchangeBtn}
title={t('tools.html_markdown_converter.exchange')}
disabled={loadingModules}
>
<FontAwesomeIcon icon={faExchangeAlt} />
</button>
</div>
<div>
<button
onClick={convertContent}
className={styles.actionBtnPrimary}
disabled={!input || loadingModules || isConverting}
>
{isConverting
? t('tools.html_markdown_converter.converting')
: t('tools.html_markdown_converter.convert')}
</button>
</div>
</div>
<textarea
value={output}
readOnly
placeholder={mode === 'md2html'
? t('tools.html_markdown_converter.html_result_placeholder')
: t('tools.html_markdown_converter.md_result_placeholder')}
className={styles.textarea}
/>
{/* 复制按钮 */}
{output && (
<div className="flex justify-end">
<button
onClick={copyResult}
className={styles.actionBtn}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
{copied
? t('tools.html_markdown_converter.copied')
: t('tools.html_markdown_converter.copy_result')}
</button>
</div>
)}
</div>
</div>
{/* 错误提示 */}
{error && (
<div className={styles.error}>
<FontAwesomeIcon icon={faInfoCircle} className="mr-2" />
{error}
</div>
)}
{/* 说明部分 */}
<div className="p-4 bg-block rounded-lg">
<h3 className="text-primary font-medium mb-2">{t('tools.html_markdown_converter.feature_title')}</h3>
<div className={styles.info}>
<p className="mb-2">
{t('tools.html_markdown_converter.feature_intro')}
</p>
<ul className="list-disc pl-5 space-y-1">
<li>{t('tools.html_markdown_converter.feature_1')}</li>
<li>{t('tools.html_markdown_converter.feature_2')}</li>
<li>{t('tools.html_markdown_converter.feature_3')}</li>
<li>{t('tools.html_markdown_converter.feature_4')}</li>
</ul>
<p className="mt-2">
{mode === 'md2html'
? t('tools.html_markdown_converter.md2html_description')
: t('tools.html_markdown_converter.html2md_description')}
</p>
</div>
</div>
</div>
</div>
{/* 回到顶部按钮 */}
<BackToTop />
</div>
);
}
@@ -0,0 +1,387 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faFileCode,
faCopy,
faCheck,
faRedo,
faExchangeAlt,
faInfoCircle
} from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
// 添加全局接口,使marked和turndown可以在window上使用
declare global {
interface Window {
marked: {
parse: (markdown: string, options?: Record<string, unknown>) => string;
[key: string]: unknown;
};
TurndownService: {
new (options?: Record<string, unknown>): {
turndown: (html: string) => string;
[key: string]: unknown;
};
};
}
}
export default function HtmlMarkdownConverter() {
// 输入与输出
const [input, setInput] = useState('');
const [output, setOutput] = useState('');
const [mode, setMode] = useState<'html2md' | 'md2html'>('md2html');
// 其他状态
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [isConverting, setIsConverting] = useState(false);
const [loadingModules, setLoadingModules] = useState(false);
const [modulesLoaded, setModulesLoaded] = useState(false);
// 动态加载marked和turndown库
useEffect(() => {
if (typeof window !== 'undefined' && !modulesLoaded) {
setLoadingModules(true);
const loadScripts = async () => {
try {
// 加载Marked库
const markedScript = document.createElement('script');
markedScript.src = '/lib/markdown/marked.min.js';
markedScript.async = true;
const markedPromise = new Promise<void>((resolve, reject) => {
markedScript.onload = () => {
console.log('Marked库加载成功');
resolve();
};
markedScript.onerror = (error) => {
console.error('加载本地Marked库失败,尝试从CDN加载:', error);
// 从CDN加载失败时的备用方案
const cdnMarkedScript = document.createElement('script');
cdnMarkedScript.src = 'https://cdn.jsdelivr.net/npm/marked/marked.min.js';
cdnMarkedScript.async = true;
cdnMarkedScript.onload = () => {
console.log('从CDN加载Marked库成功');
resolve();
};
cdnMarkedScript.onerror = (cdnError) => {
console.error('从CDN加载Marked库失败:', cdnError);
reject(new Error('加载Marked库失败'));
};
document.body.appendChild(cdnMarkedScript);
};
});
document.body.appendChild(markedScript);
// 加载Turndown库
const turndownScript = document.createElement('script');
turndownScript.src = '/lib/markdown/turndown.js';
turndownScript.async = true;
const turndownPromise = new Promise<void>((resolve, reject) => {
turndownScript.onload = () => {
console.log('Turndown库加载成功');
resolve();
};
turndownScript.onerror = (error) => {
console.error('加载本地Turndown库失败,尝试从CDN加载:', error);
// 从CDN加载失败时的备用方案
const cdnTurndownScript = document.createElement('script');
cdnTurndownScript.src = 'https://cdn.jsdelivr.net/npm/turndown/dist/turndown.js';
cdnTurndownScript.async = true;
cdnTurndownScript.onload = () => {
console.log('从CDN加载Turndown库成功');
resolve();
};
cdnTurndownScript.onerror = (cdnError) => {
console.error('从CDN加载Turndown库失败:', cdnError);
reject(new Error('加载Turndown库失败'));
};
document.body.appendChild(cdnTurndownScript);
};
});
document.body.appendChild(turndownScript);
// 等待两个脚本都加载完成
await Promise.all([markedPromise, turndownPromise]);
console.log('所有模块加载完成!');
setModulesLoaded(true);
setLoadingModules(false);
} catch (error) {
console.error('加载库失败:', error);
setError('加载转换库失败,请刷新页面重试');
setLoadingModules(false);
}
};
loadScripts();
}
return () => {
// 清理函数不需要移除脚本,因为它们会一直被缓存和重用
};
}, [modulesLoaded]);
// 转换函数
const convertContent = () => {
if (!input.trim()) {
setError('请输入需要转换的内容');
setOutput('');
return;
}
setIsConverting(true);
setError(null);
try {
if (mode === 'md2html') {
// Markdown 转 HTML
const html = window.marked.parse(input);
setOutput(html);
} else {
// HTML 转 Markdown
const turndownService = new window.TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced'
});
const markdown = turndownService.turndown(input);
setOutput(markdown);
}
setError(null);
} catch (err) {
console.error('转换错误:', err);
setError(`转换失败: ${err instanceof Error ? err.message : '未知错误'}`);
setOutput('');
} finally {
setIsConverting(false);
}
};
// 复制结果
const copyResult = () => {
if (!output) return;
navigator.clipboard.writeText(output)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => {
console.error('复制失败:', err);
setError('复制到剪贴板失败');
});
};
// 切换转换模式
const toggleMode = () => {
// 切换模式时交换输入和输出
setMode(prevMode => prevMode === 'md2html' ? 'html2md' : 'md2html');
setInput(output);
setOutput(input);
setError(null);
};
// 清空所有内容
const clearAll = () => {
setInput('');
setOutput('');
setError(null);
};
// 加载示例
const loadExample = () => {
if (mode === 'md2html') {
setInput(`# 示例标题
这是一段**粗体**文字和*斜体*文字。
## 子标题
- 列表项1
- 列表项2
- 列表项3
[这是一个链接](https://example.com)
\`\`\`javascript
// 这是一段代码
function hello() {
console.log("Hello, world!");
}
\`\`\`
> 这是一段引用文字
---
| 表头1 | 表头2 |
|-------|-------|
| 单元格1 | 单元格2 |
| 单元格3 | 单元格4 |
`);
} else {
setInput(`<h1>示例标题</h1>
<p>这是一段<strong>粗体</strong>文字和<em>斜体</em>文字。</p>
<h2>子标题</h2>
<ul>
<li>列表项1</li>
<li>列表项2</li>
<li>列表项3</li>
</ul>
<p><a href="https://example.com">这是一个链接</a></p>
<pre><code class="language-javascript">// 这是一段代码
function hello() {
console.log("Hello, world!");
}
</code></pre>
<blockquote>
<p>这是一段引用文字</p>
</blockquote>
<hr>
<table>
<thead>
<tr>
<th>表头1</th>
<th>表头2</th>
</tr>
</thead>
<tbody>
<tr>
<td>单元格1</td>
<td>单元格2</td>
</tr>
<tr>
<td>单元格3</td>
<td>单元格4</td>
</tr>
</tbody>
</table>
`);
}
};
return (
<div className="container mx-auto px-4 py-8 max-w-6xl">
<ToolHeader
title="HTML与Markdown互转"
description="HTML和Markdown文档格式转换"
icon={faFileCode}
/>
<div className="mb-6 flex flex-wrap gap-4">
<button
className={`btn-primary px-4 py-2 flex items-center gap-2 ${isConverting || loadingModules ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={convertContent}
disabled={isConverting || loadingModules || !input.trim()}
>
<FontAwesomeIcon icon={faFileCode} />
转换
</button>
<button
className="btn-secondary px-4 py-2 flex items-center gap-2"
onClick={toggleMode}
>
<FontAwesomeIcon icon={faExchangeAlt} />
切换: {mode === 'md2html' ? 'Markdown → HTML' : 'HTML → Markdown'}
</button>
<button
className={`btn-secondary px-4 py-2 flex items-center gap-2 ${!output ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={copyResult}
disabled={!output}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
{copied ? '已复制' : '复制'}
</button>
<button
className="btn-secondary px-4 py-2 flex items-center gap-2"
onClick={clearAll}
>
<FontAwesomeIcon icon={faRedo} />
清空
</button>
<button
className="btn-secondary px-4 py-2 flex items-center gap-2"
onClick={loadExample}
>
<FontAwesomeIcon icon={faInfoCircle} />
示例
</button>
</div>
{error && (
<div className="bg-red-900 bg-opacity-30 border border-red-700 text-red-100 px-4 py-2 rounded mb-4">
<FontAwesomeIcon icon={faInfoCircle} className="mr-2" />
{error}
</div>
)}
{loadingModules && (
<div className="bg-blue-900 bg-opacity-30 border border-blue-700 text-blue-100 px-4 py-2 rounded mb-4">
<FontAwesomeIcon icon={faInfoCircle} className="mr-2" />
正在加载转换库,请稍候...
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-2">
<h2 className="text-lg font-medium text-[#F1F5F9]">
{mode === 'md2html' ? 'Markdown' : 'HTML'}
</h2>
<textarea
className="w-full h-96 p-4 bg-[#1E293B] text-[#F1F5F9] rounded-lg border border-[rgba(99,102,241,0.15)] focus:border-[#6366F1] focus:ring-1 focus:ring-[#6366F1] outline-none font-mono"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={mode === 'md2html' ? '请输入Markdown内容...' : '请输入HTML内容...'}
spellCheck={false}
/>
</div>
<div className="space-y-2">
<h2 className="text-lg font-medium text-[#F1F5F9]">
{mode === 'md2html' ? 'HTML' : 'Markdown'}
</h2>
<textarea
className="w-full h-96 p-4 bg-[#1E293B] text-[#F1F5F9] rounded-lg border border-[rgba(99,102,241,0.15)] outline-none font-mono"
value={output}
readOnly
placeholder="转换结果将显示在这里..."
spellCheck={false}
/>
</div>
</div>
{mode === 'md2html' && output && (
<div className="mt-6">
<h2 className="text-lg font-medium text-[#F1F5F9] mb-2">预览</h2>
<div
className="p-4 bg-[#1E293B] rounded-lg border border-[rgba(99,102,241,0.15)] prose prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: output }}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,23 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import styles from '../styles';
interface ErrorDisplayProps {
error: string | null;
}
const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ error }) => {
if (!error) {
return null;
}
return (
<div className={styles.errorBox}>
<FontAwesomeIcon icon={faExclamationTriangle} className="mr-2 text-warning" />
{error}
</div>
);
};
export default ErrorDisplay;
@@ -0,0 +1,99 @@
import React from 'react';
interface JsonRendererProps {
data: unknown;
}
/**
* JSON格式化和语法高亮组件
*/
const JsonRenderer: React.FC<JsonRendererProps> = ({ data }) => {
// 递归渲染JSON对象
const renderJsonValue = (value: unknown, depth = 0, isLast = true): React.ReactNode => {
const indent = Array(depth * 2).fill(' ').join('');
// 处理不同类型的值
if (value === null) return <span className="text-error">null</span>;
if (value === undefined) return <span className="text-tertiary">undefined</span>;
if (typeof value === 'boolean') {
return <span className="text-warning">{value.toString()}</span>;
}
if (typeof value === 'number') {
return <span className="text-success">{value}</span>;
}
if (typeof value === 'string') {
return <span className="text-primary-light">&quot;{value}&quot;</span>;
}
// 处理数组
if (Array.isArray(value)) {
if (value.length === 0) return <span>[]</span>;
return (
<span>
<span>[</span>
<div style={{ paddingLeft: '20px' }}>
{value.map((item, index) => (
<div key={index}>
{renderJsonValue(item, depth + 1, index === value.length - 1)}
{index !== value.length - 1 && <span className="text-tertiary">,</span>}
</div>
))}
</div>
<span>{indent}]</span>
{!isLast && <span className="text-tertiary">,</span>}
</span>
);
}
// 处理对象
if (typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) return <span>{'{}'}</span>;
return (
<span>
<span>{'{'}</span>
<div style={{ paddingLeft: '20px' }}>
{entries.map(([key, val], index) => (
<div key={key}>
<span className="text-purple">&quot;{key}&quot;</span>
<span className="text-tertiary">: </span>
{renderJsonValue(val, depth + 1, index === entries.length - 1)}
{index !== entries.length - 1 && <span className="text-tertiary">,</span>}
</div>
))}
</div>
<span>{indent}{'}'}</span>
{!isLast && <span className="text-tertiary">,</span>}
</span>
);
}
return <span>{String(value)}</span>;
};
// 解析JSON字符串 (如果传入的是字符串)
const parseAndRender = () => {
try {
if (typeof data === 'string') {
const parsedData = JSON.parse(data);
return renderJsonValue(parsedData);
}
return renderJsonValue(data);
} catch {
return <span className="text-error">JSON: {String(data)}</span>;
}
};
return (
<div className="font-mono text-sm overflow-x-auto">
{parseAndRender()}
</div>
);
};
export default JsonRenderer;
@@ -0,0 +1,189 @@
import React, { useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCopy, faCheck, faDownload, faTimes, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons';
import { createPortal } from 'react-dom';
interface MarkdownPreviewProps {
markdown: string;
isOpen: boolean;
onClose: () => void;
}
const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({ markdown, isOpen, onClose }) => {
const [copied, setCopied] = React.useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// 处理关闭模态框
const handleClose = () => {
onClose();
};
// 处理点击模态框外部关闭
const handleOutsideClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (modalRef.current && e.target === e.currentTarget) {
handleClose();
}
};
// 处理复制文档内容
const handleCopy = () => {
navigator.clipboard.writeText(markdown)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => console.error('复制失败', err));
};
// 处理下载文档
const handleDownload = () => {
const blob = new Blob([markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// 使用固定的文件名,不再从文档标题提取
const fileName = 'api_document.md';
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// 打开ShowDoc官网
const openShowDoc = () => {
window.open('https://www.showdoc.com.cn/', '_blank');
};
// 监听ESC键关闭模态窗口
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
handleClose();
}
};
window.addEventListener('keydown', handleEsc);
return () => {
window.removeEventListener('keydown', handleEsc);
};
}, [isOpen]);
// 当打开模态框时,禁止背景滚动
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
// 自动聚焦文本框,但不选中内容,以便用户可以正常阅读
if (textareaRef.current) {
textareaRef.current.focus();
}
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
// 如果模态框未打开,不渲染任何内容
if (!isOpen) return null;
// 模态窗口的内容
const modalContent = (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center p-4 bg-block-strong/80 backdrop-blur-sm"
onClick={handleOutsideClick}
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0 }}
>
<div
ref={modalRef}
className="relative bg-block border border-purple-glow rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] flex flex-col"
>
{/* 模态框标题 */}
<div className="flex justify-between items-center px-6 py-4 border-b border-purple-glow/30">
<h3 className="text-lg font-medium text-primary"></h3>
<button
className="text-tertiary hover:text-primary"
onClick={handleClose}
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
{/* ShowDoc推荐信息 */}
<div className="px-6 py-3 bg-purple/10 border-b border-purple-glow/20">
<div className="flex items-center justify-between">
<div className="flex items-center">
<span className="text-sm text-secondary">
<span className="text-purple font-medium"></span>
使ShowDoc风格编写ShowDoc平台进行团队分享和管理
</span>
</div>
<button
onClick={openShowDoc}
className="text-xs text-purple hover:text-purple-hover flex items-center gap-1 transition-colors"
>
访ShowDoc官网
<FontAwesomeIcon icon={faExternalLinkAlt} className="text-[10px]" />
</button>
</div>
</div>
{/* 模态框内容 */}
<div className="flex-1 overflow-auto p-6">
<textarea
ref={textareaRef}
className="w-full h-full min-h-[500px] p-5 bg-block-strong border border-purple-glow/30 rounded-lg text-primary font-mono text-sm leading-relaxed resize-none focus:outline-none focus:border-purple-glow"
value={markdown}
readOnly
style={{
lineHeight: '1.7',
letterSpacing: '0.3px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
scrollBehavior: 'smooth',
whiteSpace: 'pre',
overflowWrap: 'normal',
tabSize: 2
}}
/>
</div>
{/* 模态框底部操作按钮 */}
<div className="flex justify-end gap-3 px-6 py-4 border-t border-purple-glow/30">
<span className="text-sm text-tertiary mr-auto">
使 <a href="https://www.showdoc.com.cn/" target="_blank" rel="noopener noreferrer" className="text-purple hover:underline">ShowDoc</a>
</span>
<button
className="btn-secondary flex items-center gap-2 px-4 py-2 text-sm"
onClick={handleDownload}
>
<FontAwesomeIcon icon={faDownload} />
</button>
<button
className="btn-primary flex items-center gap-2 px-4 py-2 text-sm"
onClick={handleCopy}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
{copied ? '已复制' : '复制文档'}
</button>
</div>
</div>
</div>
);
// 使用React Portal将模态窗口渲染到body元素下,保证它不受父元素影响
if (typeof document !== 'undefined') {
return createPortal(modalContent, document.body);
}
return null;
};
export default MarkdownPreview;
@@ -0,0 +1,124 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons';
import styles from '../styles';
import { FormField } from '../types';
import { useLanguage } from '@/context/LanguageContext';
interface RequestBodyProps {
bodyFormat: 'json' | 'text' | 'form';
body: string;
formFields: FormField[];
onBodyChange: (body: string) => void;
onBodyFormatChange: (format: 'json' | 'text' | 'form') => void;
onAddFormField: () => void;
onUpdateFormField: (id: string, key: string, value: string) => void;
onRemoveFormField: (id: string) => void;
}
const RequestBody: React.FC<RequestBodyProps> = ({
bodyFormat,
body,
formFields,
onBodyChange,
onBodyFormatChange,
onAddFormField,
onUpdateFormField,
onRemoveFormField,
}) => {
const { t } = useLanguage();
return (
<div>
{/* 请求体格式选择 */}
<div className="flex gap-2 mb-4">
<button
className={styles.methodButton(bodyFormat === 'json')}
onClick={() => onBodyFormatChange('json')}
>
{t('tools.http_tester.json_format')}
</button>
<button
className={styles.methodButton(bodyFormat === 'text')}
onClick={() => onBodyFormatChange('text')}
>
{t('tools.http_tester.text_format')}
</button>
<button
className={styles.methodButton(bodyFormat === 'form')}
onClick={() => onBodyFormatChange('form')}
>
{t('tools.http_tester.form_format')}
</button>
</div>
{bodyFormat === 'json' && (
<>
<textarea
value={body}
onChange={(e) => onBodyChange(e.target.value)}
placeholder='{\n "key": "value"\n}'
className={styles.textArea}
/>
<div className="mt-2 text-xs text-tertiary">
{t('tools.http_tester.enter_request_body')}
</div>
</>
)}
{bodyFormat === 'text' && (
<textarea
value={body}
onChange={(e) => onBodyChange(e.target.value)}
placeholder={t('tools.http_tester.enter_request_body')}
className={styles.textArea}
/>
)}
{bodyFormat === 'form' && (
<div>
<div className="mb-4">
<button
className="btn-secondary text-xs px-3 py-1"
onClick={onAddFormField}
>
<FontAwesomeIcon icon={faPlus} className="mr-1" />
{t('tools.http_tester.add_form_field')}
</button>
</div>
{formFields.map(field => (
<div key={field.id} className={styles.headerRow}>
<input
type="text"
placeholder={t('tools.http_tester.form_field_key')}
value={field.key}
onChange={(e) => onUpdateFormField(field.id, e.target.value, field.value)}
className="flex-1 bg-block text-primary px-3 py-1 text-sm rounded-md border border-purple-glow/20 focus:border-purple-glow"
/>
<input
type="text"
placeholder={t('tools.http_tester.form_field_value')}
value={field.value}
onChange={(e) => onUpdateFormField(field.id, field.key, e.target.value)}
className="flex-1 bg-block text-primary px-3 py-1 text-sm rounded-md border border-purple-glow/20 focus:border-purple-glow"
/>
<button
onClick={() => onRemoveFormField(field.id)}
className={styles.iconButton}
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
))}
<div className="mt-2 text-xs text-tertiary">
{t('tools.http_tester.enter_request_body')}
</div>
</div>
)}
</div>
);
};
export default RequestBody;
@@ -0,0 +1,629 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPaperPlane, faInfoCircle, faTimes, faCode, faServer, faNetworkWired } from '@fortawesome/free-solid-svg-icons';
import styles from '../styles';
import { HttpMethod, NetworkType } from '../types';
import { useLanguage } from '@/context/LanguageContext';
// 定义跨域配置弹窗组件
interface CorsModalProps {
isOpen: boolean;
onClose: () => void;
}
const CorsModal: React.FC<CorsModalProps> = ({ isOpen, onClose }) => {
const [activeTab, setActiveTab] = React.useState<'nginx' | 'php' | 'node' | 'java' | 'python' | 'go'>('nginx');
const { t } = useLanguage();
if (!isOpen) return null;
const tabClasses = (isActive: boolean) =>
`px-3 py-2 text-xs font-medium rounded-t-md ${
isActive
? 'bg-background text-purple border-t border-l border-r border-purple-glow/30'
: 'bg-card hover:bg-background/60 text-tertiary hover:text-secondary transition-colors'
}`;
return (
<>
{/* 背景蒙层 - 使用透明度动画 */}
<div
className="fixed inset-0 bg-black/70 z-50 backdrop-blur-sm animate-fadeIn"
onClick={onClose}
role="button"
aria-label={t('tools.http_tester.close')}
tabIndex={0}
></div>
{/* 弹窗本身 - 使用弹性盒使其居中 */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 animate-scaleIn">
<div
className="bg-card w-full max-w-3xl rounded-lg shadow-xl border border-purple-glow/30 overflow-hidden flex flex-col"
style={{
maxHeight: 'calc(100vh - 40px)',
transform: 'translate3d(0,0,0)' // 强制硬件加速,避免某些浏览器渲染问题
}}
onClick={(e) => e.stopPropagation()}
>
{/* 标题栏 */}
<div className="bg-background p-4 flex items-center justify-between border-b border-purple-glow/30 shrink-0">
<div className="flex items-center">
<FontAwesomeIcon icon={faNetworkWired} className="text-purple mr-2" />
<h3 className="text-primary font-medium">{t('tools.http_tester.cors_settings')}</h3>
</div>
<button
onClick={onClose}
className="text-tertiary hover:text-purple transition-colors p-1 rounded-full hover:bg-background/60"
aria-label={t('tools.http_tester.close')}
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
{/* 内容区 */}
<div className="p-5 overflow-auto flex-grow">
<p className="text-secondary mb-4">
{t('tools.http_tester.cors_description')}
</p>
<div className="bg-amber-900/20 border border-amber-500/30 p-3 rounded-md mb-5">
<p className="text-amber-400 text-sm flex items-start">
<FontAwesomeIcon icon={faInfoCircle} className="mr-2 mt-0.5" />
<span>{t('tools.http_tester.cors_warning')}</span>
</p>
</div>
{/* HTTPS到HTTP问题说明 */}
<div className="bg-purple-900/20 border border-purple-500/30 p-3 rounded-md mb-5">
<h4 className="font-medium text-purple-400 mb-1 flex items-center text-sm">
<FontAwesomeIcon icon={faInfoCircle} className="mr-2" />
{t('tools.http_tester.https_to_http_title')}
</h4>
<p className="text-secondary text-sm mb-2">
{t('tools.http_tester.https_to_http_description')}
</p>
<ul className="text-secondary text-sm list-disc pl-5 space-y-1">
<li className="font-medium">{t('tools.http_tester.solution_one')}
<ul className="list-disc ml-5 mt-1 text-xs font-normal">
<li>{t('tools.http_tester.solution_one_1')}</li>
<li>{t('tools.http_tester.solution_one_2')}</li>
<li>{t('tools.http_tester.solution_one_3')}</li>
</ul>
</li>
<li className="font-medium mt-2">{t('tools.http_tester.solution_two')}
<ul className="list-disc ml-5 mt-1 text-xs font-normal">
<li>{t('tools.http_tester.solution_two_1')}</li>
<li>{t('tools.http_tester.solution_two_2')}</li>
<li>{t('tools.http_tester.solution_two_3')}</li>
</ul>
</li>
</ul>
<div className="bg-red-900/20 border border-red-500/30 p-2 rounded mt-3 text-xs text-red-300">
<span className="font-medium">{t('tools.http_tester.security_note')}</span>
</div>
</div>
{/* 选项卡 - 使用sticky定位保持在顶部 */}
<div className="flex mb-0 gap-1 flex-wrap sticky top-0 bg-card pt-1 -mt-1 -mx-1 px-1 pb-1 z-10">
<button className={tabClasses(activeTab === 'nginx')} onClick={() => setActiveTab('nginx')}>
<FontAwesomeIcon icon={faServer} className="mr-1" /> Nginx
</button>
<button className={tabClasses(activeTab === 'php')} onClick={() => setActiveTab('php')}>
<FontAwesomeIcon icon={faCode} className="mr-1" /> PHP
</button>
<button className={tabClasses(activeTab === 'node')} onClick={() => setActiveTab('node')}>
<FontAwesomeIcon icon={faCode} className="mr-1" /> Node.js
</button>
<button className={tabClasses(activeTab === 'python')} onClick={() => setActiveTab('python')}>
<FontAwesomeIcon icon={faCode} className="mr-1" /> Python
</button>
<button className={tabClasses(activeTab === 'java')} onClick={() => setActiveTab('java')}>
<FontAwesomeIcon icon={faCode} className="mr-1" /> Java
</button>
<button className={tabClasses(activeTab === 'go')} onClick={() => setActiveTab('go')}>
<FontAwesomeIcon icon={faCode} className="mr-1" /> Go
</button>
</div>
{/* 代码区 */}
<div className="bg-background border border-purple-glow/30 rounded-md p-4 overflow-auto mt-2" style={{ maxHeight: '350px' }}>
{activeTab === 'nginx' && (
<pre className="text-xs text-secondary font-mono whitespace-pre">
<code>{`# 在 Nginx 的 server 或 location 块中添加:
location /api/ {
# 允许所有来源访问(开发环境使用)
add_header 'Access-Control-Allow-Origin' '*' always;
# 允许的请求方法
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS, PATCH' always;
# 允许的请求头
add_header 'Access-Control-Allow-Headers' 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Connection, User-Agent, Cookie' always;
# 允许浏览器缓存预检请求结果,单位秒
add_header 'Access-Control-Max-Age' '3600' always;
# 处理 OPTIONS 预检请求
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS, PATCH';
add_header 'Access-Control-Allow-Headers' 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Connection, User-Agent, Cookie';
add_header 'Access-Control-Max-Age' '3600';
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' '0';
return 204;
}
# 你的其他配置...
}`}</code>
</pre>
)}
{activeTab === 'php' && (
<pre className="text-xs text-secondary font-mono whitespace-pre">
<code>{`<?php
// 在 PHP 脚本开头添加以下代码:
// 允许所有来源访问(开发环境使用)
header("Access-Control-Allow-Origin: *");
// 如果需要发送 Cookie
// header("Access-Control-Allow-Origin: http://localhost:3000"); // 指定来源
// header("Access-Control-Allow-Credentials: true");
// 允许的请求方法
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS, PATCH");
// 允许的请求头
header("Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Authorization, Connection, User-Agent, Cookie");
// 处理 OPTIONS 预检请求
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
header("HTTP/1.1 204 No Content");
exit;
}
// 你的 PHP 代码...
`}</code>
</pre>
)}
{activeTab === 'node' && (
<pre className="text-xs text-secondary font-mono whitespace-pre">
<code>{`// 方法 1: 使用 Express 框架和 cors 中间件
const express = require('express');
const cors = require('cors');
const app = express();
// 基本配置: 允许所有来源
app.use(cors());
// 高级配置
app.use(cors({
origin: '*', // 或特定域名 'http://localhost:3000'
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: false // 如果需要发送 Cookie,设为 true
}));
// 方法 2: 不使用中间件,手动设置响应头
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
// 处理 OPTIONS 请求
if (req.method === 'OPTIONS') {
return res.status(204).send();
}
next();
});
// 你的路由代码...
`}</code>
</pre>
)}
{activeTab === 'python' && (
<pre className="text-xs text-secondary font-mono whitespace-pre">
<code>{`# 方法 1: 使用 Flask
from flask import Flask
from flask_cors import CORS
app = Flask(__name__)
# 允许所有路由的 CORS
CORS(app)
# 或者,更具体的配置
CORS(app, resources={
r"/api/*": {
"origins": "*", # 或特定域名 ["http://localhost:3000"]
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
"allow_headers": ["Content-Type", "Authorization"]
}
})
# 方法 2: 使用 FastAPI
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 允许所有来源
allow_credentials=False, # 是否支持 cookies
allow_methods=["*"], # 允许所有方法
allow_headers=["*"], # 允许所有头
)
# 方法 3: 使用 Django
# 在 settings.py 中添加:
INSTALLED_APPS = [
# ...其他应用
'corsheaders',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
# ...其他中间件
]
CORS_ALLOW_ALL_ORIGINS = True # 允许所有来源
# 或者指定来源
# CORS_ALLOWED_ORIGINS = [
# "http://localhost:3000",
# ]
`}</code>
</pre>
)}
{activeTab === 'java' && (
<pre className="text-xs text-secondary font-mono whitespace-pre">
<code>{`// 方法 1: 使用 Spring Boot (添加过滤器)
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
// 允许所有来源访问
config.addAllowedOrigin("*");
// 或者允许特定来源
// config.addAllowedOrigin("http://localhost:3000");
// 允许发送 Cookie
// config.setAllowCredentials(true);
// 允许的请求方法
config.addAllowedMethod("GET");
config.addAllowedMethod("POST");
config.addAllowedMethod("PUT");
config.addAllowedMethod("DELETE");
config.addAllowedMethod("OPTIONS");
// 允许的请求头
config.addAllowedHeader("*");
// 预检请求的缓存时间
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
// 方法 2: 使用 Spring Boot (使用 @CrossOrigin 注解)
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@CrossOrigin(origins = "*", allowedHeaders = "*")
public class MyController {
@GetMapping("/api/data")
public String getData() {
return "数据响应";
}
}
// 方法 3: 在 Servlet 中手动设置响应头
@WebServlet("/api/*")
public class ApiServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 设置 CORS 响应头
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
// 正常处理请求...
}
@Override
protected void doOptions(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 处理预检请求
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
response.setHeader("Access-Control-Max-Age", "3600");
response.setStatus(HttpServletResponse.SC_NO_CONTENT);
}
}
`}</code>
</pre>
)}
{activeTab === 'go' && (
<pre className="text-xs text-secondary font-mono whitespace-pre">
<code>{`// 方法 1: 使用 net/http 标准库
package main
import (
"net/http"
)
func setCorsHeaders(w http.ResponseWriter) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
}
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
setCorsHeaders(w)
// 处理预检请求
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// API 处理逻辑...
})
// 应用中间件
http.Handle("/api/", corsMiddleware(apiHandler))
http.ListenAndServe(":8080", nil)
}
// 方法 2: 使用 Gin 框架
package main
import (
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"time"
)
func main() {
r := gin.Default()
// CORS 中间件配置
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: false,
MaxAge: 12 * time.Hour,
}))
// 路由处理
r.GET("/api/data", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "数据响应",
})
})
r.Run(":8080")
}
`}</code>
</pre>
)}
</div>
</div>
{/* 底部按钮 */}
<div className="bg-background p-4 border-t border-purple-glow/30 flex justify-end shrink-0">
<button
className="btn-primary"
onClick={onClose}
>
</button>
</div>
</div>
</div>
</>
);
};
interface RequestFormProps {
url: string;
method: HttpMethod;
loading: boolean;
networkType: NetworkType;
onUrlChange: (url: string) => void;
onMethodChange: (method: HttpMethod) => void;
onNetworkTypeChange: (type: NetworkType) => void;
onSendRequest: () => void;
}
const RequestForm: React.FC<RequestFormProps> = ({
url,
method,
loading,
networkType,
onUrlChange,
onMethodChange,
onNetworkTypeChange,
onSendRequest,
}) => {
const { t } = useLanguage();
// HTTP方法列表
const methods: HttpMethod[] = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
// 显示或隐藏CORS设置弹窗
const [showCorsModal, setShowCorsModal] = React.useState(false);
// 检测是否为HTTP URL
const isHttpUrl = React.useMemo(() => {
try {
return url.trim().toLowerCase().startsWith('http://');
} catch {
return false;
}
}, [url]);
// 检测当前页面是否为HTTPS
const [isCurrentPageHttps, setIsCurrentPageHttps] = React.useState(false);
React.useEffect(() => {
// 仅在客户端执行
if (typeof window !== 'undefined') {
setIsCurrentPageHttps(window.location.protocol === 'https:');
}
}, []);
// 显示混合内容警告的条件
const showMixedContentWarning = networkType === 'local' && isHttpUrl && isCurrentPageHttps;
return (
<div className="mb-6">
<div className="flex gap-2 mb-2">
{methods.map(m => (
<button
key={m}
className={styles.methodButton(method === m)}
onClick={() => onMethodChange(m)}
>
{m}
</button>
))}
</div>
<div className="flex gap-2 mb-2">
<input
type="text"
value={url}
onChange={(e) => onUrlChange(e.target.value)}
placeholder={t('tools.http_tester.enter_url')}
className={`${styles.input} ${showMixedContentWarning ? 'border-amber-500 focus:border-amber-500' : ''}`}
/>
<button
className="btn-primary whitespace-nowrap"
onClick={onSendRequest}
disabled={loading}
>
{loading ? (
<span className="flex items-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{t('tools.http_tester.loading')}
</span>
) : (
<span className="flex items-center">
<FontAwesomeIcon icon={faPaperPlane} className="mr-2" />
{t('tools.http_tester.send_request')}
</span>
)}
</button>
</div>
{/* 混合内容特别警告 - 当检测到HTTPS页面请求HTTP URL时 */}
{showMixedContentWarning && (
<div className="mt-2 text-xs text-red-400 flex items-center mb-2">
<FontAwesomeIcon icon={faInfoCircle} className="mr-1" />
<span>{t('tools.http_tester.https_to_http_title')}</span>
<button
className="ml-2 underline text-purple text-xs hover:text-purple-light transition-colors"
onClick={() => setShowCorsModal(true)}
>
{t('tools.http_tester.cors_settings')}
</button>
</div>
)}
{/* 本地/局域网选项 */}
<div className="flex items-center gap-2 text-sm text-tertiary">
<div className="flex items-center">
<input
type="checkbox"
id="localNetwork"
checked={networkType === 'local'}
onChange={() => onNetworkTypeChange(networkType === 'local' ? 'public' : 'local')}
className="mr-1 accent-purple cursor-pointer"
/>
<label htmlFor="localNetwork" className="cursor-pointer">
{t('tools.http_tester.local_network')}
</label>
</div>
{networkType === 'local' && (
<div className="flex items-center gap-1">
<span className="text-amber-400 text-xs">
<FontAwesomeIcon icon={faInfoCircle} className="mr-1" />
{t('tools.http_tester.cors_settings')}
</span>
<button
className="text-purple text-xs border border-purple-glow/30 px-2 py-0.5 rounded hover:bg-background transition-colors"
onClick={() => setShowCorsModal(true)}
>
{t('tools.http_tester.cors_settings')}
</button>
</div>
)}
</div>
{/* 替换原来的大型警告框为一个小链接 */}
{networkType === 'local' && !showMixedContentWarning && (
<div className="mt-2 text-xs flex items-center">
<button
className="text-purple hover:text-purple-light transition-colors underline flex items-center"
onClick={() => setShowCorsModal(true)}
>
<FontAwesomeIcon icon={faInfoCircle} className="mr-1" />
{t('tools.http_tester.https_to_http_title')}
</button>
</div>
)}
{/* CORS设置弹窗 */}
<CorsModal
isOpen={showCorsModal}
onClose={() => setShowCorsModal(false)}
/>
</div>
);
};
export default RequestForm;
@@ -0,0 +1,63 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons';
import styles from '../styles';
import { RequestHeader } from '../types';
import { useLanguage } from '@/context/LanguageContext';
interface RequestHeadersProps {
headers: RequestHeader[];
onAddHeader: () => void;
onUpdateHeader: (id: string, key: string, value: string) => void;
onRemoveHeader: (id: string) => void;
}
const RequestHeaders: React.FC<RequestHeadersProps> = ({
headers,
onAddHeader,
onUpdateHeader,
onRemoveHeader,
}) => {
const { t } = useLanguage();
return (
<div>
<div className="mb-4">
<button
className="btn-secondary text-xs px-3 py-1"
onClick={onAddHeader}
>
<FontAwesomeIcon icon={faPlus} className="mr-1" />
{t('tools.http_tester.add_header')}
</button>
</div>
{headers.map(header => (
<div key={header.id} className={styles.headerRow}>
<input
type="text"
placeholder={t('tools.http_tester.header_key')}
value={header.key}
onChange={(e) => onUpdateHeader(header.id, e.target.value, header.value)}
className="flex-1 bg-block text-primary px-3 py-1 text-sm rounded-md border border-purple-glow/20 focus:border-purple-glow"
/>
<input
type="text"
placeholder={t('tools.http_tester.header_value')}
value={header.value}
onChange={(e) => onUpdateHeader(header.id, header.key, e.target.value)}
className="flex-1 bg-block text-primary px-3 py-1 text-sm rounded-md border border-purple-glow/20 focus:border-purple-glow"
/>
<button
onClick={() => onRemoveHeader(header.id)}
className={styles.iconButton}
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
))}
</div>
);
};
export default RequestHeaders;
@@ -0,0 +1,88 @@
import React from 'react';
import styles from '../styles';
import { HistoryItem, HttpMethod } from '../types';
import { useLanguage } from '@/context/LanguageContext';
interface RequestHistoryProps {
history: HistoryItem[];
showHistory: boolean;
onToggleHistory: () => void;
onClearHistory: () => void;
onLoadFromHistory: (item: {url: string, method: HttpMethod}) => void;
}
const RequestHistory: React.FC<RequestHistoryProps> = ({
history,
showHistory,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onToggleHistory,
onClearHistory,
onLoadFromHistory,
}) => {
const { t } = useLanguage();
// 格式化日期
const formatDate = (date: Date) => {
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
};
return (
<div className={styles.card}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg text-primary font-medium">{t('tools.http_tester.history')}</h2>
<div className="flex gap-2">
{/* <button
className="text-sm text-tertiary hover:text-secondary"
onClick={onToggleHistory}
>
{showHistory ? '隐藏' : '显示'}
</button> */}
{history.length > 0 && (
<button
className="text-sm text-tertiary hover:text-error"
onClick={onClearHistory}
>
{t('tools.http_tester.clear_history')}
</button>
)}
</div>
</div>
{history.length > 0 ? (
<div className="space-y-1">
{history.map((item, index) => (
<div
key={index}
className={styles.historyItem}
onClick={() => onLoadFromHistory(item)}
>
<div className="flex items-center gap-2">
<span className={styles.historyMethod(item.method)}>
{item.method}
</span>
<span className="text-sm text-primary truncate max-w-[200px]">
{item.url}
</span>
</div>
<span className="text-xs text-tertiary">
{formatDate(item.timestamp)}
</span>
</div>
))}
</div>
) : (
<div className="text-center text-tertiary py-4">
{t('tools.http_tester.history_empty')}
</div>
)}
{!showHistory && history.length > 0 && (
<div className="text-sm text-tertiary mt-4">
<p>{t('tools.http_tester.history')}</p>
</div>
)}
</div>
);
};
export default RequestHistory;
@@ -0,0 +1,321 @@
import React, { useState, useEffect, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGlobe, faCopy, faCheck, faFileAlt, faInfoCircle } from '@fortawesome/free-solid-svg-icons';
import styles from '../styles';
import { HttpResponse, HttpMethod, NetworkType } from '../types';
import JsonRenderer from './JsonRenderer';
import { generateMarkdownDoc } from '../utils';
import MarkdownPreview from './MarkdownPreview';
import { useLanguage } from '@/context/LanguageContext';
interface ResponseDisplayProps {
response: HttpResponse | null;
url: string;
method: HttpMethod;
headers: {key: string; value: string; id: string}[];
body: string;
bodyFormat: 'json' | 'text' | 'form';
formFields: {key: string; value: string; id: string}[];
networkType: NetworkType;
}
const ResponseDisplay: React.FC<ResponseDisplayProps> = ({
response,
url,
method,
headers,
body,
bodyFormat,
formFields,
networkType
}) => {
const { t } = useLanguage();
const [responseTab, setResponseTab] = useState<'body' | 'headers' | 'info'>('body');
const [copiedJson, setCopiedJson] = useState(false);
const [markdownContent, setMarkdownContent] = useState<string>('');
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
// 美化JSON
const formatJson = (json: string): string => {
try {
return JSON.stringify(JSON.parse(json), null, 2);
} catch {
return json;
}
};
// 检测内容是否为JSON
const isJsonContent = (data: unknown): boolean => {
// 检查响应头中的Content-Type
if (response?.headers && response.headers['content-type']?.includes('application/json')) {
return true;
}
// 对于对象类型的数据,直接判定为JSON
if (typeof data === 'object' && data !== null) {
return true;
}
// 尝试解析字符串
if (typeof data === 'string') {
try {
JSON.parse(data);
return true;
} catch {
return false;
}
}
return false;
};
// 复制响应内容
const copyResponse = () => {
if (!response) return;
const textToCopy = responseTab === 'body'
? typeof response.data === 'string'
? response.data
: JSON.stringify(response.data, null, 2)
: Object.entries(response.headers)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
navigator.clipboard.writeText(textToCopy)
.then(() => {
setCopiedJson(true);
setTimeout(() => setCopiedJson(false), 2000);
})
.catch(err => console.error(t('tools.http_tester.copy_failed'), err));
};
// 生成并显示Markdown接口文档
const handleGenerateMarkdown = () => {
if (!response) return;
const markdownDoc = generateMarkdownDoc(
url,
method,
headers,
body,
bodyFormat,
formFields,
response,
networkType
);
setMarkdownContent(markdownDoc);
setShowMarkdownPreview(true);
};
// 关闭Markdown预览窗口
const handleCloseMarkdownPreview = () => {
setShowMarkdownPreview(false);
};
// 调整响应区域高度以适应可用空间并跟随内容变化
useEffect(() => {
if (!response) return;
const adjustHeight = () => {
if (!containerRef.current || !contentRef.current) return;
// 计算可用的视窗高度
const viewportHeight = window.innerHeight;
// 获取容器到视窗顶部的距离
const containerTop = containerRef.current.getBoundingClientRect().top;
// 设置底部边距
const bottomMargin = 40;
// 计算容器可用的最大高度(视口高度限制)
const maxViewportHeight = viewportHeight - containerTop - bottomMargin;
// 获取内容实际高度
const contentHeight = contentRef.current.scrollHeight;
// 设置容器初始高度为视口可用高度
let targetHeight = Math.max(600, maxViewportHeight);
// 如果内容高度超过初始高度,则让容器跟随内容增高
// 最小高度600px,最大不超过内容高度+100px(为头部和边距预留空间)
if (contentHeight > targetHeight - 100) {
targetHeight = Math.min(contentHeight + 100, 2000); // 设置一个最大值2000px,防止过长
}
// 应用高度
containerRef.current.style.minHeight = `${targetHeight}px`;
};
// 初始调整
adjustHeight();
// 设置一个延时调整,确保内容渲染完成后再次计算高度
const timeoutId = setTimeout(adjustHeight, 100);
// 监听窗口大小变化
window.addEventListener('resize', adjustHeight);
return () => {
window.removeEventListener('resize', adjustHeight);
clearTimeout(timeoutId);
};
}, [response, responseTab]);
return (
<div className={`${styles.card}`} ref={containerRef}>
<h2 className="text-lg text-primary font-medium mb-4">{t('tools.http_tester.response_result')}</h2>
{response ? (
<div className="flex flex-col flex-grow w-full">
{/* 响应头部 */}
<div className={styles.responseHeader}>
<div className="flex items-center gap-2">
<span className={styles.statusBadge(response.status)}>
{response.status}
</span>
<span className="text-sm text-secondary">
{response.statusText}
</span>
</div>
<div className="flex items-center gap-3">
<div className={styles.statsText}>
{response.size} bytes | {response.time}ms
</div>
<button
className={styles.copyButton}
onClick={copyResponse}
title={t('tools.http_tester.copy')}
>
<FontAwesomeIcon icon={copiedJson ? faCheck : faCopy} />
{copiedJson ? t('tools.http_tester.copied') : t('tools.http_tester.copy')}
</button>
<button
className={styles.copyButton}
onClick={handleGenerateMarkdown}
title={t('tools.http_tester.generate_doc')}
>
<FontAwesomeIcon icon={faFileAlt} className="mr-1" />
{t('tools.http_tester.generate_doc')}
</button>
</div>
</div>
{/* 响应标签页 */}
<div className="border-b border-purple-glow/30 mb-4">
<div className="flex">
<button
className={styles.tabButton(responseTab === 'body')}
onClick={() => setResponseTab('body')}
>
{t('tools.http_tester.response_body')}
</button>
<button
className={styles.tabButton(responseTab === 'headers')}
onClick={() => setResponseTab('headers')}
>
{t('tools.http_tester.response_headers')}
</button>
<button
className={styles.tabButton(responseTab === 'info')}
onClick={() => setResponseTab('info')}
>
{t('tools.http_tester.request_info')}
</button>
</div>
</div>
{/* 响应内容 */}
<div className={`${styles.responseBox} flex-grow`} ref={contentRef}>
{responseTab === 'body' && (
<>
{isJsonContent(response.data)
? <JsonRenderer data={response.data} />
: (typeof response.data === 'string'
? response.data
: formatJson(JSON.stringify(response.data)))}
</>
)}
{responseTab === 'headers' && (
Object.entries(response.headers).map(([key, value]) => (
<div key={key}>
<span className="text-purple">{key}</span>: {value}
</div>
))
)}
{responseTab === 'info' && (
<div className="space-y-2">
<div className="text-sm">
<span className="text-purple font-medium">{t('tools.http_tester.network_mode')}:</span> {networkType === 'local' ? t('tools.http_tester.network_mode_local') : t('tools.http_tester.network_mode_public')}
{networkType === 'local' && (
<span className="ml-2 text-amber-400 text-xs">
<FontAwesomeIcon icon={faInfoCircle} className="mr-1" />
{t('tools.http_tester.cors_description')}
</span>
)}
</div>
<div className="text-sm">
<span className="text-purple font-medium">{t('tools.http_tester.request_url')}:</span> {url}
</div>
<div className="text-sm">
<span className="text-purple font-medium">{t('tools.http_tester.request_method')}:</span> {method}
</div>
<div className="text-sm">
<span className="text-purple font-medium">{t('tools.http_tester.response_result')}:</span> {response.time}ms
</div>
<div className="text-sm">
<span className="text-purple font-medium">{t('tools.http_tester.response_result')}:</span> {response.size} bytes
</div>
{Object.entries(headers).length > 0 && (
<div>
<div className="text-purple font-medium text-sm mt-4 mb-2">{t('tools.http_tester.request_headers')}</div>
<div className="bg-background/40 p-3 rounded text-xs">
{headers.filter(h => h.key && h.value).map((header, index) => (
<div key={header.id || index}>
{header.key}: {header.value}
</div>
))}
</div>
</div>
)}
{['POST', 'PUT', 'PATCH'].includes(method) && (
<div>
<div className="text-purple font-medium text-sm mt-4 mb-2">{t('tools.http_tester.request_body')}</div>
<div className="bg-background/40 p-3 rounded text-xs break-all font-mono">
{bodyFormat === 'json' ? formatJson(body) :
bodyFormat === 'form' ?
formFields.filter(f => f.key && f.value)
.map((field, _index) => `${field.key}=${field.value}`)
.join('&') :
body}
</div>
</div>
)}
</div>
)}
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-20 text-tertiary">
<FontAwesomeIcon icon={faGlobe} className="text-4xl mb-4 text-purple-glow" />
<p>{t('tools.http_tester.enter_url')}</p>
</div>
)}
{/* Markdown预览模态窗口 */}
<MarkdownPreview
markdown={markdownContent}
isOpen={showMarkdownPreview}
onClose={handleCloseMarkdownPreview}
/>
</div>
);
};
export default ResponseDisplay;
+384
View File
@@ -0,0 +1,384 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGlobe, faTrash } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import tools from '@/config/tools';
import styles from './styles';
import { useLanguage } from '@/context/LanguageContext';
// 导入类型
import { HttpMethod, RequestHeader, HttpResponse, HistoryItem, NetworkType } from './types';
// 导入组件
import RequestForm from './components/RequestForm';
import RequestHeaders from './components/RequestHeaders';
import RequestBody from './components/RequestBody';
import ResponseDisplay from './components/ResponseDisplay';
import RequestHistory from './components/RequestHistory';
import ErrorDisplay from './components/ErrorDisplay';
// 导入工具函数
import { sendHttpRequest } from './utils';
// 本地存储的key
const HISTORY_STORAGE_KEY = 'http_tester_history';
export default function HttpTester() {
const { t } = useLanguage();
// 从工具配置中获取当前工具信息
const toolConfig = tools.find(tool => tool.code === 'http_tester');
// 请求配置
const [url, setUrl] = useState('https://jsonplaceholder.typicode.com/posts/1');
const [method, setMethod] = useState<HttpMethod>('GET');
const [headers, setHeaders] = useState<RequestHeader[]>([
{ key: 'Content-Type', value: 'application/json', id: Date.now().toString() }
]);
const [body, setBody] = useState('');
const [bodyFormat, setBodyFormat] = useState<'json' | 'text' | 'form'>('json');
// 添加表单字段状态
const [formFields, setFormFields] = useState<RequestHeader[]>([
{ key: '', value: '', id: Date.now().toString() }
]);
// 网络类型(新增)
const [networkType, setNetworkType] = useState<NetworkType>('public');
// 响应状态
const [response, setResponse] = useState<HttpResponse | null>(null);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState<'body' | 'headers'>('body');
const [error, setError] = useState<string | null>(null);
const [showHistory, setShowHistory] = useState(true); // 默认显示历史记录
// 历史记录
const [history, setHistory] = useState<HistoryItem[]>([]);
// 上一次bodyFormat的引用
const prevBodyFormatRef = useRef(bodyFormat);
// 从本地存储加载历史记录 - 放在顶部优先执行
useEffect(() => {
// 确保在客户端运行
if (typeof window === 'undefined') return;
try {
// 直接从localStorage获取数据
const savedHistory = localStorage.getItem(HISTORY_STORAGE_KEY);
if (savedHistory) {
const parsedHistory = JSON.parse(savedHistory);
// 转换时间戳为Date对象
const processedHistory = parsedHistory.map((item: {url: string, method: HttpMethod, timestamp: string}) => ({
...item,
timestamp: new Date(item.timestamp)
}));
// 设置历史记录
setHistory(processedHistory);
}
} catch (e) {
console.error(t('tools.http_tester.copy_failed'), e);
}
}, [t]);
// 监听bodyFormat变化,自动更新Content-Type
useEffect(() => {
// 如果bodyFormat没有变化,直接返回
if (prevBodyFormatRef.current === bodyFormat) return;
// 更新上一次的bodyFormat
prevBodyFormatRef.current = bodyFormat;
// 获取对应的Content-Type值
let newContentType = '';
if (bodyFormat === 'json') {
newContentType = 'application/json';
} else if (bodyFormat === 'form') {
newContentType = 'application/x-www-form-urlencoded';
} else if (bodyFormat === 'text') {
newContentType = 'text/plain';
}
// 使用函数式更新,避免依赖headers
setHeaders(prevHeaders => {
// 查找现有的Content-Type请求头
const contentTypeIndex = prevHeaders.findIndex(
h => h.key.toLowerCase() === 'content-type'
);
// 如果找到了Content-Type并且需要更新
if (contentTypeIndex !== -1) {
const updatedHeaders = [...prevHeaders];
updatedHeaders[contentTypeIndex] = {
...updatedHeaders[contentTypeIndex],
value: newContentType
};
return updatedHeaders;
} else if (newContentType) {
// 如果没有找到Content-Type但需要添加
return [
...prevHeaders,
{ key: 'Content-Type', value: newContentType, id: Date.now().toString() }
];
}
// 没有变化时返回原来的headers
return prevHeaders;
});
}, [bodyFormat]); // 只依赖bodyFormat
// 在组件挂载时检查是否是从首页导航过来
useEffect(() => {
// 确保在客户端运行
if (typeof window !== 'undefined') {
// 检查是否是从首页导航过来的标记
const fromHomepage = sessionStorage.getItem('from_homepage');
if (fromHomepage) {
// 确保历史记录显示
setShowHistory(true);
// 清除标记
sessionStorage.removeItem('from_homepage');
}
}
}, []);
// 当历史记录更新时保存到本地存储
useEffect(() => {
if (typeof window === 'undefined' || history.length === 0) return;
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(history));
}, [history]);
// 添加请求头
const addHeader = () => {
setHeaders(prevHeaders => [...prevHeaders, { key: '', value: '', id: Date.now().toString() }]);
};
// 更新请求头
const updateHeader = (id: string, key: string, value: string) => {
setHeaders(prevHeaders =>
prevHeaders.map(header =>
header.id === id ? { ...header, key, value } : header
)
);
};
// 删除请求头
const removeHeader = (id: string) => {
setHeaders(prevHeaders => prevHeaders.filter(header => header.id !== id));
};
// 添加表单字段
const addFormField = () => {
setFormFields(prevFields => [...prevFields, { key: '', value: '', id: Date.now().toString() }]);
};
// 更新表单字段
const updateFormField = (id: string, key: string, value: string) => {
setFormFields(prevFields =>
prevFields.map(field =>
field.id === id ? { ...field, key, value } : field
)
);
};
// 删除表单字段
const removeFormField = (id: string) => {
setFormFields(prevFields => prevFields.filter(field => field.id !== id));
};
// 清空所有内容
const clearAll = () => {
// 一次性重置所有状态
setUrl('https://jsonplaceholder.typicode.com/posts/1');
setMethod('GET');
setHeaders([{ key: 'Content-Type', value: 'application/json', id: Date.now().toString() }]);
setBody('');
setFormFields([{ key: '', value: '', id: Date.now().toString() }]);
setResponse(null);
setError(null);
// 确保bodyFormat匹配Content-Type
setBodyFormat('json');
};
// 发送请求
const handleSendRequest = async () => {
setLoading(true);
setResponse(null);
setError(null);
const result = await sendHttpRequest(
url,
method,
headers,
body,
bodyFormat,
formFields,
networkType
);
if (result.error) {
setError(result.error);
} else if (result.response) {
setResponse(result.response);
// 更新历史记录
setHistory(prev => [
{ url, method, timestamp: new Date() },
...prev.slice(0, 9) // 保留最近10条
]);
}
setLoading(false);
};
// 从历史记录加载请求
const loadFromHistory = (historyItem: {url: string, method: HttpMethod}) => {
setUrl(historyItem.url);
setMethod(historyItem.method);
// 不重置其他状态,保留当前的请求头和请求体
};
// 清空历史记录
const clearHistory = () => {
if (confirm(t('tools.http_tester.clear_history_confirm'))) {
setHistory([]);
localStorage.removeItem(HISTORY_STORAGE_KEY);
}
};
// 切换历史记录显示状态
const toggleHistory = () => {
setShowHistory(!showHistory);
};
return (
<div className={styles.container}>
{/* 工具头部 */}
<ToolHeader
icon={toolConfig?.icon || faGlobe}
toolCode="http_tester"
title=""
description=""
/>
{/* 轻量提示信息 */}
<div className="mb-4 text-xs text-tertiary italic text-right">
<a
href="https://www.showdoc.com.cn/runapi"
target="_blank"
rel="noopener noreferrer"
className="text-purple hover:text-purple-hover transition-colors"
>
{t('tools.http_tester.need_advanced')}
</a>
</div>
{/* 错误提示 */}
<ErrorDisplay error={error} />
{/* 主要内容区 */}
<div className={styles.grid}>
{/* 左侧 - 请求配置 */}
<div className="space-y-6 flex flex-col">
{/* 请求表单 */}
<div className={styles.card}>
<div className={styles.formHeader}>
<h2 className="text-lg text-primary font-medium">{t('tools.http_tester.http_request')}</h2>
<button
className="btn-secondary text-xs px-3 py-1"
onClick={clearAll}
>
<FontAwesomeIcon icon={faTrash} className="mr-1" />
{t('tools.http_tester.clear')}
</button>
</div>
{/* 请求URL和方法 */}
<RequestForm
url={url}
method={method}
loading={loading}
networkType={networkType}
onUrlChange={setUrl}
onMethodChange={setMethod}
onNetworkTypeChange={setNetworkType}
onSendRequest={handleSendRequest}
/>
{/* 请求参数标签页 */}
<div className="border-b border-purple-glow/30 mb-4">
<div className="flex">
<button
className={styles.tabButton(activeTab === 'headers')}
onClick={() => setActiveTab('headers')}
>
{t('tools.http_tester.request_headers')}
</button>
<button
className={styles.tabButton(activeTab === 'body')}
onClick={() => setActiveTab('body')}
>
{t('tools.http_tester.request_body')}
</button>
</div>
</div>
{/* 请求头 */}
{activeTab === 'headers' && (
<RequestHeaders
headers={headers}
onAddHeader={addHeader}
onUpdateHeader={updateHeader}
onRemoveHeader={removeHeader}
/>
)}
{/* 请求体 */}
{activeTab === 'body' && (
<RequestBody
bodyFormat={bodyFormat}
body={body}
formFields={formFields}
onBodyChange={setBody}
onBodyFormatChange={setBodyFormat}
onAddFormField={addFormField}
onUpdateFormField={updateFormField}
onRemoveFormField={removeFormField}
/>
)}
</div>
{/* 历史记录 */}
<RequestHistory
history={history}
showHistory={showHistory}
onToggleHistory={toggleHistory}
onClearHistory={clearHistory}
onLoadFromHistory={loadFromHistory}
/>
</div>
{/* 右侧 - 响应结果 */}
<div className="flex w-full">
<ResponseDisplay
response={response}
url={url}
method={method}
headers={headers}
body={body}
bodyFormat={bodyFormat}
formFields={formFields}
networkType={networkType}
/>
</div>
</div>
{/* 回到顶部按钮 */}
<BackToTop position="bottom-right" offset={30} size="medium" />
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
// 定义样式对象
const styles = {
card: "card p-6 h-full flex flex-col transition-all duration-300 w-full",
input: "search-input w-full",
textArea: "w-full h-48 p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple resize-y",
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6 pb-16",
grid: "grid grid-cols-1 lg:grid-cols-2 gap-6 flex-grow",
tabButton: (active: boolean) => `px-4 py-2 text-sm font-medium ${active ? 'text-purple border-b-2 border-purple' : 'text-tertiary hover:text-secondary'}`,
methodButton: (active: boolean) => `px-3 py-1 text-xs rounded-md ${active ? 'bg-purple-glow/20 text-purple' : 'bg-block-strong text-secondary'}`,
responseHeader: "flex items-center justify-between bg-block-strong p-3 rounded-t-lg",
statusBadge: (status: number) => {
if (status >= 200 && status < 300) return "px-2 py-1 bg-green-900/20 text-success text-xs rounded-md";
if (status >= 300 && status < 400) return "px-2 py-1 bg-blue-900/20 text-blue-500 text-xs rounded-md";
if (status >= 400 && status < 500) return "px-2 py-1 bg-yellow-900/20 text-warning text-xs rounded-md";
return "px-2 py-1 bg-red-900/20 text-error text-xs rounded-md";
},
responseBox: "bg-block p-3 border border-purple-glow rounded-lg font-mono text-sm text-primary overflow-auto min-h-[400px] flex-grow whitespace-pre-wrap w-full",
historyItem: "flex items-center justify-between p-2 hover:bg-block-hover rounded-md cursor-pointer",
historyMethod: (method: string) => {
const colors: Record<string, string> = {
GET: "bg-green-900/20 text-success",
POST: "bg-blue-900/20 text-blue-500",
PUT: "bg-yellow-900/20 text-warning",
DELETE: "bg-red-900/20 text-error",
PATCH: "bg-purple-900/20 text-purple",
default: "bg-block-strong text-secondary"
};
return `px-2 py-1 text-xs rounded-md ${colors[method] || colors.default}`;
},
formHeader: "mb-4 flex items-center justify-between",
headerRow: "flex items-center gap-2 mb-2",
errorBox: "p-3 bg-red-900/20 border border-red-700/30 text-error rounded-lg mb-4",
iconButton: "p-1 text-secondary hover:text-primary",
copyButton: "flex items-center gap-1 text-xs px-2 py-1 rounded bg-block-strong hover:bg-block-hover text-secondary transition-colors",
statsText: "text-xs text-tertiary",
};
export default styles;
+36
View File
@@ -0,0 +1,36 @@
// 定义HTTP方法类型
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
// 定义请求头类型
export type RequestHeader = {
key: string;
value: string;
id: string;
};
// 定义历史记录项类型
export type HistoryItem = {
url: string;
method: HttpMethod;
timestamp: Date;
};
// 定义响应数据类型
export type ResponseData = string | Record<string, unknown>;
// 定义响应类型
export type HttpResponse = {
status: number;
statusText: string;
headers: Record<string, string>;
data: ResponseData;
time: number;
size: number;
error?: string;
};
// 定义表单字段类型 (与请求头结构相同)
export type FormField = RequestHeader;
// 定义网络类型(本地/公网)
export type NetworkType = 'public' | 'local';
+348
View File
@@ -0,0 +1,348 @@
import { HttpMethod, HttpResponse, RequestHeader, NetworkType, ResponseData } from './types';
import { apiClient } from '@/lib/api-client';
// 代理响应接口
interface ProxyResponse {
status: number;
statusText: string;
headers: Record<string, string>;
data: ResponseData;
time?: number;
size?: number;
}
/**
* 发送HTTP请求
* @param url 请求URL
* @param method 请求方法
* @param headers 请求头
* @param body 请求体
* @param bodyFormat 请求体格式
* @param formFields 表单字段
* @param networkType 网络类型(公网/本地)
*/
export const sendHttpRequest = async (
url: string,
method: HttpMethod,
headers: RequestHeader[],
body: string,
bodyFormat: 'json' | 'text' | 'form',
formFields: RequestHeader[],
networkType: NetworkType = 'public'
): Promise<{ response: HttpResponse | null; error: string | null }> => {
try {
if (!url.trim()) {
return { response: null, error: 'URL不能为空' };
}
// 准备请求头
const headerObj: Record<string, string> = {};
headers.forEach(h => {
if (h.key.trim() && h.value.trim()) {
headerObj[h.key] = h.value;
}
});
// 准备请求体
let requestBody: string | FormData | undefined;
if (['POST', 'PUT', 'PATCH'].includes(method)) {
if (bodyFormat === 'json') {
try {
// 尝试验证JSON格式
if (body.trim()) {
JSON.parse(body);
}
requestBody = body;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
// 忽略具体错误,只返回格式无效的消息
return { response: null, error: '请求体不是有效的JSON格式' };
}
} else if (bodyFormat === 'form') {
try {
// 使用表单字段构建请求体
const formData = new URLSearchParams();
formFields.forEach(field => {
if (field.key.trim() && field.value.trim()) {
formData.append(field.key, field.value);
}
});
if ([...formData.keys()].length === 0) {
return { response: null, error: '表单至少需要一个有效的字段' };
}
requestBody = formData.toString();
// 设置适当的Content-Type
headerObj['Content-Type'] = 'application/x-www-form-urlencoded';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
// 忽略具体错误,只返回处理失败的消息
return { response: null, error: '表单数据处理失败,请检查输入的字段值' };
}
} else {
requestBody = body;
}
}
const startTime = performance.now();
// 根据网络类型决定发送请求的方式
let responseData: ProxyResponse;
let clientTime: number;
// 如果是本地/局域网请求,直接发送请求(不通过代理)
if (networkType === 'local') {
try {
// 直接使用fetch API发送请求到目标地址
const response = await fetch(url, {
method,
headers: headerObj,
body: ['POST', 'PUT', 'PATCH'].includes(method) ? requestBody : undefined,
});
const endTime = performance.now();
clientTime = Math.round(endTime - startTime);
// 获取响应头
const headers: Record<string, string> = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
// 尝试解析响应体
let data: ResponseData;
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
data = await response.json();
} else {
data = await response.text();
}
// 计算响应大小
const bodyText = typeof data === 'string' ? data : JSON.stringify(data);
const size = new Blob([bodyText]).size;
responseData = {
status: response.status,
statusText: response.statusText,
headers,
data,
time: clientTime,
size,
};
} catch (error) {
return {
response: null,
error: `本地请求失败: ${(error as Error).message}。请确保目标服务器已配置CORS,允许跨域请求。`
};
}
} else {
// 通过后端代理发送请求,避免跨域问题
const proxyResponse = await apiClient.post<ProxyResponse>('/api/proxy', {
url,
method,
headers: headerObj,
body: requestBody,
});
const endTime = performance.now();
clientTime = Math.round(endTime - startTime);
responseData = proxyResponse;
}
return {
response: {
status: responseData.status,
statusText: responseData.statusText,
headers: responseData.headers,
data: responseData.data,
// 使用服务器计算的响应时间或客户端时间
time: responseData.time || clientTime,
size: responseData.size || 0,
},
error: null
};
} catch (error) {
console.error('请求错误', error);
return {
response: null,
error: (error as Error).message || '请求失败'
};
}
};
/**
* 生成Markdown格式的接口文档
* @param url 请求URL
* @param method 请求方法
* @param headers 请求头
* @param requestBody 请求体
* @param bodyFormat 请求体格式
* @param formFields 表单字段
* @param response 响应数据
* @param networkType 网络类型(公网/本地)
* @returns Markdown格式的接口文档
*/
export const generateMarkdownDoc = (
url: string,
method: HttpMethod,
headers: RequestHeader[],
requestBody: string,
bodyFormat: 'json' | 'text' | 'form',
formFields: RequestHeader[],
response: HttpResponse | null,
networkType: NetworkType = 'public'
): string => {
// 准备请求体展示
let requestBodyContent = '';
if (['POST', 'PUT', 'PATCH'].includes(method)) {
if (bodyFormat === 'json' && requestBody.trim()) {
try {
// 美化JSON
const parsedJson = JSON.parse(requestBody);
requestBodyContent = JSON.stringify(parsedJson, null, 2);
} catch {
requestBodyContent = requestBody;
}
} else if (bodyFormat === 'form') {
// 构建表单内容,但不需要在这里生成请求体内容
// 因为我们将在下面的请求参数部分直接使用表格展示
const formData = new URLSearchParams();
formFields.forEach(field => {
if (field.key.trim() && field.value.trim()) {
formData.append(field.key, field.value);
}
});
// 在表单模式下,请求体内容为空,因为我们会直接使用表格展示
requestBodyContent = '';
} else {
requestBodyContent = requestBody;
}
}
// 过滤有效的请求头
const validHeaders = headers.filter(h => h.key.trim() && h.value.trim());
// 构建Markdown文档 - 使用ShowDoc风格
let markdown = ``;
// 简要描述部分
markdown += `**简要描述:** \n\n`;
markdown += `- 自动生成的API接口文档\n\n`;
// 请求模式部分(新增)
markdown += `**请求模式:** \n\n`;
markdown += `- ${networkType === 'local' ? '本地/局域网' : '公网代理'} \n\n`;
// 请求URL部分
markdown += `**请求URL** \n\n`;
markdown += `- \`${url}\`\n\n`;
// 请求方式部分
markdown += `**请求方式:**\n\n`;
markdown += `- ${method} \n\n`;
// 请求头部分(如果有)
if (validHeaders.length > 0) {
markdown += `**请求头:** \n\n`;
markdown += `| 参数名 | 必选 | 参数值 | 说明 |\n`;
markdown += `| ------ | ---- | ------ | ---- |\n`;
validHeaders.forEach(header => {
const isContent = header.key.toLowerCase() === 'content-type';
markdown += `| ${header.key} | ${isContent ? '是' : '否'} | ${header.value} | ${isContent ? '请求数据类型' : '-'} |\n`;
});
markdown += `\n`;
}
// 请求参数部分(针对POST、PUT等方法)
if (['POST', 'PUT', 'PATCH'].includes(method) && (requestBodyContent || bodyFormat === 'form')) {
markdown += `**请求参数:** \n\n`;
// 针对表单格式单独处理
if (bodyFormat === 'form') {
// 直接生成表单参数表格
markdown += `| 参数名 | 必选 | 类型 | 说明 |\n`;
markdown += `| ------ | ---- | ---- | ---- |\n`;
const validFormFields = formFields.filter(field => field.key.trim() && field.value.trim());
if (validFormFields.length > 0) {
validFormFields.forEach(field => {
markdown += `| ${field.key} | 是 | string | - |\n`;
});
markdown += `\n`;
// 添加URL编码格式的说明
const formData = new URLSearchParams();
validFormFields.forEach(field => {
formData.append(field.key, field.value);
});
markdown += `**表单URL编码格式:** \n\n`;
markdown += `\`${formData.toString()}\`\n\n`;
} else {
markdown += `| - | - | - | 无参数 |\n\n`;
}
}
// JSON或文本格式的处理
else {
// 尝试解析参数并构建表格
try {
if (bodyFormat === 'json') {
const parsedBody = JSON.parse(requestBodyContent);
if (typeof parsedBody === 'object' && parsedBody !== null) {
markdown += `| 参数名 | 必选 | 类型 | 说明 |\n`;
markdown += `| ------ | ---- | ---- | ---- |\n`;
Object.entries(parsedBody).forEach(([key, value]) => {
const type = Array.isArray(value) ? 'array' : typeof value;
markdown += `| ${key} | 是 | ${type} | - |\n`;
});
markdown += `\n`;
} else {
markdown += `\`\`\`json\n${requestBodyContent}\n\`\`\`\n\n`;
}
} else if (requestBodyContent.trim()) {
markdown += `\`\`\`\n${requestBodyContent}\n\`\`\`\n\n`;
}
} catch {
if (requestBodyContent.trim()) {
markdown += `\`\`\`\n${requestBodyContent}\n\`\`\`\n\n`;
}
}
}
}
// 响应结果部分
if (response) {
// 返回示例
markdown += `**返回示例**\n\n`;
if (typeof response.data === 'object') {
// 直接使用JSON.stringify的缩进参数格式化JSON
markdown += `\`\`\`json\n${JSON.stringify(response.data, null, 2)}\n\`\`\`\n\n`;
} else if (typeof response.data === 'string') {
// 尝试检测是否为JSON字符串
try {
const parsedJson = JSON.parse(response.data);
markdown += `\`\`\`json\n${JSON.stringify(parsedJson, null, 2)}\n\`\`\`\n\n`;
} catch {
// 不是JSON字符串,直接显示
markdown += `\`\`\`\n${response.data}\n\`\`\`\n\n`;
}
} else {
markdown += `\`\`\`\n${response.data}\n\`\`\`\n\n`;
}
}
// 备注
markdown += `**备注** \n\n`;
markdown += `- 此文档由HTTP测试工具自动生成\n`;
markdown += `- 响应时间: ${response?.time || '-'}ms\n`;
markdown += `- 响应大小: ${response?.size || '-'} bytes\n`;
return markdown;
};
+345
View File
@@ -0,0 +1,345 @@
'use client';
import React, { useState, useRef, ChangeEvent } from 'react';
import Compressor from 'compressorjs';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUpload, faDownload, faCog, faTrash, faImage } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
card: "card p-6 mb-6",
inputLabel: "block text-secondary text-sm font-bold mb-2",
fileUploadBtn: "btn-primary flex items-center justify-center cursor-pointer",
fileName: "ml-3 text-secondary text-sm",
heading: "text-lg font-semibold mb-2 text-primary",
settingsContainer: "space-y-4",
rangeInput: "w-full accent-[rgb(var(--color-primary))]",
textInput: "search-input w-full",
infoPanel: "bg-block p-4 rounded-lg border border-purple-glow/15",
infoPanelRow: "flex justify-between mb-2",
infoLabel: "text-secondary",
infoValue: "font-medium text-primary",
successValue: "font-medium text-success",
actionBtn: "w-full",
actionBtnDisabled: "btn-secondary opacity-50 cursor-not-allowed w-full",
imageContainer: "bg-block rounded-lg p-4 min-h-64 flex items-center justify-center border border-purple-glow/15",
image: "max-w-full max-h-96 object-contain",
placeholder: "text-tertiary",
};
export default function ImageCompressor() {
const { t } = useLanguage();
const [originalImage, setOriginalImage] = useState<string | null>(null);
const [compressedImage, setCompressedImage] = useState<string | null>(null);
const [fileName, setFileName] = useState<string>('');
const [originalSize, setOriginalSize] = useState<number>(0);
const [compressedSize, setCompressedSize] = useState<number>(0);
const [isCompressing, setIsCompressing] = useState<boolean>(false);
const [quality, setQuality] = useState<number>(80);
const [maxWidth, setMaxWidth] = useState<number>(1920);
const [maxHeight, setMaxHeight] = useState<number>(1080);
const [maintainRatio, setMaintainRatio] = useState<boolean>(true);
const [compressFormat, setCompressFormat] = useState<string>('auto');
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setFileName(file.name);
setOriginalSize(file.size);
setCompressedImage(null);
setCompressedSize(0);
const reader = new FileReader();
reader.onload = (e) => {
setOriginalImage(e.target?.result as string);
};
reader.readAsDataURL(file);
};
const compressImage = () => {
if (!fileInputRef.current?.files?.[0]) return;
const file = fileInputRef.current.files[0];
setIsCompressing(true);
new Compressor(file, {
quality: quality / 100,
maxWidth,
maxHeight,
checkOrientation: true,
convertSize: 5000000, // 如果图片大于5MB,则转换为JPEG
mimeType: compressFormat === 'auto' ? undefined : `image/${compressFormat}`,
success(result) {
const reader = new FileReader();
reader.onload = (e) => {
setCompressedImage(e.target?.result as string);
setCompressedSize(result.size);
setIsCompressing(false);
};
reader.readAsDataURL(result);
},
error(err) {
console.error(t('tools.image_compressor.compression_failed'), err);
setIsCompressing(false);
},
});
};
const downloadCompressedImage = () => {
if (!compressedImage) return;
const link = document.createElement('a');
const ext = compressFormat === 'auto'
? fileName.split('.').pop()
: compressFormat;
const newFileName = fileName.replace(
/\.[^/.]+$/,
`.compressed.${ext === 'auto' ? 'jpg' : ext}`
);
link.href = compressedImage;
link.download = newFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const resetAll = () => {
setOriginalImage(null);
setCompressedImage(null);
setFileName('');
setOriginalSize(0);
setCompressedSize(0);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const formatSize = (bytes: number): string => {
if (bytes === 0) return '0 ' + t('tools.image_compressor.bytes');
const k = 1024;
const sizes = [
t('tools.image_compressor.bytes'),
t('tools.image_compressor.kb'),
t('tools.image_compressor.mb'),
t('tools.image_compressor.gb')
];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const calculateReduction = (): string => {
if (!originalSize || !compressedSize) return '0%';
const reduction = ((originalSize - compressedSize) / originalSize) * 100;
return `${reduction.toFixed(2)}%`;
};
return (
<div className={styles.container}>
{/* 使用 ToolHeader 组件 */}
<ToolHeader
icon={faImage}
toolCode="image_compressor"
title=""
description=""
/>
{/* 主要内容区 */}
<div className={styles.card}>
<div className="mb-6">
<label className={styles.inputLabel}>
{t('tools.image_compressor.select_image')}
</label>
<div className="flex items-center">
<label className={styles.fileUploadBtn}>
<FontAwesomeIcon icon={faUpload} className="mr-2 icon" />
{t('tools.image_compressor.choose_file')}
<input
type="file"
className="hidden"
accept="image/*"
onChange={handleFileChange}
ref={fileInputRef}
/>
</label>
{fileName && (
<span className={styles.fileName}>{fileName}</span>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div>
<h3 className={styles.heading}>{t('tools.image_compressor.compression_settings')}</h3>
<div className={styles.settingsContainer}>
<div>
<label className={styles.inputLabel}>
{t('tools.image_compressor.quality')} ({quality}%)
</label>
<input
type="range"
min="1"
max="100"
value={quality}
onChange={(e) => setQuality(parseInt(e.target.value))}
className={styles.rangeInput}
/>
</div>
<div>
<label className={styles.inputLabel}>
{t('tools.image_compressor.max_width')}
</label>
<input
type="number"
min="1"
value={maxWidth}
onChange={(e) => setMaxWidth(parseInt(e.target.value))}
className={styles.textInput}
/>
</div>
<div>
<label className={styles.inputLabel}>
{t('tools.image_compressor.max_height')}
</label>
<input
type="number"
min="1"
value={maxHeight}
onChange={(e) => setMaxHeight(parseInt(e.target.value))}
className={styles.textInput}
/>
</div>
<div>
<label className={styles.inputLabel}>
{t('tools.image_compressor.output_format')}
</label>
<select
value={compressFormat}
onChange={(e) => setCompressFormat(e.target.value)}
className={styles.textInput}
>
<option value="auto">{t('tools.image_compressor.auto_format')}</option>
<option value="jpeg">JPEG</option>
<option value="png">PNG</option>
<option value="webp">WebP</option>
</select>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="maintainRatio"
checked={maintainRatio}
onChange={(e) => setMaintainRatio(e.target.checked)}
className="mr-2 accent-[rgb(var(--color-primary))] w-4 h-4"
/>
<label htmlFor="maintainRatio" className={styles.inputLabel}>
{t('tools.image_compressor.maintain_ratio')}
</label>
</div>
</div>
</div>
<div>
{originalImage && (
<div className="mb-4">
<h3 className={styles.heading}>{t('tools.image_compressor.compression_info')}</h3>
<div className={styles.infoPanel}>
<div className={styles.infoPanelRow}>
<span className={styles.infoLabel}>{t('tools.image_compressor.original_size')}:</span>
<span className={styles.infoValue}>{formatSize(originalSize)}</span>
</div>
{compressedSize > 0 && (
<>
<div className={styles.infoPanelRow}>
<span className={styles.infoLabel}>{t('tools.image_compressor.compressed_size')}:</span>
<span className={styles.infoValue}>{formatSize(compressedSize)}</span>
</div>
<div className={styles.infoPanelRow}>
<span className={styles.infoLabel}>{t('tools.image_compressor.compression_ratio')}:</span>
<span className={styles.successValue}>{calculateReduction()}</span>
</div>
</>
)}
</div>
</div>
)}
<div className="space-y-3">
<button
onClick={compressImage}
disabled={!originalImage || isCompressing}
className={!originalImage || isCompressing ? styles.actionBtnDisabled : "btn-primary " + styles.actionBtn}
>
<FontAwesomeIcon icon={faCog} className="mr-2 icon" />
{isCompressing ? t('tools.image_compressor.compressing') : t('tools.image_compressor.compress_image')}
</button>
<button
onClick={downloadCompressedImage}
disabled={!compressedImage}
className={!compressedImage ? styles.actionBtnDisabled : "btn-primary " + styles.actionBtn}
style={{ background: compressedImage ? 'linear-gradient(to right, var(--color-success), var(--color-success-hover))' : '' }}
>
<FontAwesomeIcon icon={faDownload} className="mr-2 icon" />
{t('tools.image_compressor.download_compressed')}
</button>
<button
onClick={resetAll}
disabled={!originalImage}
className={!originalImage ? styles.actionBtnDisabled : "btn-secondary " + styles.actionBtn}
>
<FontAwesomeIcon icon={faTrash} className="mr-2 icon" />
{t('tools.image_compressor.reset')}
</button>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="card p-4">
<h3 className={styles.heading}>{t('tools.image_compressor.original_image')}</h3>
<div className={styles.imageContainer}>
{originalImage ? (
<img
src={originalImage}
alt={t('tools.image_compressor.original_image')}
className={styles.image}
/>
) : (
<div className={styles.placeholder}>{t('tools.image_compressor.no_image_selected')}</div>
)}
</div>
</div>
<div className="card p-4">
<h3 className={styles.heading}>{t('tools.image_compressor.compressed_image')}</h3>
<div className={styles.imageContainer}>
{compressedImage ? (
<img
src={compressedImage}
alt={t('tools.image_compressor.compressed_image')}
className={styles.image}
/>
) : (
<div className={styles.placeholder}>
{isCompressing ? t('tools.image_compressor.compressing') : t('tools.image_compressor.no_compressed_image')}
</div>
)}
</div>
</div>
</div>
</div>
);
}
+578
View File
@@ -0,0 +1,578 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faNetworkWired, faCopy, faCheck, faSearch } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import { apiClient } from '@/lib/api-client';
import { useLanguage } from '@/context/LanguageContext';
// IP信息接口
interface IPInfo {
ip: string;
country?: string;
region?: string;
city?: string;
isp?: string;
lat?: number;
lon?: number;
timezone?: string;
source?: string;
[key: string]: unknown; // 其他可能的字段
}
// API接口定义
interface ApiSource {
name: string;
url: string;
responseParser: (data: unknown) => IPInfo;
supportsQuery: boolean;
}
// API响应接口
interface ApiResponse {
data: unknown;
source: string;
requestType?: 'self' | 'query';
cached?: boolean;
}
// 添加CSS变量样式
const styles = {
card: "card p-6",
input: "search-input w-full",
textarea: "w-full p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple transition-all",
label: "text-secondary font-medium",
secondaryText: "text-sm text-tertiary",
resultItem: "flex justify-between items-center py-2 border-b border-purple-glow/10",
resultLabel: "text-sm text-secondary",
resultValue: "text-sm text-primary font-semibold",
iconButton: "text-tertiary hover:text-purple transition-colors",
primaryBtn: "btn-primary flex items-center justify-center gap-2",
resultBox: "p-3 bg-block rounded-md border border-purple-glow/30",
}
export default function IpLookup() {
const { t, language } = useLanguage();
// 状态管理
const [ipAddress, setIpAddress] = useState<string>('');
const [ipInfo, setIpInfo] = useState<IPInfo | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>('');
const [copied, setCopied] = useState(false);
// API 源列表
const apiSources: ApiSource[] = [
{
name: t('tools.ip_lookup.api_sources.pconline'),
url: '/api/ip?ip={ip}&source=太平洋电脑网',
responseParser: (data: unknown) => {
const response = data as ApiResponse;
try {
const typedData = response.data as { ip?: string; addr?: string; pro?: string; city?: string };
// 确保所有字段都存在并有默认值
const addr = typedData.addr || '';
const pro = typedData.pro || '';
const city = typedData.city || '';
return {
ip: typedData.ip || t('tools.ip_lookup.unknown'),
country: addr.split(' ')[0] || t('tools.ip_lookup.unknown'),
region: pro || t('tools.ip_lookup.unknown'),
city: city || t('tools.ip_lookup.unknown'),
isp: addr.includes('电信') ? '电信' :
addr.includes('联通') ? '联通' :
addr.includes('移动') ? '移动' :
addr.includes('铁通') ? '铁通' :
addr.includes('网通') ? '网通' : t('tools.ip_lookup.unknown'),
source: response.source
};
} catch (error) {
console.error(t('tools.ip_lookup.console_errors.pconline'), error);
return {
ip: t('tools.ip_lookup.unknown'),
country: t('tools.ip_lookup.unknown'),
region: t('tools.ip_lookup.unknown'),
city: t('tools.ip_lookup.unknown'),
isp: t('tools.ip_lookup.unknown'),
source: response.source
};
}
},
supportsQuery: true
},
{
name: t('tools.ip_lookup.api_sources.ipcn'),
url: '/api/ip?ip={ip}&source=IP.CN',
responseParser: (data: unknown) => {
const response = data as ApiResponse;
try {
const typedData = response.data as { ip?: string; address?: string };
if (!typedData?.ip) {
return {
ip: t('tools.ip_lookup.unknown'),
country: t('tools.ip_lookup.unknown'),
region: t('tools.ip_lookup.unknown'),
city: t('tools.ip_lookup.unknown'),
isp: t('tools.ip_lookup.unknown'),
source: response.source
};
}
return {
ip: typedData.ip,
country: typedData.address?.split(' ')?.[0] || t('tools.ip_lookup.unknown'),
region: typedData.address?.split(' ')?.[1] || t('tools.ip_lookup.unknown'),
city: typedData.address?.split(' ')?.[2] || t('tools.ip_lookup.unknown'),
isp: typedData.address?.split(' ')?.[3] || t('tools.ip_lookup.unknown'),
source: response.source
};
} catch (error) {
console.error(t('tools.ip_lookup.console_errors.ipcn'), error);
return {
ip: t('tools.ip_lookup.unknown'),
country: t('tools.ip_lookup.unknown'),
region: t('tools.ip_lookup.unknown'),
city: t('tools.ip_lookup.unknown'),
isp: t('tools.ip_lookup.unknown'),
source: response.source
};
}
},
supportsQuery: true
},
{
name: t('tools.ip_lookup.api_sources.ipapi'),
url: '/api/ip?ip={ip}&source=ip-api.com',
responseParser: (data: unknown) => {
const response = data as ApiResponse;
try {
const typedData = response.data as {
query?: string;
country?: string;
regionName?: string;
city?: string;
isp?: string;
lat?: number;
lon?: number;
timezone?: string;
};
if (!typedData?.query) {
return {
ip: t('tools.ip_lookup.unknown'),
country: t('tools.ip_lookup.unknown'),
region: t('tools.ip_lookup.unknown'),
city: t('tools.ip_lookup.unknown'),
isp: t('tools.ip_lookup.unknown'),
source: response.source
};
}
return {
ip: typedData.query,
country: typedData.country || t('tools.ip_lookup.unknown'),
region: typedData.regionName || t('tools.ip_lookup.unknown'),
city: typedData.city || t('tools.ip_lookup.unknown'),
isp: typedData.isp || t('tools.ip_lookup.unknown'),
lat: typedData.lat,
lon: typedData.lon,
timezone: typedData.timezone,
source: response.source
};
} catch (error) {
console.error(t('tools.ip_lookup.console_errors.ipapi'), error);
return {
ip: t('tools.ip_lookup.unknown'),
country: t('tools.ip_lookup.unknown'),
region: t('tools.ip_lookup.unknown'),
city: t('tools.ip_lookup.unknown'),
isp: t('tools.ip_lookup.unknown'),
source: response.source
};
}
},
supportsQuery: true
},
{
name: t('tools.ip_lookup.api_sources.baidu'),
url: '/api/ip?ip={ip}&source=百度IP',
responseParser: (data: unknown) => {
const response = data as ApiResponse;
const typedData = response.data as {
data: Array<{
location: string;
origip: string;
}>
};
// 安全处理
if (!typedData.data || !typedData.data[0]) {
return {
ip: t('tools.ip_lookup.unknown'),
source: response.source
};
}
const locationInfo = typedData.data[0].location?.split(' ') || [];
return {
ip: typedData.data[0].origip || t('tools.ip_lookup.unknown'),
country: locationInfo[0] || t('tools.ip_lookup.unknown'),
region: locationInfo[1] || t('tools.ip_lookup.unknown'),
city: locationInfo[2] || t('tools.ip_lookup.unknown'),
isp: locationInfo[3] || t('tools.ip_lookup.unknown'),
source: response.source
};
},
supportsQuery: true
},
{
name: t('tools.ip_lookup.api_sources.meitu'),
url: '/api/ip?ip={ip}&source=美图IP',
responseParser: (data: unknown) => {
const response = data as ApiResponse;
try {
const typedData = response.data as {
data: {
[key: string]: {
nation: string;
province: string;
city: string;
isp: string;
latitude: number;
longitude: number;
}
}
};
// 找到IP键
const ipKey = Object.keys(typedData.data)[0];
const ipData = typedData.data[ipKey];
return {
ip: ipKey || t('tools.ip_lookup.unknown'),
country: ipData.nation || t('tools.ip_lookup.unknown'),
region: ipData.province || t('tools.ip_lookup.unknown'),
city: ipData.city || t('tools.ip_lookup.unknown'),
isp: ipData.isp || t('tools.ip_lookup.unknown'),
lat: ipData.latitude,
lon: ipData.longitude,
source: response.source
};
} catch (error) {
console.error(t('tools.ip_lookup.console_errors.meitu'), error);
return {
ip: t('tools.ip_lookup.unknown'),
country: t('tools.ip_lookup.unknown'),
region: t('tools.ip_lookup.unknown'),
city: t('tools.ip_lookup.unknown'),
isp: t('tools.ip_lookup.unknown'),
source: response.source
};
}
},
supportsQuery: true
}
];
// API源重构,确保语言变化时更新
useEffect(() => {
// 此处空实现,确保语言切换时组件重新渲染
}, [language, t]);
// 处理我的IP点击
const handleMyIpClick = () => {
setIpAddress('');
searchIP('');
};
// 获取IP信息
const fetchIPInfo = async (ip: string) => {
setLoading(true);
setError('');
try {
const results = await Promise.all(
apiSources.map(async (source) => {
try {
const url = source.url.replace('{ip}', ip);
const response = await apiClient.get(url);
return source.responseParser(response);
} catch (error) {
console.error(`${source.name}${t('tools.ip_lookup.errors.query_failed')}:`, error);
return null;
}
})
);
// 过滤掉失败的结果
const validResults = results.filter((result): result is IPInfo => result !== null);
if (validResults.length === 0) {
throw new Error(t('tools.ip_lookup.errors.query_failed'));
}
// 使用第一个有效结果
setIpInfo(validResults[0]);
} catch (error) {
setError(error instanceof Error ? error.message : t('tools.ip_lookup.errors.query_failed'));
} finally {
setLoading(false);
}
};
// 搜索IP
const searchIP = async (ip: string = ipAddress) => {
if (!ip) {
await fetchIPInfo('');
return;
}
// 验证IP地址格式
const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (!ipRegex.test(ip)) {
setError(t('tools.ip_lookup.errors.invalid_ip'));
return;
}
await fetchIPInfo(ip);
};
// 复制到剪贴板
const copyToClipboard = () => {
if (!ipInfo) return;
const text = Object.entries(ipInfo)
.filter(([key]) => !['source'].includes(key))
.map(([key, value]) => `${t(`tools.ip_lookup.ip_info.${key}`)}: ${value}`)
.join('\n');
navigator.clipboard.writeText(text)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(() => {
console.error(t('tools.ip_lookup.errors.copy_failed'));
});
};
// 获取IP类型
const getIPType = (ip: string) => {
if (!ip || ip === t('tools.ip_lookup.unknown')) return t('tools.ip_lookup.unknown');
const firstOctet = parseInt(ip.split('.')[0]);
if (firstOctet >= 1 && firstOctet <= 126) return 'A';
if (firstOctet >= 128 && firstOctet <= 191) return 'B';
if (firstOctet >= 192 && firstOctet <= 223) return 'C';
if (firstOctet >= 224 && firstOctet <= 239) return 'D';
if (firstOctet >= 240 && firstOctet <= 255) return 'E';
return t('tools.ip_lookup.unknown');
};
// 获取IP分类
const getIPClass = (ip: string) => {
if (!ip || ip === t('tools.ip_lookup.unknown')) return t('tools.ip_lookup.unknown');
const firstOctet = parseInt(ip.split('.')[0]);
if (firstOctet === 10) return t('tools.ip_lookup.ip_classes.private');
if (firstOctet === 172 && parseInt(ip.split('.')[1]) >= 16 && parseInt(ip.split('.')[1]) <= 31) return t('tools.ip_lookup.ip_classes.private');
if (firstOctet === 192 && parseInt(ip.split('.')[1]) === 168) return t('tools.ip_lookup.ip_classes.private');
if (firstOctet === 127) return t('tools.ip_lookup.ip_classes.loopback');
if (firstOctet === 169 && parseInt(ip.split('.')[1]) === 254) return t('tools.ip_lookup.ip_classes.link_local');
return t('tools.ip_lookup.ip_classes.public');
};
// IP转二进制
const ipToBinary = (ip: string) => {
if (!ip || ip === t('tools.ip_lookup.unknown')) return t('tools.ip_lookup.unknown');
return ip.split('.').map(num => parseInt(num).toString(2).padStart(8, '0')).join('.');
};
// IP转十六进制
const ipToHex = (ip: string) => {
if (!ip || ip === t('tools.ip_lookup.unknown')) return t('tools.ip_lookup.unknown');
return ip.split('.').map(num => parseInt(num).toString(16).padStart(2, '0')).join('.');
};
// 处理搜索点击
const handleSearchClick = () => {
searchIP();
};
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
<ToolHeader
icon={faNetworkWired}
toolCode="ip_lookup"
title=""
description=""
/>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="lg:col-span-5">
<div className={styles.card}>
<h2 className="text-lg font-medium text-primary mb-4">{t('tools.ip_lookup.title')}</h2>
<div className="mb-6">
<label className={styles.label}>{t('tools.ip_lookup.input_label')}</label>
<div className="mt-2 flex gap-2">
<div className="relative flex-1">
<input
type="text"
value={ipAddress}
onChange={(e) => setIpAddress(e.target.value.trim())}
onKeyDown={(e) => {
if (e.key === 'Enter') {
searchIP();
}
}}
placeholder={t('tools.ip_lookup.input_placeholder_example')}
className={styles.input}
/>
</div>
<button
className={styles.primaryBtn}
onClick={handleSearchClick}
disabled={loading}
>
{loading ? (
<span className="animate-spin">
<FontAwesomeIcon icon={faSearch} />
</span>
) : (
<>
<FontAwesomeIcon icon={faSearch} />
{t('tools.ip_lookup.search_button')}
</>
)}
</button>
</div>
<button
className="mt-3 btn-secondary text-sm"
onClick={handleMyIpClick}
disabled={loading}
>
{t('tools.ip_lookup.my_ip_button')}
</button>
{error && (
<div className="mt-3 p-2 bg-red-900/20 border border-red-700/30 text-red-500 rounded-lg">
{error}
</div>
)}
</div>
<div>
<h3 className="text-primary font-medium mb-2">{t('tools.ip_lookup.instruction_title')}</h3>
<ul className="list-disc pl-5 space-y-1 text-sm text-tertiary">
<li>{t('tools.ip_lookup.instructions.line1')}</li>
<li>{t('tools.ip_lookup.instructions.line2')}</li>
<li>{t('tools.ip_lookup.instructions.line3')}</li>
<li>{t('tools.ip_lookup.instructions.line4')}</li>
</ul>
</div>
</div>
</div>
<div className="lg:col-span-7">
<div className={styles.card}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium text-primary">{t('tools.ip_lookup.results.title')}</h2>
</div>
{ipInfo ? (
<div className="space-y-4">
<div className={styles.resultBox}>
<div className="flex justify-between items-center mb-3 pb-2 border-b border-purple-glow/20">
<h3 className="text-primary font-medium">{t('tools.ip_lookup.ip_info.ip')}: {ipInfo.ip}</h3>
<button
onClick={copyToClipboard}
className={styles.iconButton}
title={t('tools.ip_lookup.copy')}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} className="ml-1" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.country')}:</span>
<span className={styles.resultValue}>{ipInfo.country || t('tools.ip_lookup.unknown')}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.region')}:</span>
<span className={styles.resultValue}>{ipInfo.region || t('tools.ip_lookup.unknown')}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.city')}:</span>
<span className={styles.resultValue}>{ipInfo.city || t('tools.ip_lookup.unknown')}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.isp')}:</span>
<span className={styles.resultValue}>{ipInfo.isp || t('tools.ip_lookup.unknown')}</span>
</div>
{ipInfo.lat && ipInfo.lon && (
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.coordinates')}:</span>
<span className={styles.resultValue}>{ipInfo.lat}, {ipInfo.lon}</span>
</div>
)}
{ipInfo.timezone && (
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.timezone')}:</span>
<span className={styles.resultValue}>{ipInfo.timezone}</span>
</div>
)}
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.source')}:</span>
<span className={styles.resultValue}>{ipInfo.source || t('tools.ip_lookup.unknown')}</span>
</div>
</div>
</div>
<div className="bg-block p-4 rounded-lg border border-purple-glow/20">
<h3 className="text-primary font-medium mb-3">{t('tools.ip_lookup.technical_details')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.ip_type')}:</span>
<span className={styles.resultValue}>{getIPType(ipInfo.ip)}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.ip_class')}:</span>
<span className={styles.resultValue}>{getIPClass(ipInfo.ip)}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.binary')}:</span>
<span className={`${styles.resultValue} font-mono text-xs`}>{ipToBinary(ipInfo.ip)}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.hex')}:</span>
<span className={`${styles.resultValue} font-mono`}>{ipToHex(ipInfo.ip)}</span>
</div>
</div>
</div>
</div>
) : (
<div className="p-12 flex flex-col items-center justify-center text-tertiary">
<FontAwesomeIcon icon={faNetworkWired} className="text-4xl mb-4 opacity-20" />
<p>{t('tools.ip_lookup.results.empty_state')}</p>
</div>
)}
</div>
</div>
</div>
</div>
);
}
+567
View File
@@ -0,0 +1,567 @@
// JSON转换工具函数
/**
* 将JSON转换为XML格式
*/
export function jsonToXml(jsonString: string, options?: { rootName?: string }): string {
try {
const jsonObj = JSON.parse(jsonString);
const rootName = options?.rootName || 'root';
// XML头部
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
// 递归转换JSON对象为XML
const jsonObjToXml = (obj: unknown, nodeName: string, indent = ''): string => {
if (obj === null || obj === undefined) {
return `${indent}<${nodeName}></${nodeName}>\n`;
}
if (typeof obj !== 'object') {
// 处理基本类型
return `${indent}<${nodeName}>${escapeXml(String(obj))}</${nodeName}>\n`;
}
if (Array.isArray(obj)) {
// 处理数组:每个元素都使用相同的节点名
return obj.map(item => jsonObjToXml(item, nodeName, indent)).join('');
}
// 处理对象
let result = `${indent}<${nodeName}>\n`;
// 遍历对象属性
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = (obj as Record<string, unknown>)[key];
result += jsonObjToXml(value, key, indent + ' ');
}
}
result += `${indent}</${nodeName}>\n`;
return result;
};
// 转换JSON对象并添加到XML字符串
xml += jsonObjToXml(jsonObj, rootName);
return xml;
} catch (error) {
if (error instanceof Error) {
throw new Error(`JSON转XML失败: ${error.message}`);
}
throw new Error('JSON转XML失败');
}
}
/**
* 将XML转换为JSON格式
*/
export function xmlToJson(xmlString: string): string {
try {
// 创建DOM解析器
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlString, 'text/xml');
// 检查解析错误
const parseError = xmlDoc.getElementsByTagName('parsererror');
if (parseError.length > 0) {
throw new Error('XML解析错误:无效的XML格式');
}
// 递归处理XML节点
const processNode = (node: Element): unknown => {
// 如果节点没有子元素,返回节点的文本内容
if (node.childNodes.length === 0 ||
(node.childNodes.length === 1 && node.childNodes[0].nodeType === 3)) {
const text = node.textContent || '';
// 尝试转换为数字或布尔值
if (text === 'true') return true;
if (text === 'false') return false;
if (!isNaN(Number(text)) && text.trim() !== '') return Number(text);
return text;
}
// 处理有子元素的节点
const result: Record<string, unknown> = {};
const childElements = Array.from(node.children);
// 对每个子元素类型进行分组,检测数组
const elementCounts: Record<string, Element[]> = {};
childElements.forEach(child => {
if (!elementCounts[child.nodeName]) {
elementCounts[child.nodeName] = [];
}
elementCounts[child.nodeName].push(child);
});
// 处理每种子元素
for (const [nodeName, elements] of Object.entries(elementCounts)) {
if (elements.length === 1) {
// 单个元素
result[nodeName] = processNode(elements[0]);
} else {
// 多个同名元素,作为数组处理
result[nodeName] = elements.map(processNode);
}
}
return result;
};
// 处理XML文档的根元素
const root = xmlDoc.documentElement;
const jsonObj = processNode(root);
// 将对象转换为格式化的JSON字符串
return JSON.stringify(jsonObj, null, 2);
} catch (error) {
if (error instanceof Error) {
throw new Error(`XML转JSON失败: ${error.message}`);
}
throw new Error('XML转JSON失败');
}
}
/**
* 将JSON转换为CSV格式
*/
export function jsonToCsv(jsonString: string, options?: { delimiter?: string; header?: boolean }): string {
try {
const jsonObj = JSON.parse(jsonString);
const delimiter = options?.delimiter || ',';
const includeHeader = options?.header !== false;
// 处理数组数据
if (Array.isArray(jsonObj)) {
if (jsonObj.length === 0) {
return '';
}
// 提取所有可能的字段
const fields = new Set<string>();
jsonObj.forEach(item => {
if (typeof item === 'object' && item !== null) {
Object.keys(item).forEach(key => fields.add(key));
}
});
const fieldNames = Array.from(fields);
// 生成CSV头
let csv = includeHeader ? fieldNames.map(escapeCSV).join(delimiter) + '\n' : '';
// 生成CSV数据行
jsonObj.forEach(item => {
const row = fieldNames.map(field => {
const value = (item as Record<string, unknown>)[field];
// 处理嵌套对象或数组
if (value !== null && typeof value === 'object') {
return escapeCSV(JSON.stringify(value));
}
return value !== undefined ? escapeCSV(String(value)) : '';
});
csv += row.join(delimiter) + '\n';
});
return csv;
} else if (typeof jsonObj === 'object' && jsonObj !== null) {
// 如果是单个对象,将其转换为单行CSV
const fields = Object.keys(jsonObj);
let csv = includeHeader ? fields.map(escapeCSV).join(delimiter) + '\n' : '';
// 生成数据行
const row = fields.map(field => {
const value = (jsonObj as Record<string, unknown>)[field];
// 处理嵌套对象或数组
if (value !== null && typeof value === 'object') {
return escapeCSV(JSON.stringify(value));
}
return value !== undefined ? escapeCSV(String(value)) : '';
});
csv += row.join(delimiter) + '\n';
return csv;
} else {
throw new Error('无法转换为CSVJSON必须是对象或数组');
}
} catch (error) {
if (error instanceof Error) {
throw new Error(`JSON转CSV失败: ${error.message}`);
}
throw new Error('JSON转CSV失败');
}
}
/**
* 将CSV转换为JSON格式
*/
export function csvToJson(csvString: string, options?: { delimiter?: string; header?: boolean }): string {
try {
const delimiter = options?.delimiter || ',';
const hasHeader = options?.header !== false;
// 分割CSV行
const lines = csvString.trim().split(/\r?\n/);
if (lines.length === 0) {
return '[]';
}
// 解析CSV行,处理引号内的分隔符
const parseCSVLine = (line: string): string[] => {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
// 引号处理:检查是否为转义的引号
if (i + 1 < line.length && line[i + 1] === '"') {
current += '"';
i++; // 跳过下一个引号
} else {
inQuotes = !inQuotes;
}
} else if (char === delimiter && !inQuotes) {
// 找到分隔符且不在引号内
result.push(current);
current = '';
} else {
current += char;
}
}
// 添加最后一个字段
result.push(current);
return result;
};
let headers: string[];
let startIndex: number;
if (hasHeader) {
// 使用第一行作为字段名
headers = parseCSVLine(lines[0]);
startIndex = 1;
} else {
// 自动生成字段名
const firstLine = parseCSVLine(lines[0]);
headers = firstLine.map((_, index) => `field${index}`);
startIndex = 0;
}
// 处理CSV数据行
const result: Record<string, unknown>[] = [];
for (let i = startIndex; i < lines.length; i++) {
if (!lines[i].trim()) continue; // 跳过空行
const values = parseCSVLine(lines[i]);
const obj: Record<string, unknown> = {};
// 将每个值与相应的字段名匹配
for (let j = 0; j < headers.length; j++) {
if (j < values.length) {
const value = values[j].trim();
// 尝试转换为合适的数据类型
if (value === 'true') obj[headers[j]] = true;
else if (value === 'false') obj[headers[j]] = false;
else if (!isNaN(Number(value)) && value !== '') obj[headers[j]] = Number(value);
else obj[headers[j]] = value;
} else {
obj[headers[j]] = '';
}
}
result.push(obj);
}
return JSON.stringify(result, null, 2);
} catch (error) {
if (error instanceof Error) {
throw new Error(`CSV转JSON失败: ${error.message}`);
}
throw new Error('CSV转JSON失败');
}
}
/**
* 将JSON转换为YAML格式
*/
export function jsonToYaml(jsonString: string): string {
try {
const jsonObj = JSON.parse(jsonString);
// 递归转换JSON对象为YAML
const convertToYaml = (obj: unknown, indent = 0): string => {
if (obj === null || obj === undefined) {
return 'null';
}
const spaces = ' '.repeat(indent);
if (typeof obj !== 'object') {
// 处理基本类型
if (typeof obj === 'string') {
// 检查是否需要引号
if (
/^[-:?!,[\]{}#&*!|>'"%@\`]|^[0-9]/.test(obj) ||
/^(true|false|null|y|n|yes|no|on|off)$/i.test(obj) ||
obj.includes('\n') ||
obj.includes(' ')
) {
// 对特殊字符进行转义
return `'${obj.replace(/'/g, "''")}'`;
}
return obj;
}
return String(obj);
}
if (Array.isArray(obj)) {
// 如果是空数组
if (obj.length === 0) {
return '[]';
}
// 处理数组:每个元素前加-号
return obj.map(item => {
if (typeof item === 'object' && item !== null) {
return `${spaces}- ${convertToYaml(item, indent + 2).trimStart()}`;
} else {
return `${spaces}- ${convertToYaml(item, indent)}`;
}
}).join('\n');
}
// 如果是空对象
if (Object.keys(obj as Record<string, unknown>).length === 0) {
return '{}';
}
// 处理对象
return Object.entries(obj as Record<string, unknown>).map(([key, value]) => {
const yamlKey = /^[a-zA-Z0-9_]+$/.test(key) ? key : `'${key}'`;
if (typeof value === 'object' && value !== null) {
return `${spaces}${yamlKey}:\n${convertToYaml(value, indent + 2)}`;
} else {
return `${spaces}${yamlKey}: ${convertToYaml(value, indent)}`;
}
}).join('\n');
};
return convertToYaml(jsonObj);
} catch (error) {
if (error instanceof Error) {
throw new Error(`JSON转YAML失败: ${error.message}`);
}
throw new Error('JSON转YAML失败');
}
}
/**
* 将YAML转换为JSON格式
*/
export function yamlToJson(yamlString: string): string {
try {
// YAML解析是复杂的,通常使用库,但这里我们简化实现
// 主要解析简单的YAML格式:缩进表示层级,冒号分隔键值对
const lines = yamlString.split(/\r?\n/);
// 处理缩进
const getIndent = (line: string): number => {
let i = 0;
while (i < line.length && line[i] === ' ') i++;
return i;
};
// 递归解析YAML
const parseYaml = (currentIndex: number, minIndent: number): [unknown, number] => {
let result: unknown = null;
let i = currentIndex;
// 确定当前行的缩进级别
const currentIndent = getIndent(lines[i]);
let isArray = lines[i].trimStart().startsWith('-');
if (isArray) {
// 解析数组
result = [];
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) {
i++;
continue; // 跳过空行
}
const lineIndent = getIndent(line);
if (lineIndent < minIndent) break; // 缩进减少,退出当前层级
if (line.trimStart().startsWith('-')) {
// 数组项
const itemText = line.trim().substring(1).trimStart();
if (itemText.includes(':')) {
// 数组项是对象
const [key, valuePart] = splitKeyValue(itemText);
const arrayItem: Record<string, unknown> = {};
if (valuePart.trim()) {
// 行内值
arrayItem[key] = parseScalar(valuePart.trim());
} else {
// 子对象,下一行应该有更多缩进
if (i + 1 < lines.length && getIndent(lines[i + 1]) > lineIndent) {
const [nestedObj, nextIndex] = parseYaml(i + 1, lineIndent + 2);
arrayItem[key] = nestedObj;
i = nextIndex - 1; // 回退一行,下次循环会递增
} else {
arrayItem[key] = null; // 键值为空
}
}
(result as unknown[]).push(arrayItem);
} else if (itemText) {
// 简单值
(result as unknown[]).push(parseScalar(itemText));
} else {
// 下一行有更多缩进
if (i + 1 < lines.length && getIndent(lines[i + 1]) > lineIndent) {
const [nestedObj, nextIndex] = parseYaml(i + 1, lineIndent + 2);
(result as unknown[]).push(nestedObj);
i = nextIndex - 1;
} else {
(result as unknown[]).push(null);
}
}
} else if (lineIndent === currentIndent) {
// 同一缩进级别但不是数组项,说明数组结束
break;
}
i++;
}
} else {
// 解析对象
result = {};
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) {
i++;
continue; // 跳过空行
}
const lineIndent = getIndent(line);
if (lineIndent < minIndent) break; // 缩进减少,退出当前层级
if (lineIndent === minIndent) {
if (line.trimStart().startsWith('-')) {
// 数组开始,与对象同级
isArray = true;
break;
}
if (line.includes(':')) {
// 键值对
const [key, valuePart] = splitKeyValue(line.trim());
if (valuePart.trim()) {
// 行内值
(result as Record<string, unknown>)[key] = parseScalar(valuePart.trim());
} else {
// 子对象或数组,下一行应该有更多缩进
if (i + 1 < lines.length && getIndent(lines[i + 1]) > lineIndent) {
const [nestedObj, nextIndex] = parseYaml(i + 1, lineIndent + 2);
(result as Record<string, unknown>)[key] = nestedObj;
i = nextIndex - 1; // 回退一行,下次循环会递增
} else {
(result as Record<string, unknown>)[key] = null; // 键值为空
}
}
}
} else if (lineIndent > minIndent) {
// 缩进增加,属于上一个键的子内容
continue;
}
i++;
}
}
return [result, i];
};
// 辅助函数:分割键值对
const splitKeyValue = (line: string): [string, string] => {
const colonIndex = line.indexOf(':');
if (colonIndex === -1) return [line, ''];
let key = line.substring(0, colonIndex).trim();
// 如果键名有引号,去掉引号
if ((key.startsWith("'") && key.endsWith("'")) ||
(key.startsWith('"') && key.endsWith('"'))) {
key = key.substring(1, key.length - 1);
}
const value = line.substring(colonIndex + 1);
return [key, value];
};
// 辅助函数:解析标量值
const parseScalar = (value: string): unknown => {
// 去掉引号
if ((value.startsWith("'") && value.endsWith("'")) ||
(value.startsWith('"') && value.endsWith('"'))) {
return value.substring(1, value.length - 1);
}
// 解析为特定类型
if (value === 'null' || value === '~' || value === '') return null;
if (value === 'true' || value === 'yes' || value === 'on') return true;
if (value === 'false' || value === 'no' || value === 'off') return false;
if (!isNaN(Number(value))) return Number(value);
return value;
};
// 开始解析
const [result] = parseYaml(0, 0);
return JSON.stringify(result, null, 2);
} catch (error) {
if (error instanceof Error) {
throw new Error(`YAML转JSON失败: ${error.message}`);
}
throw new Error('YAML转JSON失败');
}
}
// 辅助函数:转义XML特殊字符
function escapeXml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// 辅助函数:转义CSV字段
function escapeCSV(value: string): string {
// 如果字段包含逗号、双引号或换行符,需要用双引号包围并处理
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
// 双引号内的双引号需要再次转义
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
+552
View File
@@ -0,0 +1,552 @@
'use client';
import React, { useState, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faExchangeAlt, faCopy, faCheck, faSyncAlt,
faEraser, faDownload, faCog, faInfoCircle,
faCode
} from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import tools from '@/config/tools';
import { useLanguage } from '@/context/LanguageContext';
// 格式转换函数
import {
jsonToXml, xmlToJson,
jsonToCsv, csvToJson,
jsonToYaml, yamlToJson
} from './converters';
// 添加CSS变量样式
const styles = {
card: "card p-6",
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
input: "search-input w-full",
textarea: "w-full p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple transition-all",
label: "text-sm text-secondary font-medium",
secondaryText: "text-sm text-tertiary",
statusLabel: "px-2 py-1 rounded-md text-xs",
selectBox: "w-full px-3 py-2 bg-block border border-purple-glow rounded text-primary focus:outline-none focus:border-purple",
errorBox: "p-3 bg-red-900/20 border border-red-700/30 rounded-lg text-error",
iconButton: "p-1 text-secondary hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed",
directionIndicator: (active: boolean) => active ? "text-primary" : "text-tertiary",
formatTypeBtn: "flex items-center gap-2 p-3 rounded-lg border border-purple-glow/20 transition-all hover:border-purple-glow",
formatTypeBtnActive: "bg-purple-glow/10 border-purple",
actionBtn: "btn-secondary flex items-center gap-2",
actionBtnPrimary: "btn-primary flex items-center gap-2",
swapBtn: "bg-block p-2 rounded-full hover:bg-block-hover transition-colors text-purple",
advancedOptionsBtn: "text-sm flex items-center gap-1 text-secondary hover:text-primary transition-colors",
advancedOptionsContainer: "mt-4 p-4 bg-block-strong rounded-lg",
progressContainer: "flex items-center justify-center min-h-[200px]",
flexRow: "flex flex-col sm:flex-row gap-4 justify-between items-center",
formatGroup: "grid grid-cols-1 md:grid-cols-3 gap-3",
}
export default function JsonConverter() {
const { t } = useLanguage();
// 从工具配置中获取当前工具信息
const toolConfig = tools.find(tool => tool.code === 'json_converter');
// 格式类型选项
const formatTypes = [
{ id: 'xml', name: t('tools.json_converter.format_types.xml.name'), description: t('tools.json_converter.format_types.xml.description') },
{ id: 'csv', name: t('tools.json_converter.format_types.csv.name'), description: t('tools.json_converter.format_types.csv.description') },
{ id: 'yaml', name: t('tools.json_converter.format_types.yaml.name'), description: t('tools.json_converter.format_types.yaml.description') },
];
// 状态管理
const [inputText, setInputText] = useState('');
const [outputText, setOutputText] = useState('');
const [formatType, setFormatType] = useState('xml');
const [direction, setDirection] = useState('json_to_format'); // 'json_to_format' 或 'format_to_json'
const [error, setError] = useState('');
const [copied, setCopied] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [advancedOptions, setAdvancedOptions] = useState(false);
const [csvDelimiter, setCsvDelimiter] = useState(',');
const [csvHeader, setCsvHeader] = useState(true);
const [xmlRootName, setXmlRootName] = useState('root');
// 引用
const inputRef = useRef<HTMLTextAreaElement>(null);
const outputRef = useRef<HTMLTextAreaElement>(null);
// 执行转换
const performConversion = () => {
if (!inputText.trim()) {
setOutputText('');
setError('');
return;
}
setIsProcessing(true);
setError('');
try {
let result = '';
const options = {
delimiter: csvDelimiter,
header: csvHeader,
rootName: xmlRootName
};
if (direction === 'json_to_format') {
// 首先验证输入是有效的JSON
try {
JSON.parse(inputText);
} catch {
throw new Error(t('tools.json_converter.errors.invalid_json'));
}
// JSON 转换为其他格式
if (formatType === 'xml') {
result = jsonToXml(inputText, options);
} else if (formatType === 'csv') {
result = jsonToCsv(inputText, options);
} else if (formatType === 'yaml') {
result = jsonToYaml(inputText);
}
} else {
// 其他格式转换为JSON
if (formatType === 'xml') {
result = xmlToJson(inputText);
} else if (formatType === 'csv') {
result = csvToJson(inputText, options);
} else if (formatType === 'yaml') {
result = yamlToJson(inputText);
}
}
setOutputText(result);
} catch (err) {
if (err instanceof Error) {
setError(err.message);
} else {
setError(t('tools.json_converter.errors.conversion_error'));
}
setOutputText('');
} finally {
setIsProcessing(false);
}
};
// 复制输出内容到剪贴板
const copyToClipboard = () => {
if (!outputText) return;
navigator.clipboard.writeText(outputText)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => {
console.error(t('tools.json_converter.errors.copy_failed'), err);
setError(t('tools.json_converter.errors.clipboard_error'));
});
};
// 下载输出结果
const downloadOutput = () => {
if (!outputText) return;
let extension = 'txt';
let mimeType = 'text/plain';
if (direction === 'json_to_format') {
if (formatType === 'xml') {
extension = 'xml';
mimeType = 'application/xml';
} else if (formatType === 'csv') {
extension = 'csv';
mimeType = 'text/csv';
} else if (formatType === 'yaml') {
extension = 'yaml';
mimeType = 'application/x-yaml';
}
} else {
extension = 'json';
mimeType = 'application/json';
}
const blob = new Blob([outputText], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `converted.${extension}`;
document.body.appendChild(link);
link.click();
URL.revokeObjectURL(url);
document.body.removeChild(link);
};
// 清空输入和输出
const clearAll = () => {
setInputText('');
setOutputText('');
setError('');
if (inputRef.current) {
inputRef.current.focus();
}
};
// 交换方向
const swapDirection = () => {
setDirection(direction === 'json_to_format' ? 'format_to_json' : 'json_to_format');
// 交换输入和输出的内容
setInputText(outputText);
setOutputText('');
setError('');
};
// 加载示例
const loadExample = () => {
const jsonExample = `{
"person": {
"name": "张三",
"age": 28,
"isStudent": false,
"address": {
"city": "北京",
"district": "海淀区",
"postal": "100000"
},
"hobbies": ["读书", "旅游", "编程"]
}
}`;
const xmlExample = `<?xml version="1.0" encoding="UTF-8"?>
<root>
<person>
<n>张三</n>
<age>28</age>
<isStudent>false</isStudent>
<address>
<city>北京</city>
<district>海淀区</district>
<postal>100000</postal>
</address>
<hobbies>读书</hobbies>
<hobbies>旅游</hobbies>
<hobbies>编程</hobbies>
</person>
</root>`;
const csvExample = `name,age,isStudent,city,district,postal,hobbies
张三,28,false,北京,海淀区,100000,"读书,旅游,编程"`;
const yamlExample = `person:
name: 张三
age: 28
isStudent: false
address:
city: 北京
district: 海淀区
postal: '100000'
hobbies:
- 读书
- 旅游
- 编程`;
// 根据当前格式和方向加载示例
if (direction === 'json_to_format') {
setInputText(jsonExample);
} else {
if (formatType === 'xml') {
setInputText(xmlExample);
} else if (formatType === 'csv') {
setInputText(csvExample);
} else if (formatType === 'yaml') {
setInputText(yamlExample);
}
}
setOutputText('');
setError('');
};
return (
<div className={styles.container}>
{/* 工具头部 */}
<ToolHeader
icon={toolConfig?.icon || faCode}
toolCode="json_converter"
title=""
description=""
/>
{/* 主内容区域 */}
<div className={styles.card}>
{/* 格式类型选择 */}
<div className="space-y-6">
{/* 格式选择区域 */}
<div>
<h2 className="text-primary font-medium mb-4">{t('tools.json_converter.select_format_type')}</h2>
<div className={styles.formatGroup}>
{formatTypes.map((type) => (
<button
key={type.id}
className={`${styles.formatTypeBtn} ${formatType === type.id ? styles.formatTypeBtnActive : ''}`}
onClick={() => setFormatType(type.id)}
>
<span className="font-medium">{type.name}</span>
<span className="text-sm text-tertiary">{type.description}</span>
</button>
))}
</div>
</div>
{/* 转换方向选择 */}
<div className={styles.flexRow}>
<div className="flex items-center space-x-4">
<span className={styles.directionIndicator(direction === 'json_to_format')}>
{t('tools.json_converter.direction.json_to_format')}
</span>
<button
onClick={swapDirection}
className={styles.swapBtn}
>
<FontAwesomeIcon
icon={faExchangeAlt}
className={`transition-transform ${direction === 'format_to_json' ? 'rotate-180' : ''}`}
/>
</button>
<span className={styles.directionIndicator(direction === 'format_to_json')}>
{t('tools.json_converter.direction.format_to_json').replace('{format}', formatType.toUpperCase())}
</span>
</div>
<div className="flex gap-2">
<button
onClick={loadExample}
className={styles.actionBtn}
>
<FontAwesomeIcon icon={faSyncAlt} />
{t('tools.json_converter.actions.load_example')}
</button>
<button
onClick={clearAll}
disabled={!inputText}
className={styles.actionBtn}
>
<FontAwesomeIcon icon={faEraser} />
{t('tools.json_converter.actions.clear')}
</button>
</div>
</div>
{/* 高级选项切换 */}
<div>
<button
onClick={() => setAdvancedOptions(!advancedOptions)}
className={styles.advancedOptionsBtn}
>
<FontAwesomeIcon icon={faCog} />
{advancedOptions
? t('tools.json_converter.advanced_options.hide')
: t('tools.json_converter.advanced_options.show')}
</button>
{/* 高级选项面板 */}
{advancedOptions && (
<div className={styles.advancedOptionsContainer}>
{formatType === 'csv' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className={styles.label}>{t('tools.json_converter.advanced_options.csv.delimiter')}</label>
<select
value={csvDelimiter}
onChange={(e) => setCsvDelimiter(e.target.value)}
className={styles.selectBox}
>
<option value=",">{t('tools.json_converter.advanced_options.csv.delimiters.comma')}</option>
<option value=";">{t('tools.json_converter.advanced_options.csv.delimiters.semicolon')}</option>
<option value="\t">{t('tools.json_converter.advanced_options.csv.delimiters.tab')}</option>
<option value="|">{t('tools.json_converter.advanced_options.csv.delimiters.pipe')}</option>
</select>
</div>
<div>
<label className={styles.label}>{t('tools.json_converter.advanced_options.csv.include_header')}</label>
<div className="mt-2">
<label className="flex items-center">
<input
type="checkbox"
checked={csvHeader}
onChange={(e) => setCsvHeader(e.target.checked)}
className="mr-2"
/>
<span className="text-secondary">
{direction === 'json_to_format'
? t('tools.json_converter.advanced_options.csv.generate_header')
: t('tools.json_converter.advanced_options.csv.parse_header')}
</span>
</label>
</div>
</div>
</div>
)}
{formatType === 'xml' && (
<div>
<label className={styles.label}>{t('tools.json_converter.advanced_options.xml.root_element')}</label>
<input
type="text"
value={xmlRootName}
onChange={(e) => setXmlRootName(e.target.value)}
placeholder={t('tools.json_converter.advanced_options.xml.root_placeholder')}
className={styles.input}
/>
</div>
)}
<div className="mt-4">
<div className="flex items-center">
<FontAwesomeIcon icon={faInfoCircle} className="text-tertiary mr-2" />
<p className={styles.secondaryText}>{t('tools.json_converter.advanced_options.description')}</p>
</div>
</div>
</div>
)}
</div>
{/* 转换区域 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 输入框 */}
<div className="space-y-3">
<label className={styles.label}>
{direction === 'json_to_format'
? t('tools.json_converter.input.json')
: t('tools.json_converter.input.format').replace('{format}', formatType.toUpperCase())}
</label>
<textarea
ref={inputRef}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
className={styles.textarea}
placeholder={direction === 'json_to_format'
? t('tools.json_converter.input.json_placeholder')
: t('tools.json_converter.input.format_placeholder').replace('{format}', formatType.toUpperCase())}
rows={15}
/>
</div>
{/* 输出框 */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<label className={styles.label}>
{direction === 'json_to_format'
? t('tools.json_converter.output.format').replace('{format}', formatType.toUpperCase())
: t('tools.json_converter.output.json')}
</label>
<div className="flex gap-2">
<button
onClick={performConversion}
className={styles.actionBtnPrimary}
disabled={!inputText || isProcessing}
>
{isProcessing ? t('tools.json_converter.actions.converting') : t('tools.json_converter.actions.convert')}
</button>
</div>
</div>
{isProcessing ? (
<div className={styles.progressContainer}>
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-purple"></div>
</div>
) : (
<>
<textarea
ref={outputRef}
value={outputText}
readOnly
className={styles.textarea}
placeholder={direction === 'json_to_format'
? t('tools.json_converter.output.format_placeholder').replace('{format}', formatType.toUpperCase())
: t('tools.json_converter.output.json_placeholder')}
rows={15}
/>
{/* 错误信息 */}
{error && (
<div className={styles.errorBox}>
{error}
</div>
)}
{/* 操作按钮 */}
{outputText && (
<div className="flex justify-end gap-2">
<button
onClick={copyToClipboard}
className={styles.actionBtn}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
{copied ? t('tools.json_converter.actions.copied') : t('tools.json_converter.actions.copy')}
</button>
<button
onClick={downloadOutput}
className={styles.actionBtn}
>
<FontAwesomeIcon icon={faDownload} />
{t('tools.json_converter.actions.download')}
</button>
</div>
)}
</>
)}
</div>
</div>
{/* 说明部分 */}
<div className="p-4 bg-block rounded-lg">
<h3 className="text-primary font-medium mb-2">{t('tools.json_converter.notes.title')}</h3>
<div className="text-sm text-tertiary">
{formatType === 'xml' && (
<div>
<p>{t('tools.json_converter.notes.xml.title')}</p>
<ul className="list-disc pl-5 mt-2">
<li>{t('tools.json_converter.notes.xml.items.0')}</li>
<li>{t('tools.json_converter.notes.xml.items.1')}</li>
<li>{t('tools.json_converter.notes.xml.items.2')}</li>
</ul>
</div>
)}
{formatType === 'csv' && (
<div>
<p>{t('tools.json_converter.notes.csv.title')}</p>
<ul className="list-disc pl-5 mt-2">
<li>{t('tools.json_converter.notes.csv.items.0')}</li>
<li>{t('tools.json_converter.notes.csv.items.1')}</li>
<li>{t('tools.json_converter.notes.csv.items.2')}</li>
</ul>
</div>
)}
{formatType === 'yaml' && (
<div>
<p>{t('tools.json_converter.notes.yaml.title')}</p>
<ul className="list-disc pl-5 mt-2">
<li>{t('tools.json_converter.notes.yaml.items.0')}</li>
<li>{t('tools.json_converter.notes.yaml.items.1')}</li>
<li>{t('tools.json_converter.notes.yaml.items.2')}</li>
</ul>
</div>
)}
</div>
</div>
</div>
</div>
{/* 回到顶部按钮 */}
<BackToTop />
</div>
);
}
+271
View File
@@ -0,0 +1,271 @@
'use client';
import React, { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faCopy, faCheck, faDownload,
faUpload, faTrash
} from '@fortawesome/free-solid-svg-icons';
import dynamic from 'next/dynamic';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import tools from '@/config/tools';
import type { Content } from 'vanilla-jsoneditor';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
card: "card p-6",
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
actionBar: "flex flex-wrap items-center gap-3 p-4 bg-block rounded-lg border border-purple-glow",
input: "bg-block-strong text-primary border border-purple-glow/30 rounded px-3 py-1 text-sm focus:outline-none focus:border-purple focus:ring-1 focus:ring-purple",
statusMsg: "px-4 py-2 bg-block rounded-lg border border-purple-glow/30 text-secondary animate-fadeIn",
editorContainer: "overflow-hidden border border-purple-glow/30 rounded-lg shadow-lg",
fileNameLabel: "text-sm text-secondary",
loaderContainer: "h-[400px] flex items-center justify-center bg-block rounded-lg",
loaderBox: "flex flex-col items-center",
loaderSpinner: "w-8 h-8 border-2 border-purple border-t-transparent rounded-full animate-spin mb-4",
loaderText: "text-secondary"
};
// 动态导入JsonEditor组件,避免SSR问题
const JsonEditor = dynamic(() => import('@/components/JsonEditor'), {
ssr: false,
loading: () => {
return (
<div className={styles.loaderContainer}>
<div className={styles.loaderBox}>
<div className={styles.loaderSpinner}></div>
<span className={styles.loaderText}>Loading JSON Editor...</span>
</div>
</div>
);
}
});
export default function JsonEditorTool() {
// 从工具配置中获取当前工具信息
const toolConfig = tools.find(tool => tool.code === 'json_editor');
const { t } = useLanguage();
// 状态管理
const [content, setContent] = useState<Content>({ json: { example: t('tools.json_editor.edit_json_here') } });
const [copied, setCopied] = useState(false);
const [fileName, setFileName] = useState('data.json');
const [statusMessage, setStatusMessage] = useState('');
// 复制JSON到剪贴板
const copyToClipboard = () => {
try {
// 获取当前JSON内容
let jsonString;
if ('json' in content && content.json) {
jsonString = JSON.stringify(content.json, null, 2);
} else if ('text' in content && content.text) {
jsonString = content.text;
} else {
throw new Error(t('tools.json_editor.invalid_json'));
}
// 复制到剪贴板
navigator.clipboard.writeText(jsonString)
.then(() => {
setCopied(true);
setStatusMessage(t('tools.json_editor.copied_to_clipboard'));
setTimeout(() => {
setCopied(false);
setStatusMessage('');
}, 2000);
})
.catch(err => {
console.error(t('tools.json_editor.copy_failed'), err);
setStatusMessage(t('tools.json_editor.copy_failed'));
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_error) {
setStatusMessage(t('tools.json_editor.copy_failed'));
}
};
// 下载JSON文件
const downloadJson = () => {
try {
// 获取当前JSON内容
let jsonString;
if ('json' in content && content.json) {
jsonString = JSON.stringify(content.json, null, 2);
} else if ('text' in content && content.text) {
jsonString = content.text;
} else {
throw new Error(t('tools.json_editor.invalid_json'));
}
// 创建下载链接
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
// 清理
URL.revokeObjectURL(url);
document.body.removeChild(link);
setStatusMessage(t('tools.json_editor.download_success').replace('{fileName}', fileName));
setTimeout(() => setStatusMessage(''), 2000);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_error) {
setStatusMessage(t('tools.json_editor.download_failed'));
}
};
// 上传JSON文件
const uploadJson = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setFileName(file.name);
const reader = new FileReader();
reader.onload = (e) => {
try {
const fileContent = e.target?.result as string;
try {
// 尝试解析为JSON
const jsonData = JSON.parse(fileContent);
setContent({ json: jsonData });
setStatusMessage(t('tools.json_editor.loaded_file').replace('{fileName}', file.name));
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_parseError) {
// 如果解析失败,则以文本形式加载
setContent({ text: fileContent });
setStatusMessage(t('tools.json_editor.loaded_as_text').replace('{fileName}', file.name));
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_error) {
setStatusMessage(t('tools.json_editor.read_file_failed'));
}
// 重置文件输入框
event.target.value = '';
setTimeout(() => setStatusMessage(''), 2000);
};
reader.readAsText(file);
};
// 清空编辑器
const clearEditor = () => {
if (confirm(t('tools.json_editor.confirm_clear'))) {
setContent({ json: {} });
setStatusMessage(t('tools.json_editor.editor_cleared'));
setTimeout(() => setStatusMessage(''), 2000);
}
};
// 修改文件名
const handleFileNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
let name = event.target.value.trim();
if (!name) name = 'data.json';
if (!name.endsWith('.json')) name += '.json';
setFileName(name);
};
// 编辑器内容变化处理
const handleContentChange = (updatedContent: Content) => {
setContent(updatedContent);
};
return (
<div className={styles.container}>
{/* 工具头部 */}
<ToolHeader
toolCode="json_editor"
icon={toolConfig?.icon || tools[0].icon}
title=""
description=""
/>
{/* 主要内容区 */}
<div className="grid grid-cols-1 gap-6">
{/* 操作栏 */}
<div className={styles.actionBar}>
{/* 文件操作按钮 */}
<div className="flex flex-wrap items-center gap-2">
<button
className="btn-secondary"
onClick={copyToClipboard}
title={t('tools.json_editor.copy')}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} className="mr-2" />
{t('tools.json_editor.copy')}
</button>
<button
className="btn-secondary"
onClick={downloadJson}
title={t('tools.json_editor.download')}
>
<FontAwesomeIcon icon={faDownload} className="mr-2" />
{t('tools.json_editor.download')}
</button>
<label className="btn-secondary cursor-pointer">
<FontAwesomeIcon icon={faUpload} className="mr-2" />
{t('tools.json_editor.upload')}
<input
type="file"
accept=".json,application/json"
className="hidden"
onChange={uploadJson}
/>
</label>
<button
className="btn-secondary"
onClick={clearEditor}
title={t('tools.json_editor.clear')}
>
<FontAwesomeIcon icon={faTrash} className="mr-2" />
{t('tools.json_editor.clear')}
</button>
</div>
{/* 文件名输入框 */}
<div className="flex items-center gap-2 ml-auto">
<label htmlFor="filename" className={styles.fileNameLabel}>{t('tools.json_editor.file_name')}</label>
<input
id="filename"
type="text"
value={fileName}
onChange={handleFileNameChange}
className={styles.input}
/>
</div>
</div>
{/* 状态消息 */}
{statusMessage && (
<div className={styles.statusMsg}>
{statusMessage}
</div>
)}
{/* JSON编辑器 */}
<div className={styles.editorContainer}>
<JsonEditor
content={content}
onChange={handleContentChange}
className="h-[600px] w-full"
/>
</div>
</div>
{/* 返回顶部按钮 */}
<BackToTop />
</div>
);
}
+1058
View File
@@ -0,0 +1,1058 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faCode, faCopy, faCheck, faCompress, faExpand,
faSearch, faTrash, faSync, faFolderOpen, faFolder, faSpinner,
faSave, faHistory, faTimes, faEdit, faStar, faTrashAlt
} from '@fortawesome/free-solid-svg-icons';
import dynamic from 'next/dynamic';
import BackToTop from '@/components/BackToTop';
import ToolHeader from '@/components/ToolHeader';
import { useLanguage } from '@/context/LanguageContext';
// 动态导入@uiw/react-json-view组件,避免SSR问题
const ReactJson = dynamic(() => import('@uiw/react-json-view'), { ssr: false });
// 定义历史记录条目类型
interface JsonHistoryItem {
id: string;
title: string;
json: string;
timestamp: number;
isFavorite?: boolean;
}
export default function JsonFormatter() {
const { t } = useLanguage();
const [jsonInput, setJsonInput] = useState('');
const jsonInputRef = useRef<HTMLTextAreaElement>(null);
const jsonPathInputRef = useRef<HTMLInputElement>(null);
// 状态管理
const [jsonOutput, setJsonOutput] = useState<string>('');
const [jsonPath, setJsonPath] = useState<string>('');
const [pathResult, setPathResult] = useState<string>('');
const [errorMessage, setErrorMessage] = useState<string>('');
const [isCompressed, setIsCompressed] = useState<boolean>(false);
const [isFoldable, setIsFoldable] = useState<boolean>(true);
const [copied, setCopied] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isLargeJson, setIsLargeJson] = useState<boolean>(false);
const [validationResult, setValidationResult] = useState<{
isValid: boolean;
message: string;
}>({ isValid: false, message: '' });
// 历史记录相关状态
const [historyItems, setHistoryItems] = useState<JsonHistoryItem[]>([]);
const [isHistoryOpen, setIsHistoryOpen] = useState<boolean>(false);
const [savingTitle, setSavingTitle] = useState<string>('');
const [isSaveModalOpen, setIsSaveModalOpen] = useState<boolean>(false);
const [editingItem, setEditingItem] = useState<JsonHistoryItem | null>(null);
// 参考值,确保能在格式化过程中保持加载状态
const processingRef = useRef<boolean>(false);
// 从本地存储加载历史记录
useEffect(() => {
const savedHistory = localStorage.getItem('json_formatter_history');
if (savedHistory) {
try {
const parsedHistory = JSON.parse(savedHistory) as JsonHistoryItem[];
setHistoryItems(parsedHistory);
} catch (e) {
console.error(t('tools.json_formatter.load_history_error'), e);
}
}
}, [t]);
// 保存历史记录到本地存储
const saveHistoryToLocalStorage = (items: JsonHistoryItem[]) => {
localStorage.setItem('json_formatter_history', JSON.stringify(items));
};
// 格式化JSON
const formatJson = (json: string, compress = false) => {
if (!json.trim()) {
setJsonOutput('');
setValidationResult({ isValid: false, message: '' });
return;
}
// 检查JSON大小
const isLarge = json.length > 100000;
setIsLargeJson(isLarge);
// 设置加载状态和处理参考值
setIsLoading(true);
processingRef.current = true;
// 使用setTimeout确保UI先更新,但不添加不必要的延迟
setTimeout(() => {
try {
// 处理可能的JS对象文本 (将单引号转为双引号)
const processedJson = json
.replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2":') // 键名标准化
.replace(/'/g, '"'); // 单引号转双引号
try {
let parsed;
try {
parsed = JSON.parse(processedJson);
} catch (e) {
// 尝试使用eval处理JS对象(不安全,但为了更好的兼容性)
try {
// eslint-disable-next-line no-eval
parsed = eval('(' + json + ')');
} catch (/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
_) {
throw e; // 如果eval也失败了,抛出原始错误
}
}
// 根据模式输出不同格式
let formattedJson;
if (compress) {
formattedJson = JSON.stringify(parsed);
} else {
formattedJson = JSON.stringify(parsed, null, 2);
}
// 设置输出
setJsonOutput(formattedJson);
// 计算大小
const sizeKB = (formattedJson.length / 1024).toFixed(1);
const largeJsonMessage = t('tools.json_formatter.large_json_processed').replace('{size}', sizeKB);
setValidationResult({
isValid: true,
message: isLarge ? largeJsonMessage : t('tools.json_formatter.json_valid')
});
setErrorMessage('');
// 如果有JSONPath查询,执行查询
if (jsonPath) {
queryJsonPath(parsed, jsonPath);
}
// 完成后取消加载状态和处理参考值
setIsLoading(false);
processingRef.current = false;
} catch (error) {
if (error instanceof Error) {
setErrorMessage(error.message);
setValidationResult({ isValid: false, message: t('tools.json_formatter.json_invalid') });
setIsLoading(false);
processingRef.current = false;
}
}
} catch (error) {
if (error instanceof Error) {
setErrorMessage(error.message);
setValidationResult({ isValid: false, message: t('tools.json_formatter.json_invalid') });
setIsLoading(false);
processingRef.current = false;
}
}
}, 0);
};
// 取消正在进行的格式化操作
const cancelFormatting = () => {
processingRef.current = false;
setIsLoading(false);
};
// 清除组件卸载时可能的处理操作
useEffect(() => {
return () => {
processingRef.current = false;
};
}, []);
// 压缩/美化切换
const toggleCompression = () => {
setIsCompressed(!isCompressed);
formatJson(jsonInput, !isCompressed);
};
// 切换折叠功能
const toggleFoldable = () => {
setIsFoldable(!isFoldable);
};
// 复制结果到剪贴板
const copyToClipboard = () => {
if (jsonOutput) {
navigator.clipboard.writeText(jsonOutput)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => console.error(t('tools.json_formatter.copy_failed'), err));
}
};
// 清空输入
const clearInput = () => {
// 如果正在处理,先取消
if (isLoading) {
cancelFormatting();
}
setJsonInput('');
setJsonOutput('');
setErrorMessage('');
setValidationResult({ isValid: false, message: '' });
setPathResult('');
if (jsonInputRef.current) {
jsonInputRef.current.focus();
}
};
// 处理输入变化
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setJsonInput(value);
};
// 处理粘贴事件
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
// 获取粘贴的内容
const pastedText = e.clipboardData.getData('text');
if (pastedText && pastedText.trim().length > 0) {
// 如果正在处理,先取消
if (isLoading) {
cancelFormatting();
}
// 更新输入内容
setJsonInput(pastedText);
// 立即设置加载状态但延迟执行格式化,确保UI更新
setIsLoading(true);
processingRef.current = true;
setTimeout(() => formatJson(pastedText, isCompressed), 100);
}
};
// 路径查询变化
const handlePathChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setJsonPath(value);
// 如果有有效的JSON和路径,执行查询
if (value && jsonOutput) {
try {
const parsed = JSON.parse(jsonOutput);
queryJsonPath(parsed, value);
} catch (/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
_error) {
// JSON解析错误,忽略
}
} else {
setPathResult('');
}
};
// 执行JSONPath查询
const queryJsonPath = (json: Record<string, unknown>, path: string) => {
if (!path) {
setPathResult('');
return;
}
try {
// 简单的路径解析,支持点符号和方括号
const segments = path
.replace(/\[(\w+)\]/g, '.$1') // 将[abc]转换为.abc
.replace(/^\./, '') // 移除开头的点
.split('.');
let result: unknown = json;
for (const segment of segments) {
if (typeof result === 'object' && result !== null && segment in result) {
result = (result as Record<string, unknown>)[segment];
} else {
throw new Error(`路径 '${path}' 不存在`);
}
}
// 格式化结果
if (typeof result === 'object' && result !== null) {
setPathResult(JSON.stringify(result, null, 2));
} else {
setPathResult(String(result));
}
} catch (error) {
if (error instanceof Error) {
setPathResult(`查询错误: ${error.message}`);
}
}
};
// 加载示例JSON
const loadExample = () => {
// 如果正在处理,先取消
if (isLoading) {
cancelFormatting();
}
const example = {
name: "极速箱",
version: "1.0.0",
description: "高效开发工具集成平台",
author: {
name: "JiSuXiang开发团队",
email: "[email protected]"
},
features: [
"JSON格式化与验证",
"时间戳转换",
"编码转换工具",
"正则表达式测试"
],
statistics: {
tools: 2400,
users: 10000000,
rating: 4.9
},
isOpenSource: true,
lastUpdate: "2063-12-01T08:00:00Z"
};
const exampleJson = JSON.stringify(example);
setJsonInput(exampleJson);
formatJson(exampleJson, isCompressed);
};
// 重新格式化
const reformat = () => {
// 如果正在处理,先取消
if (isLoading) {
cancelFormatting();
}
formatJson(jsonInput, isCompressed);
};
// 渲染可折叠的JSON
const renderFoldableJson = (jsonStr: string) => {
if (!jsonStr) return null;
try {
const jsonObj = JSON.parse(jsonStr);
return (
<div className="json-viewer-theme">
<ReactJson
value={jsonObj}
style={{
backgroundColor: 'transparent',
fontFamily: 'monospace',
fontSize: '0.95rem',
lineHeight: '1.7',
color: 'rgb(var(--color-text-primary))'
}}
displayObjectSize={true}
enableClipboard={false}
displayDataTypes={false}
/>
</div>
);
} catch (/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
_error) {
// 如果解析失败,回退到普通模式
return <pre className="whitespace-pre-wrap m-0">{jsonStr}</pre>;
}
};
// 处理输入/输出区域的样式
const getTextareaClasses = (hasError: boolean) => {
return `w-full p-4 font-mono text-sm rounded-md border ${
hasError ? 'border-[rgb(var(--color-error))]' : 'border-[rgba(var(--color-primary),0.2)]'
} focus:outline-none focus:border-[rgb(var(--color-primary))] focus:ring-1 focus:ring-[rgb(var(--color-primary))] min-h-[350px] transition-all bg-[rgb(var(--color-bg-secondary))] text-[rgb(var(--color-text-primary))]`;
};
// 右侧输出区域样式
const outputAreaClasses = `${getTextareaClasses(false)} overflow-auto relative flex-grow json-output-area`;
// 工具栏按钮样式
const toolbarButtonClass = "px-3 py-1.5 rounded text-sm flex items-center gap-1 transition-all border border-transparent hover:border-[rgba(var(--color-primary),0.3)]";
// 历史记录项目样式
const historyItemClass = "p-3 rounded-md border border-[rgba(var(--color-primary),0.15)] hover:border-[rgba(var(--color-primary),0.4)] transition-all cursor-pointer bg-[rgb(var(--color-bg-secondary))] flex justify-between items-center mb-2";
// 自动格式化(仅在首次输入更改后)
useEffect(() => {
if (jsonInput) {
formatJson(jsonInput, isCompressed);
}
}, []);
// 保存当前JSON到历史记录
const saveToHistory = () => {
if (!jsonOutput || !jsonOutput.trim()) return;
if (editingItem) {
// 更新现有项目
const updatedItem = {
...editingItem,
title: savingTitle || `未命名 ${new Date().toLocaleString()}`,
json: jsonOutput,
timestamp: Date.now()
};
const updatedHistory = historyItems.map(item =>
item.id === editingItem.id ? updatedItem : item
);
setHistoryItems(updatedHistory);
saveHistoryToLocalStorage(updatedHistory);
} else {
// 创建新项目
const newItem: JsonHistoryItem = {
id: Date.now().toString(),
title: savingTitle || `未命名 ${new Date().toLocaleString()}`,
json: jsonOutput,
timestamp: Date.now()
};
const updatedHistory = [newItem, ...historyItems];
setHistoryItems(updatedHistory);
saveHistoryToLocalStorage(updatedHistory);
}
// 重置状态
setSavingTitle('');
setIsSaveModalOpen(false);
setEditingItem(null);
};
// 加载历史记录中的JSON
const loadFromHistory = (item: JsonHistoryItem) => {
setJsonInput(item.json);
formatJson(item.json, isCompressed);
setIsHistoryOpen(false);
};
// 删除历史记录项目
const deleteHistoryItem = (id: string, e: React.MouseEvent) => {
e.stopPropagation(); // 防止触发父元素的点击事件
const updatedHistory = historyItems.filter(item => item.id !== id);
setHistoryItems(updatedHistory);
saveHistoryToLocalStorage(updatedHistory);
};
// 编辑历史记录项目标题
const startEditingTitle = (item: JsonHistoryItem, e: React.MouseEvent) => {
e.stopPropagation(); // 防止触发父元素的点击事件
setEditingItem(item);
setSavingTitle(item.title);
setIsSaveModalOpen(true);
};
// 切换收藏状态
const toggleFavorite = (id: string, e: React.MouseEvent) => {
e.stopPropagation(); // 防止触发父元素的点击事件
const updatedHistory = historyItems.map(item => {
if (item.id === id) {
return { ...item, isFavorite: !item.isFavorite };
}
return item;
});
setHistoryItems(updatedHistory);
saveHistoryToLocalStorage(updatedHistory);
};
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
{/* 使用自定义样式 */}
<style jsx global>{`
.json-viewer-theme {
--w-rjv-border-left-color: rgba(var(--color-primary), 0.5);
--w-rjv-border-left-width: 1px;
--w-rjv-color: rgb(var(--color-text-primary));
--w-rjv-key-string: rgb(var(--color-primary-light));
--w-rjv-type-string-color: rgb(var(--color-success));
--w-rjv-type-int-color: rgb(var(--color-warning));
--w-rjv-type-float-color: rgb(var(--color-warning));
--w-rjv-type-boolean-color: rgb(var(--color-primary-hover));
--w-rjv-arrow-color: rgb(var(--color-text-secondary));
--w-rjv-background-color: transparent;
}
/* 增强JSON展示区域样式 */
.json-output-area {
font-size: 0.95rem !important;
letter-spacing: 0.01em;
background-color: rgb(var(--color-bg-secondary)) !important;
}
.json-output-area pre {
font-size: 0.95rem !important;
color: rgb(var(--color-text-primary)) !important;
}
/* 增强JSON视图组件中的文本样式 */
.json-viewer-theme > div {
font-size: 0.95rem !important;
letter-spacing: 0.01em;
}
/* 优化字符串、数字和布尔值显示 */
.json-viewer-theme [data-type="string"] {
color: rgb(var(--color-success)) !important;
opacity: 0.95;
}
.json-viewer-theme [data-type="number"],
.json-viewer-theme [data-type="int"] {
color: rgb(var(--color-warning)) !important;
opacity: 0.95;
}
.json-viewer-theme [data-type="boolean"] {
color: rgb(var(--color-primary-hover)) !important;
opacity: 0.95;
}
/* 优化键名显示 */
.json-viewer-theme [data-key] {
color: rgb(var(--color-primary-light)) !important;
font-weight: 500;
opacity: 0.9;
}
/* 优化项目计数显示 */
.json-viewer-theme .w-rjv-objects {
color: rgb(var(--color-text-secondary)) !important;
opacity: 0.9;
font-size: 0.9em;
}
/* 优化同级元素对齐 */
.json-viewer-theme .w-rjv-object-key,
.json-viewer-theme .w-rjv-array-key {
position: relative;
display: inline-flex;
min-width: 1em;
}
/* 使展开/折叠按钮更加合理 */
.json-viewer-theme .w-rjv-item {
position: relative;
}
.json-viewer-theme .w-rjv-arrow {
position: absolute;
left: -15px;
top: 3px;
}
/* 减弱引号的显示 */
.json-viewer-theme .w-rjv-qoute {
opacity: 0.5;
color: rgb(var(--color-text-tertiary)) !important;
}
/* 增强非折叠模式下的JSON显示 */
.json-output-area pre.text-base {
padding: 0.5rem;
}
/* 增强JSONPath查询结果区域 */
.card pre {
font-size: 0.95rem !important;
color: rgb(var(--color-text-primary)) !important;
}
/* 暗色主题特定样式 */
[data-theme="dark"] .json-output-area pre {
text-shadow: none;
}
[data-theme="dark"] .json-viewer-theme [data-type="string"] {
text-shadow: none;
}
[data-theme="dark"] .json-viewer-theme [data-type="number"],
[data-theme="dark"] .json-viewer-theme [data-type="int"] {
text-shadow: none;
}
[data-theme="dark"] .json-viewer-theme [data-type="boolean"] {
text-shadow: none;
}
/* 历史记录面板样式 */
.history-panel {
position: fixed;
top: 0;
right: 0;
height: 100vh;
width: 350px;
background-color: rgb(var(--color-bg-primary));
border-left: 1px solid rgba(var(--color-primary), 0.2);
z-index: 50;
transform: translateX(100%);
transition: transform 0.3s ease-in-out;
box-shadow: -5px 0 15px rgba(0, 0, 0, 0.1);
padding: 1rem;
overflow-y: auto;
}
.history-panel.open {
transform: translateX(0);
}
.history-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 40;
backdrop-filter: blur(2px);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease-in-out;
}
.history-overlay.open {
opacity: 1;
pointer-events: auto;
}
/* 模态框样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 60;
display: flex;
justify-content: center;
align-items: center;
backdrop-filter: blur(3px);
}
.modal-container {
background-color: rgb(var(--color-bg-primary));
border-radius: 0.5rem;
padding: 1.5rem;
width: 90%;
max-width: 500px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
border: 1px solid rgba(var(--color-primary), 0.2);
}
/* 收藏图标样式 */
.favorite-icon {
color: rgba(var(--color-text-tertiary));
transition: color 0.2s ease-in-out;
}
.favorite-icon.active {
color: rgb(var(--color-warning));
}
`}</style>
{/* 使用 ToolHeader 组件 */}
<ToolHeader
toolCode="json_formatter"
icon={faCode}
title={t('tools.json_formatter.title')}
description={t('tools.json_formatter.description')}
/>
{/* 验证状态显示 */}
<div className="text-center mb-4">
{validationResult.message && !isLoading && (
<span className={validationResult.isValid ? 'text-[rgb(var(--color-success))]' : 'text-[rgb(var(--color-error))]'}>
{validationResult.message}
</span>
)}
{isLoading && (
<span className="flex items-center justify-center" style={{color: 'rgb(var(--color-text-tertiary))'}}>
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-1" />
<span>{isLargeJson ? t('tools.json_formatter.processing_large_json') : t('tools.json_formatter.parsing_json')}</span>
</span>
)}
</div>
{/* 工具栏 */}
<div className="flex flex-wrap gap-2 mb-4">
<button
className={`${toolbarButtonClass} bg-[rgb(var(--color-bg-secondary))] text-[rgb(var(--color-text-secondary))] hover:text-[rgb(var(--color-text-primary))]`}
onClick={toggleCompression}
disabled={isLoading}
>
<FontAwesomeIcon icon={isCompressed ? faExpand : faCompress} />
<span>{isCompressed ? t('tools.json_formatter.beautify') : t('tools.json_formatter.compress')}</span>
</button>
<button
className={`${toolbarButtonClass} bg-[rgb(var(--color-bg-secondary))] text-[rgb(var(--color-text-secondary))] hover:text-[rgb(var(--color-text-primary))]`}
onClick={toggleFoldable}
disabled={isLoading || !jsonOutput}
>
<FontAwesomeIcon icon={isFoldable ? faFolder : faFolderOpen} />
<span>{isFoldable ? t('tools.json_formatter.normal_mode') : t('tools.json_formatter.fold_mode')}</span>
</button>
<button
className={`${toolbarButtonClass} bg-[rgb(var(--color-bg-secondary))] text-[rgb(var(--color-text-secondary))] hover:text-[rgb(var(--color-text-primary))]`}
onClick={copyToClipboard}
disabled={!jsonOutput || isLoading}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
<span>{copied ? t('common.copySuccess') : t('tools.json_formatter.copy')}</span>
</button>
<button
className={`${toolbarButtonClass} bg-[rgb(var(--color-bg-secondary))] text-[rgb(var(--color-text-secondary))] hover:text-[rgb(var(--color-text-primary))]`}
onClick={clearInput}
disabled={isLoading}
>
<FontAwesomeIcon icon={faTrash} />
<span>{t('tools.json_formatter.clear')}</span>
</button>
<button
className={`${toolbarButtonClass} bg-[rgb(var(--color-bg-secondary))] text-[rgb(var(--color-text-secondary))] hover:text-[rgb(var(--color-text-primary))]`}
onClick={loadExample}
disabled={isLoading}
>
<FontAwesomeIcon icon={faCode} />
<span>{t('tools.json_formatter.load_example')}</span>
</button>
<button
className={`${toolbarButtonClass} bg-[rgb(var(--color-bg-secondary))] text-[rgb(var(--color-text-secondary))] hover:text-[rgb(var(--color-text-primary))]`}
onClick={reformat}
disabled={!jsonInput || isLoading}
>
<FontAwesomeIcon icon={faSync} className={isLoading ? 'animate-spin' : ''} />
<span>{isLoading ? t('tools.json_formatter.processing') : t('tools.json_formatter.reformat')}</span>
</button>
{/* 新增的保存和历史记录按钮 */}
<button
className={`${toolbarButtonClass} bg-[rgb(var(--color-bg-secondary))] text-[rgb(var(--color-text-secondary))] hover:text-[rgb(var(--color-text-primary))]`}
onClick={() => setIsSaveModalOpen(true)}
disabled={!jsonOutput || isLoading}
>
<FontAwesomeIcon icon={faSave} />
<span>{t('tools.json_formatter.save')}</span>
</button>
<button
className={`${toolbarButtonClass} bg-[rgb(var(--color-bg-secondary))] text-[rgb(var(--color-text-secondary))] hover:text-[rgb(var(--color-text-primary))]`}
onClick={() => setIsHistoryOpen(true)}
>
<FontAwesomeIcon icon={faHistory} />
<span>{t('tools.json_formatter.history')}</span>
</button>
{isLoading && (
<button
className="px-3 py-1.5 rounded text-sm flex items-center gap-1 transition-all border"
style={{
backgroundColor: 'rgb(var(--color-bg-secondary))',
color: 'rgb(var(--color-text-secondary))',
borderColor: 'rgb(var(--color-primary))',
opacity: 0.8
}}
onClick={cancelFormatting}
>
<FontAwesomeIcon icon={faSpinner} className="animate-spin" />
<span>{t('tools.json_formatter.cancel')}</span>
</button>
)}
</div>
{/* 主内容区 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6 flex-grow">
{/* 输入区域 */}
<div className="flex flex-col h-full">
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium" style={{color: 'rgb(var(--color-text-secondary))'}}>{t('tools.json_formatter.input_json')}</label>
<div className="text-xs" style={{color: 'rgb(var(--color-text-tertiary))'}}>{t('tools.json_formatter.paste_json_here')}</div>
</div>
<div className="flex-grow flex flex-col">
<textarea
ref={jsonInputRef}
className={getTextareaClasses(!!errorMessage) + " flex-grow"}
value={jsonInput}
onChange={handleInputChange}
onBlur={() => jsonInput && !isLoading && formatJson(jsonInput, isCompressed)}
onPaste={handlePaste}
placeholder={t('tools.json_formatter.paste_json_placeholder')}
disabled={isLoading}
/>
{errorMessage && (
<div className="mt-2 text-sm text-[rgb(var(--color-error))]">
{errorMessage}
</div>
)}
</div>
</div>
{/* 输出区域 */}
<div className="flex flex-col h-full">
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium" style={{color: 'rgb(var(--color-text-secondary))'}}>{t('tools.json_formatter.output')}</label>
<div className="text-xs" style={{color: 'rgb(var(--color-text-tertiary))'}}>
{jsonOutput && !isLoading && `${jsonOutput.length.toLocaleString()} ${t('tools.json_formatter.characters')}`}
{isLoading && (
<span className="flex items-center">
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-1" />
<span>{isLargeJson ? t('tools.json_formatter.processing_large_json') : t('tools.json_formatter.processing')}</span>
</span>
)}
</div>
</div>
<div className={outputAreaClasses}>
{isLoading ? (
<div className="absolute inset-0 flex items-center justify-center backdrop-blur-sm"
style={{backgroundColor: 'rgba(var(--color-bg-secondary), 0.7)'}}>
<div className="flex flex-col items-center space-y-2">
<FontAwesomeIcon icon={faSpinner} className="animate-spin text-2xl" style={{color: 'rgb(var(--color-primary))'}} />
<span style={{color: 'rgb(var(--color-text-secondary))'}} className="text-center">
{isLargeJson ?
t('tools.json_formatter.processing_large_json_message') :
t('tools.json_formatter.parsing_json')}
</span>
<button
className="mt-3 px-3 py-1.5 text-xs rounded transition-all border"
style={{
backgroundColor: 'rgb(var(--color-bg-secondary))',
color: 'rgb(var(--color-text-secondary))',
borderColor: 'rgba(var(--color-primary), 0.5)'
}}
onClick={cancelFormatting}
>
{t('tools.json_formatter.cancel_processing')}
</button>
</div>
</div>
) : isFoldable ? (
renderFoldableJson(jsonOutput)
) : (
<pre className="whitespace-pre-wrap m-0 text-base leading-7" style={{color: 'rgb(var(--color-text-primary))'}}>{jsonOutput}</pre>
)}
</div>
</div>
</div>
{/* JSONPath查询 */}
<div className="mt-4 card p-4">
<div className="mb-2">
<label className="text-sm font-medium" style={{color: 'rgb(var(--color-text-secondary))'}}>{t('tools.json_formatter.jsonpath_query')}</label>
<div className="text-xs mt-1" style={{color: 'rgb(var(--color-text-tertiary))'}}>
{t('tools.json_formatter.enter_jsonpath')}
</div>
</div>
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<FontAwesomeIcon
icon={faSearch}
className="absolute left-3 top-1/2 -translate-y-1/2"
style={{color: 'rgb(var(--color-text-tertiary))'}}
/>
<input
ref={jsonPathInputRef}
type="text"
value={jsonPath}
onChange={handlePathChange}
placeholder={t('tools.json_formatter.jsonpath_placeholder')}
className="search-input pl-10"
disabled={isLoading || !jsonOutput}
/>
</div>
</div>
<div className="flex-1">
<div className="p-3 rounded-md min-h-[40px] text-sm"
style={{
backgroundColor: 'rgb(var(--color-bg-secondary))',
color: 'rgb(var(--color-text-secondary))'
}}>
<pre className="whitespace-pre-wrap">{pathResult || t('tools.json_formatter.query_result_placeholder')}</pre>
</div>
</div>
</div>
</div>
{/* 历史记录侧边栏 */}
<div className={`history-overlay ${isHistoryOpen ? 'open' : ''}`} onClick={() => setIsHistoryOpen(false)}></div>
<div className={`history-panel ${isHistoryOpen ? 'open' : ''}`}>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium" style={{color: 'rgb(var(--color-text-primary))'}}>{t('tools.json_formatter.history')}</h3>
<button
className="p-2 rounded-full hover:bg-[rgba(var(--color-primary),0.1)]"
onClick={() => setIsHistoryOpen(false)}
>
<FontAwesomeIcon icon={faTimes} style={{color: 'rgb(var(--color-text-secondary))'}} />
</button>
</div>
{historyItems.length === 0 ? (
<div className="text-center py-8" style={{color: 'rgb(var(--color-text-tertiary))'}}>
<p>{t('tools.json_formatter.no_saved_records')}</p>
<p className="text-sm mt-2">{t('tools.json_formatter.save_first_record')}</p>
</div>
) : (
<div>
{/* 收藏的项目 */}
{historyItems.some(item => item.isFavorite) && (
<div className="mb-4">
<h4 className="text-sm font-medium mb-2" style={{color: 'rgb(var(--color-text-secondary))'}}>{t('tools.json_formatter.favorites')}</h4>
{historyItems
.filter(item => item.isFavorite)
.map(item => (
<div key={item.id} className={historyItemClass} onClick={() => loadFromHistory(item)}>
<div className="flex-1 truncate">
<div className="font-medium truncate">{item.title}</div>
<div className="text-xs" style={{color: 'rgb(var(--color-text-tertiary))'}}>
{new Date(item.timestamp).toLocaleString()}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => toggleFavorite(item.id, e)}
title={item.isFavorite ? t('tools.json_formatter.remove_favorite') : t('tools.json_formatter.add_favorite')}
>
<FontAwesomeIcon
icon={faStar}
className={`favorite-icon ${item.isFavorite ? 'active' : ''}`}
/>
</button>
<button onClick={(e) => startEditingTitle(item, e)} title={t('tools.json_formatter.edit_title')}>
<FontAwesomeIcon
icon={faEdit}
style={{color: 'rgb(var(--color-text-tertiary))'}}
/>
</button>
<button onClick={(e) => deleteHistoryItem(item.id, e)} title={t('tools.json_formatter.delete')}>
<FontAwesomeIcon
icon={faTrashAlt}
style={{color: 'rgb(var(--color-text-tertiary))'}}
/>
</button>
</div>
</div>
))}
</div>
)}
{/* 全部历史记录 */}
<h4 className="text-sm font-medium mb-2" style={{color: 'rgb(var(--color-text-secondary))'}}>{t('tools.json_formatter.all_history')}</h4>
{historyItems.map(item => (
<div key={item.id} className={historyItemClass} onClick={() => loadFromHistory(item)}>
<div className="flex-1 truncate">
<div className="font-medium truncate">{item.title}</div>
<div className="text-xs" style={{color: 'rgb(var(--color-text-tertiary))'}}>
{new Date(item.timestamp).toLocaleString()}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => toggleFavorite(item.id, e)}
title={item.isFavorite ? t('tools.json_formatter.remove_favorite') : t('tools.json_formatter.add_favorite')}
>
<FontAwesomeIcon
icon={faStar}
className={`favorite-icon ${item.isFavorite ? 'active' : ''}`}
/>
</button>
<button onClick={(e) => startEditingTitle(item, e)} title={t('tools.json_formatter.edit_title')}>
<FontAwesomeIcon
icon={faEdit}
style={{color: 'rgb(var(--color-text-tertiary))'}}
/>
</button>
<button onClick={(e) => deleteHistoryItem(item.id, e)} title={t('tools.json_formatter.delete')}>
<FontAwesomeIcon
icon={faTrashAlt}
style={{color: 'rgb(var(--color-text-tertiary))'}}
/>
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* 保存模态框 */}
{isSaveModalOpen && (
<div className="modal-overlay">
<div className="modal-container">
<h3 className="text-lg font-medium mb-4" style={{color: 'rgb(var(--color-text-primary))'}}>
{editingItem ? t('tools.json_formatter.edit_saved_json') : t('tools.json_formatter.save_to_history')}
</h3>
<div className="mb-4">
<label className="block text-sm font-medium mb-1" style={{color: 'rgb(var(--color-text-secondary))'}}>
{t('tools.json_formatter.title')}
</label>
<input
type="text"
value={savingTitle}
onChange={(e) => setSavingTitle(e.target.value)}
placeholder={t('tools.json_formatter.enter_title')}
className="search-input w-full"
/>
</div>
<div className="flex justify-end gap-2">
<button
className="px-4 py-2 rounded-md text-sm"
style={{
backgroundColor: 'rgb(var(--color-bg-secondary))',
color: 'rgb(var(--color-text-secondary))'
}}
onClick={() => {
setIsSaveModalOpen(false);
setEditingItem(null);
setSavingTitle('');
}}
>
{t('tools.json_formatter.cancel')}
</button>
<button
className="px-4 py-2 rounded-md text-sm"
style={{
backgroundColor: 'rgb(var(--color-primary))',
color: 'white'
}}
onClick={saveToHistory}
>
{editingItem ? t('tools.json_formatter.update') : t('tools.json_formatter.save')}
</button>
</div>
</div>
</div>
)}
{/* 说明信息 */}
<div className="mt-8 text-sm" style={{color: 'rgb(var(--color-text-tertiary))'}}>
<h3 className="mb-2 font-medium" style={{color: 'rgb(var(--color-text-secondary))'}}>{t('tools.json_formatter.usage_guide')}</h3>
<ul className="list-disc pl-5 space-y-1">
<li>{t('tools.json_formatter.guide_1')}</li>
<li>{t('tools.json_formatter.guide_2')}</li>
<li>{t('tools.json_formatter.guide_3')}</li>
<li>{t('tools.json_formatter.guide_4')}</li>
<li>{t('tools.json_formatter.guide_5')}</li>
<li>{t('tools.json_formatter.guide_6')}</li>
<li>{t('tools.json_formatter.guide_7')}</li>
</ul>
</div>
{/* 回到顶部按钮 */}
<BackToTop position="bottom-right" offset={30} size="medium" />
</div>
);
}
+536
View File
@@ -0,0 +1,536 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLock, faCopy, faCheck, faEraser, faSync, faInfoCircle } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import tools from '@/config/tools';
import { useLanguage } from '@/context/LanguageContext';
interface JwtPayload {
exp?: number;
iat?: number;
sub?: string;
[key: string]: unknown;
}
interface JwtHeader {
alg: string;
typ: string;
[key: string]: unknown;
}
interface DecodedJwt {
header: JwtHeader;
payload: JwtPayload;
signature: string;
isValid: boolean;
expirationStatus: 'valid' | 'expired' | 'not-set';
expiresIn?: string;
}
// 添加CSS变量样式
const styles = {
card: "card p-6",
textArea: "w-full h-24 p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple transition-all resize-none font-mono",
preArea: "w-full h-80 p-3 bg-block border border-purple-glow rounded-lg text-primary overflow-auto scrollbar-thin scrollbar-thumb-block-strong scrollbar-track-block font-mono text-sm whitespace-pre-wrap",
label: "text-secondary font-medium",
error: "p-3 bg-red-900/20 border border-red-700/30 text-red-500 rounded-lg",
success: "p-3 bg-green-900/20 border border-green-700/30 text-success rounded-lg",
tabButton: (isActive: boolean) => `px-4 py-2 font-medium text-sm ${isActive ? 'text-purple border-b-2 border-purple' : 'text-tertiary hover:text-secondary'}`,
copyButton: "flex items-center gap-1 text-sm px-2 py-1 rounded bg-block-strong hover:bg-block-strong/80 text-secondary transition-colors",
statusBox: (type: 'valid' | 'expired' | 'not-set') => {
if (type === 'valid') return "px-2 py-1 rounded-md text-xs bg-green-900/10 text-success";
if (type === 'expired') return "px-2 py-1 rounded-md text-xs bg-red-900/10 text-error";
return "px-2 py-1 rounded-md text-xs bg-block-strong text-tertiary";
},
heading: "text-primary font-medium mb-2",
secondaryText: "text-sm text-tertiary",
highlight: "text-purple",
tokenPart: "rounded px-2 py-1 text-xs text-white font-mono",
headerPart: "bg-blue-500",
payloadPart: "bg-purple-500",
signaturePart: "bg-green-500"
}
export default function JwtDecoder() {
// 从工具配置中获取当前工具信息
const toolConfig = tools.find(tool => tool.code === 'jwt_decoder');
const { t } = useLanguage();
// 状态管理
const [jwtToken, setJwtToken] = useState('');
const [decodedJwt, setDecodedJwt] = useState<DecodedJwt | null>(null);
const [activeTab, setActiveTab] = useState<'header' | 'payload' | 'signature'>('payload');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [copied, setCopied] = useState(false);
const [showTokenParts, setShowTokenParts] = useState(false);
// 解析JWT
useEffect(() => {
if (!jwtToken.trim()) {
setDecodedJwt(null);
setError('');
return;
}
try {
const result = decodeJwt(jwtToken);
setDecodedJwt(result);
setError('');
} catch (err) {
if (err instanceof Error) {
setError(err.message);
} else {
setError(t('tools.jwt_decoder.parsing_error'));
}
setDecodedJwt(null);
}
}, [jwtToken, t]);
// 解码JWT令牌
const decodeJwt = (token: string): DecodedJwt => {
if (!token) {
throw new Error(t('tools.jwt_decoder.token_empty'));
}
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error(t('tools.jwt_decoder.invalid_format'));
}
try {
// 解码header和payload
const header = JSON.parse(base64UrlDecode(parts[0])) as JwtHeader;
const payload = JSON.parse(base64UrlDecode(parts[1])) as JwtPayload;
const signature = parts[2];
// 计算过期状态
let expirationStatus: 'valid' | 'expired' | 'not-set' = 'not-set';
let expiresIn: string | undefined;
if (payload.exp) {
const expiration = new Date(payload.exp * 1000);
const now = new Date();
if (expiration > now) {
expirationStatus = 'valid';
expiresIn = getTimeRemaining(expiration);
} else {
expirationStatus = 'expired';
}
}
// 简单验证
const isValid = verifySignatureFormat(token);
return {
header,
payload,
signature,
isValid,
expirationStatus,
expiresIn
};
} catch {
throw new Error(t('tools.jwt_decoder.parsing_failed'));
}
};
// Base64 URL解码
const base64UrlDecode = (str: string): string => {
// 替换URL安全Base64字符为标准Base64
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
// 添加填充字符
while (base64.length % 4) {
base64 += '=';
}
try {
// 解码
return decodeURIComponent(
atob(base64)
.split('')
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
} catch {
throw new Error(t('tools.jwt_decoder.invalid_base64'));
}
};
// 验证签名格式
const verifySignatureFormat = (token: string): boolean => {
const parts = token.split('.');
return parts.length === 3 && !!parts[2];
};
// 格式化JSON数据
const formatJson = (obj: unknown): string => {
return JSON.stringify(obj, null, 2);
};
// 计算剩余时间
const getTimeRemaining = (expirationDate: Date): string => {
const now = new Date();
const diff = expirationDate.getTime() - now.getTime();
if (diff <= 0) {
return t('tools.jwt_decoder.expired');
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
let timeStr = '';
if (days > 0) timeStr += `${days}${t('tools.jwt_decoder.days')} `;
if (hours > 0 || days > 0) timeStr += `${hours}${t('tools.jwt_decoder.hours')} `;
if (minutes > 0 || hours > 0 || days > 0) timeStr += `${minutes}${t('tools.jwt_decoder.minutes')} `;
timeStr += `${seconds}${t('tools.jwt_decoder.seconds')}`;
return timeStr;
};
// 复制当前标签内容到剪贴板
const copyToClipboard = () => {
if (!decodedJwt) return;
let contentToCopy = '';
if (activeTab === 'header') {
contentToCopy = formatJson(decodedJwt.header);
} else if (activeTab === 'payload') {
contentToCopy = formatJson(decodedJwt.payload);
} else {
contentToCopy = decodedJwt.signature;
}
navigator.clipboard.writeText(contentToCopy)
.then(() => {
setCopied(true);
setSuccess(t('tools.jwt_decoder.copied_content'));
setTimeout(() => {
setCopied(false);
setSuccess('');
}, 2000);
})
.catch(err => {
console.error(t('tools.jwt_decoder.copy_failed'), err);
setError(t('tools.jwt_decoder.clipboard_error'));
});
};
// 复制完整的JWT令牌
const copyFullToken = () => {
if (!jwtToken) return;
navigator.clipboard.writeText(jwtToken)
.then(() => {
setSuccess(t('tools.jwt_decoder.token_copied'));
setTimeout(() => setSuccess(''), 2000);
})
.catch(err => {
console.error(t('tools.jwt_decoder.copy_failed'), err);
setError(t('tools.jwt_decoder.clipboard_error'));
});
};
// 清空输入
const clearAll = () => {
setJwtToken('');
setDecodedJwt(null);
setError('');
setSuccess('');
};
// 加载示例JWT
const loadExample = () => {
// 示例JWT(过期时间设置为创建后的1小时)
const now = Math.floor(Date.now() / 1000);
const payload = {
sub: '1234567890',
name: t('tools.jwt_decoder.example_user'),
iat: now,
exp: now + 3600
};
// 创建示例token的header和payload部分
const header = { alg: 'HS256', typ: 'JWT' };
// 使用安全的方式转换为Base64URL
const headerB64 = base64UrlEncode(JSON.stringify(header));
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
// 签名部分用随机字符替代
const fakeSig = 'XqYSaj1HB9h0X5mJJwD9x_Z_U_Fel9YQcpP9ehZ0-0w';
// 完整示例JWT
const exampleJwt = `${headerB64}.${payloadB64}.${fakeSig}`;
setJwtToken(exampleJwt);
};
// Base64 URL编码(支持Unicode字符)
const base64UrlEncode = (str: string): string => {
// 将字符串转换为UTF-8编码的字节数组
const utf8Bytes = new TextEncoder().encode(str);
// 将字节数组转换为二进制字符串
let binaryStr = '';
utf8Bytes.forEach(byte => {
binaryStr += String.fromCharCode(byte);
});
// 使用btoa进行Base64编码,然后转换为URL安全的Base64
return btoa(binaryStr)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
};
// 获取过期状态的显示文本和类名
const getExpirationInfo = () => {
if (!decodedJwt || decodedJwt.expirationStatus === 'not-set') {
return {
text: t('tools.jwt_decoder.not_set'),
className: 'text-tertiary'
};
}
if (decodedJwt.expirationStatus === 'valid') {
return {
text: t('tools.jwt_decoder.valid_remaining').replace('{time}', decodedJwt.expiresIn || ''),
className: 'text-success'
};
} else {
return {
text: t('tools.jwt_decoder.expired'),
className: 'text-error'
};
}
};
// 获取JWT令牌各部分
const getTokenParts = () => {
if (!jwtToken) return null;
const parts = jwtToken.split('.');
if (parts.length !== 3) return null;
return {
header: parts[0],
payload: parts[1],
signature: parts[2]
};
};
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
<ToolHeader
toolCode="jwt_decoder"
icon={toolConfig?.icon || faLock}
title=""
description=""
/>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* 左侧面板 */}
<div className="lg:col-span-5">
<div className={styles.card}>
<h2 className="text-lg font-medium text-primary mb-4">{t('tools.jwt_decoder.decode_jwt')}</h2>
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<label className={styles.label}>{t('tools.jwt_decoder.jwt_token')}</label>
<button
className="text-xs text-tertiary hover:text-secondary"
onClick={() => setShowTokenParts(!showTokenParts)}
>
{showTokenParts ? t('tools.jwt_decoder.hide_parts') : t('tools.jwt_decoder.show_parts')}
</button>
</div>
<textarea
className={styles.textArea}
value={jwtToken}
onChange={(e) => setJwtToken(e.target.value)}
placeholder={t('tools.jwt_decoder.paste_jwt')}
/>
</div>
{/* 显示令牌分段 */}
{showTokenParts && (
<div className="space-y-2">
<div className="text-xs text-tertiary">{t('tools.jwt_decoder.token_structure')}</div>
<div className="flex flex-col gap-2 text-xs">
{getTokenParts() && (
<>
<div className="flex items-center gap-2">
<span className={`${styles.tokenPart} ${styles.headerPart}`}>{t('tools.jwt_decoder.header')}</span>
<span className="text-tertiary font-mono break-all">{getTokenParts()?.header}</span>
</div>
<div className="flex items-center gap-2">
<span className={`${styles.tokenPart} ${styles.payloadPart}`}>{t('tools.jwt_decoder.payload')}</span>
<span className="text-tertiary font-mono break-all">{getTokenParts()?.payload}</span>
</div>
<div className="flex items-center gap-2">
<span className={`${styles.tokenPart} ${styles.signaturePart}`}>{t('tools.jwt_decoder.signature')}</span>
<span className="text-tertiary font-mono break-all">{getTokenParts()?.signature}</span>
</div>
</>
)}
</div>
</div>
)}
{error && (
<div className={styles.error}>
{error}
</div>
)}
{success && (
<div className={styles.success}>
{success}
</div>
)}
<div className="flex flex-wrap gap-2">
<button className="btn-primary" onClick={loadExample}>
<FontAwesomeIcon icon={faSync} className="mr-2" />
{t('tools.jwt_decoder.load_example')}
</button>
<button className="btn-secondary" onClick={clearAll}>
<FontAwesomeIcon icon={faEraser} className="mr-2" />
{t('tools.jwt_decoder.clear')}
</button>
<button
className="btn-secondary"
onClick={copyFullToken}
disabled={!jwtToken}
>
<FontAwesomeIcon icon={faCopy} className="mr-2" />
{t('tools.jwt_decoder.copy_token')}
</button>
</div>
{decodedJwt && (
<div className="mt-6 space-y-4">
<div className="flex items-center justify-between">
<div className="text-secondary">{t('tools.jwt_decoder.jwt_status')}</div>
<div className={`px-2 py-1 rounded-md text-xs ${decodedJwt.isValid ? 'bg-green-900/10 text-success' : 'bg-red-900/10 text-error'}`}>
{decodedJwt.isValid ? t('tools.jwt_decoder.format_valid') : t('tools.jwt_decoder.format_invalid')}
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-secondary">{t('tools.jwt_decoder.expiration_status')}</div>
<div className={styles.statusBox(decodedJwt.expirationStatus)}>
{getExpirationInfo().text}
</div>
</div>
{decodedJwt.payload.iat && (
<div className="flex items-center justify-between">
<div className="text-secondary">{t('tools.jwt_decoder.issue_time')}</div>
<div className="text-xs text-tertiary">
{new Date(decodedJwt.payload.iat * 1000).toLocaleString()}
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* 右侧面板 */}
<div className="lg:col-span-7">
{decodedJwt ? (
<div className={styles.card}>
<div className="border-b border-purple-glow/30 mb-4">
<div className="flex">
<button
className={styles.tabButton(activeTab === 'header')}
onClick={() => setActiveTab('header')}
>
{t('tools.jwt_decoder.header_tab')}
</button>
<button
className={styles.tabButton(activeTab === 'payload')}
onClick={() => setActiveTab('payload')}
>
{t('tools.jwt_decoder.payload_tab')}
</button>
<button
className={styles.tabButton(activeTab === 'signature')}
onClick={() => setActiveTab('signature')}
>
{t('tools.jwt_decoder.signature_tab')}
</button>
</div>
</div>
<div className="mb-4 flex justify-end">
<button
className={styles.copyButton}
onClick={copyToClipboard}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
{copied ? t('tools.jwt_decoder.copied') : t('tools.jwt_decoder.copy')}
</button>
</div>
<pre className={styles.preArea}>
{activeTab === 'header' && formatJson(decodedJwt.header)}
{activeTab === 'payload' && formatJson(decodedJwt.payload)}
{activeTab === 'signature' && decodedJwt.signature}
</pre>
</div>
) : (
<div className={styles.card}>
<h3 className={styles.heading}>{t('tools.jwt_decoder.what_is_jwt')}</h3>
<div className="flex items-center gap-2 mb-4">
<FontAwesomeIcon icon={faInfoCircle} className="text-purple" />
<span className="text-secondary">{t('tools.jwt_decoder.jwt_full_name')}</span>
</div>
<p className={styles.secondaryText}>
{t('tools.jwt_decoder.jwt_intro')}
</p>
<p className={styles.secondaryText}>
{t('tools.jwt_decoder.jwt_parts')}
</p>
<ul className="list-disc pl-5 text-sm text-tertiary">
<li><span className={styles.highlight}>Header</span> - {t('tools.jwt_decoder.header_desc')}</li>
<li><span className={styles.highlight}>Payload</span> - {t('tools.jwt_decoder.payload_desc')}</li>
<li><span className={styles.highlight}>Signature</span> - {t('tools.jwt_decoder.signature_desc')}</li>
</ul>
<p className={styles.secondaryText}>
{t('tools.jwt_decoder.tool_purpose')}
</p>
<div className="mt-4 p-3 bg-block-strong rounded-lg">
<h4 className="text-sm font-medium text-secondary mb-2">{t('tools.jwt_decoder.common_use_cases')}</h4>
<ul className="list-disc pl-5 text-sm text-tertiary">
<li>{t('tools.jwt_decoder.use_case_1')}</li>
<li>{t('tools.jwt_decoder.use_case_2')}</li>
<li>{t('tools.jwt_decoder.use_case_3')}</li>
<li>{t('tools.jwt_decoder.use_case_4')}</li>
<li>{t('tools.jwt_decoder.use_case_5')}</li>
</ul>
</div>
</div>
)}
</div>
</div>
<BackToTop />
</div>
);
}
+514
View File
@@ -0,0 +1,514 @@
'use client';
import React, { useState, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faImage, faCopy, faCheck, faDownload, faTrash, faRedo } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import { QRCode } from 'react-qrcode-logo';
import { useLanguage } from '@/context/LanguageContext';
// 定义QR码样式类型
type QRStyle = 'squares' | 'dots';
// 定义颜色预设
const colorPresets = [
{ bg: '#FFFFFF', fg: '#000000', name: 'preset_classic_bw' },
{ bg: '#0088CC', fg: '#FFFFFF', name: 'preset_blue_white' },
{ bg: '#EF4444', fg: '#FFFFFF', name: 'preset_vibrant_red' },
{ bg: '#10B981', fg: '#FFFFFF', name: 'preset_fresh_green' },
{ bg: '#6366F1', fg: '#FFFFFF', name: 'preset_tech_purple' },
{ bg: '#262626', fg: '#F5F5F5', name: 'preset_dark_mode' },
{ bg: '#FFFFFF', fg: '#F97316', name: 'preset_orange_accent' },
{ bg: '#FFEDD5', fg: '#7C2D12', name: 'preset_warm_brown' },
];
// 添加CSS变量样式
const styles = {
card: "card p-4",
heading: "text-md font-medium text-primary mb-4",
label: "block text-sm text-secondary mb-2",
input: "search-input w-full",
rangeValue: "text-sm text-primary min-w-[40px] text-right",
buttonActive: "px-4 py-2 rounded-md transition-all bg-gradient-to-r from-[rgb(var(--color-primary))] to-[rgb(var(--color-primary-hover))] text-white shadow-sm shadow-[rgba(var(--color-primary),0.3)]",
buttonInactive: "px-4 py-2 rounded-md transition-all btn-secondary",
presetButton: "p-2 rounded-md border transition-all hover:border-purple",
colorBox: "w-5 h-5 rounded-sm border border-purple-glow/30",
secondaryText: "text-sm text-tertiary",
flexCenter: "flex items-center justify-center",
preview: "rounded-xl overflow-hidden border border-purple-glow/20 bg-block-strong p-2",
placeholder: "flex items-center justify-center p-4 text-tertiary",
}
export default function QRCodeGenerator() {
const { t } = useLanguage();
// QR码内容和样式状态
const [value, setValue] = useState('https://example.com');
const [size, setSize] = useState(200);
const [bgColor, setBgColor] = useState('#FFFFFF');
const [fgColor, setFgColor] = useState('#000000');
const [quietZone, setQuietZone] = useState(10);
const [qrStyle, setQrStyle] = useState<QRStyle>('squares');
// Logo相关状态
const [logoImage, setLogoImage] = useState<string | null>(null);
const [logoWidth, setLogoWidth] = useState(60);
const [logoHeight, setLogoHeight] = useState(60);
const [logoOpacity, setLogoOpacity] = useState(1);
const [removeQrCodeBehindLogo, setRemoveQrCodeBehindLogo] = useState(true);
// 眼睛(定位图案)相关状态
const [eyeColor, setEyeColor] = useState('#000000');
const [eyeRadius, setEyeRadius] = useState(0);
// 其他状态
const [copied, setCopied] = useState(false);
const [downloading, setDownloading] = useState(false);
// QR码引用
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const qrCodeRef = useRef<any>(null);
// 文件上传引用
const fileInputRef = useRef<HTMLInputElement>(null);
// 处理Logo图片上传
const handleLogoUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result as string;
setLogoImage(result);
};
reader.readAsDataURL(file);
}
};
// 触发文件上传点击
const triggerLogoUpload = () => {
fileInputRef.current?.click();
};
// 移除Logo
const removeLogo = () => {
setLogoImage(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
// 复制QR码内容
const copyQRValue = () => {
navigator.clipboard.writeText(value)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => console.error(t('tools.qrcode_generator.copy_failed'), err));
};
// 下载QR码图片
const downloadQRCode = () => {
if (qrCodeRef.current) {
setDownloading(true);
try {
qrCodeRef.current.download('png', '二维码');
} catch (err) {
console.error(t('tools.qrcode_generator.download_failed'), err);
} finally {
setDownloading(false);
}
}
};
// 应用颜色预设
const applyColorPreset = (preset: { bg: string; fg: string }) => {
setBgColor(preset.bg);
setFgColor(preset.fg);
setEyeColor(preset.fg);
};
// 重置所有设置
const resetSettings = () => {
setValue('https://example.com');
setSize(200);
setBgColor('#FFFFFF');
setFgColor('#000000');
setQuietZone(10);
setQrStyle('squares');
setLogoImage(null);
setLogoWidth(60);
setLogoHeight(60);
setLogoOpacity(1);
setRemoveQrCodeBehindLogo(true);
setEyeColor('#000000');
setEyeRadius(0);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
<ToolHeader
toolCode="qrcode_generator"
icon={faImage}
title=""
description=""
/>
{/* 主内容区 */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* 左侧面板 - 设置选项 */}
<div className="lg:col-span-2 space-y-6">
{/* 基本设置 */}
<div className={styles.card}>
<h2 className={styles.heading}>{t('tools.qrcode_generator.basic_settings')}</h2>
<div className="space-y-4">
<div>
<label className={styles.label}>{t('tools.qrcode_generator.qrcode_content')}</label>
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={t('tools.qrcode_generator.input_placeholder')}
className="search-input min-h-[80px] w-full resize-y"
/>
</div>
<div>
<label className={styles.label}>{t('tools.qrcode_generator.size_pixels')}</label>
<div className="flex items-center gap-4">
<input
type="range"
min="100"
max="400"
step="10"
value={size}
onChange={(e) => setSize(Number(e.target.value))}
className="w-full"
/>
<span className={styles.rangeValue}>{size}</span>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.qrcode_generator.margin_pixels')}</label>
<div className="flex items-center gap-4">
<input
type="range"
min="0"
max="50"
step="5"
value={quietZone}
onChange={(e) => setQuietZone(Number(e.target.value))}
className="w-full"
/>
<span className={styles.rangeValue}>{quietZone}</span>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.qrcode_generator.dot_style')}</label>
<div className="flex gap-4">
<button
className={qrStyle === 'squares' ? styles.buttonActive : styles.buttonInactive}
onClick={() => setQrStyle('squares')}
>
{t('tools.qrcode_generator.squares')}
</button>
<button
className={qrStyle === 'dots' ? styles.buttonActive : styles.buttonInactive}
onClick={() => setQrStyle('dots')}
>
{t('tools.qrcode_generator.dots')}
</button>
</div>
</div>
</div>
</div>
{/* 颜色设置 */}
<div className={styles.card}>
<h2 className={styles.heading}>{t('tools.qrcode_generator.color_settings')}</h2>
<div className="space-y-4">
<div>
<label className={styles.label}>{t('tools.qrcode_generator.background_color')}</label>
<div className="flex items-center gap-2">
<input
type="color"
value={bgColor}
onChange={(e) => setBgColor(e.target.value)}
className="w-10 h-10 rounded cursor-pointer border-none"
/>
<input
type="text"
value={bgColor}
onChange={(e) => setBgColor(e.target.value)}
className="search-input"
/>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.qrcode_generator.foreground_color')}</label>
<div className="flex items-center gap-2">
<input
type="color"
value={fgColor}
onChange={(e) => setFgColor(e.target.value)}
className="w-10 h-10 rounded cursor-pointer border-none"
/>
<input
type="text"
value={fgColor}
onChange={(e) => setFgColor(e.target.value)}
className="search-input"
/>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.qrcode_generator.eye_color')}</label>
<div className="flex items-center gap-2">
<input
type="color"
value={eyeColor}
onChange={(e) => setEyeColor(e.target.value)}
className="w-10 h-10 rounded cursor-pointer border-none"
/>
<input
type="text"
value={eyeColor}
onChange={(e) => setEyeColor(e.target.value)}
className="search-input"
/>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.qrcode_generator.eye_radius')}</label>
<div className="flex items-center gap-4">
<input
type="range"
min="0"
max="50"
step="5"
value={eyeRadius}
onChange={(e) => setEyeRadius(Number(e.target.value))}
className="w-full"
/>
<span className={styles.rangeValue}>{eyeRadius}%</span>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.qrcode_generator.preset_colors')}</label>
<div className="grid grid-cols-4 gap-2 mt-2">
{colorPresets.map((preset, index) => (
<button
key={index}
className={styles.presetButton}
onClick={() => applyColorPreset(preset)}
title={t(`tools.qrcode_generator.${preset.name}`)}
>
<div className="flex flex-col items-center">
<div
className={styles.colorBox}
style={{ backgroundColor: preset.fg, borderColor: preset.bg }}
></div>
<div className="text-xs mt-1 truncate w-full text-center">{t(`tools.qrcode_generator.${preset.name}`)}</div>
</div>
</button>
))}
</div>
</div>
</div>
</div>
{/* Logo设置 */}
<div className={styles.card}>
<h2 className={styles.heading}>{t('tools.qrcode_generator.logo_settings')}</h2>
<div className="space-y-4">
<div>
<input
type="file"
ref={fileInputRef}
accept="image/*"
onChange={handleLogoUpload}
style={{ display: 'none' }}
/>
<div className="flex justify-between items-center mb-2">
<button
className="btn-primary px-3 py-2 text-sm"
onClick={triggerLogoUpload}
>
<FontAwesomeIcon icon={faImage} className="mr-2" />
{t('tools.qrcode_generator.upload_logo')}
</button>
{logoImage && (
<button
className="btn-secondary px-3 py-2 text-sm text-error"
onClick={removeLogo}
>
<FontAwesomeIcon icon={faTrash} className="mr-2" />
{t('tools.qrcode_generator.remove')}
</button>
)}
</div>
{logoImage && (
<div className="rounded bg-block-strong p-3 mb-4 flex justify-center items-center">
<img src={logoImage} alt="Logo" className="max-h-20 max-w-full" />
</div>
)}
</div>
{logoImage && (
<>
<div>
<label className={styles.label}>{t('tools.qrcode_generator.logo_width')}</label>
<div className="flex items-center gap-4">
<input
type="range"
min="20"
max="150"
value={logoWidth}
onChange={(e) => setLogoWidth(Number(e.target.value))}
className="w-full"
/>
<span className={styles.rangeValue}>{logoWidth}</span>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.qrcode_generator.logo_height')}</label>
<div className="flex items-center gap-4">
<input
type="range"
min="20"
max="150"
value={logoHeight}
onChange={(e) => setLogoHeight(Number(e.target.value))}
className="w-full"
/>
<span className={styles.rangeValue}>{logoHeight}</span>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.qrcode_generator.logo_opacity')}</label>
<div className="flex items-center gap-4">
<input
type="range"
min="0"
max="1"
step="0.1"
value={logoOpacity}
onChange={(e) => setLogoOpacity(Number(e.target.value))}
className="w-full"
/>
<span className={styles.rangeValue}>{Math.round(logoOpacity * 100)}%</span>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="removeQrCodeBehindLogo"
checked={removeQrCodeBehindLogo}
onChange={(e) => setRemoveQrCodeBehindLogo(e.target.checked)}
className="form-checkbox"
/>
<label htmlFor="removeQrCodeBehindLogo" className="text-sm cursor-pointer">
{t('tools.qrcode_generator.remove_code_behind_logo')}
</label>
</div>
</>
)}
</div>
</div>
</div>
{/* 右侧面板 - 预览和说明 */}
<div className="lg:col-span-2 space-y-6">
<div className={styles.card}>
<div className="flex justify-between items-center mb-4">
<h2 className={styles.heading}>{t('tools.qrcode_generator.preview')}</h2>
<div className="flex space-x-2">
<button
onClick={copyQRValue}
className="text-tertiary hover:text-purple transition-colors"
title={t('tools.qrcode_generator.copy_content')}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
</button>
<button
onClick={downloadQRCode}
className="text-tertiary hover:text-purple transition-colors"
title={t('tools.qrcode_generator.download_qrcode')}
disabled={downloading}
>
<FontAwesomeIcon icon={faDownload} className={downloading ? 'animate-pulse' : ''} />
</button>
<button
onClick={resetSettings}
title={t('tools.qrcode_generator.reset_settings')}
className="text-tertiary hover:text-purple transition-colors"
>
<FontAwesomeIcon icon={faRedo} />
</button>
</div>
</div>
<div className={styles.preview}>
{value ? (
<div className={styles.flexCenter}>
<QRCode
ref={qrCodeRef}
value={value}
size={size}
quietZone={quietZone}
bgColor={bgColor}
fgColor={fgColor}
logoImage={logoImage || undefined}
logoWidth={logoWidth}
logoHeight={logoHeight}
logoOpacity={logoOpacity}
removeQrCodeBehindLogo={removeQrCodeBehindLogo}
eyeColor={eyeColor}
eyeRadius={eyeRadius}
qrStyle={qrStyle}
logoPadding={0}
ecLevel="H"
/>
</div>
) : (
<div className={styles.placeholder}>{t('tools.qrcode_generator.please_input_content')}</div>
)}
</div>
</div>
<div className={styles.card}>
<h2 className={styles.heading}>{t('tools.qrcode_generator.instructions')}</h2>
<div className="space-y-2 text-secondary text-sm">
<p>{t('tools.qrcode_generator.instruction_1')}</p>
<p>{t('tools.qrcode_generator.instruction_2')}</p>
<p>{t('tools.qrcode_generator.instruction_3')}</p>
<p>{t('tools.qrcode_generator.instruction_4')}</p>
</div>
<p className={styles.secondaryText}>{t('tools.qrcode_generator.note')}</p>
</div>
</div>
</div>
</div>
);
}
+426
View File
@@ -0,0 +1,426 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faKey, faCopy, faCheck, faExclamationTriangle, faInfoCircle, faTrash } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
card: "card p-4",
input: "search-input w-full",
secondaryBtn: "btn-secondary",
primaryText: "text-primary",
secondaryText: "text-secondary",
tertiaryText: "text-tertiary",
block: "bg-block",
codeBlock: "bg-block px-1 rounded",
iconButton: "text-tertiary hover:text-purple transition-colors",
selectedFlag: "bg-purple-glow/20 text-purple",
buttonActive: "px-3 py-1 text-xs rounded-md transition-all bg-gradient-to-r from-[rgb(var(--color-primary))] to-[rgb(var(--color-primary-hover))] text-white shadow-sm shadow-[rgba(var(--color-primary),0.3)]",
buttonInactive: "px-3 py-1 text-xs rounded-md transition-all btn-secondary",
error: "text-error",
highlightBg: "bg-purple-500/30 text-white font-medium",
}
// 定义匹配项类型
type MatchResult = RegExpExecArray;
type MatchResultArray = MatchResult[];
export default function RegexTester() {
const { t } = useLanguage();
// 正则表达式输入
const [regexString, setRegexString] = useState('');
const [flags, setFlags] = useState('g');
const [testString, setTestString] = useState('');
// 测试结果
const [matches, setMatches] = useState<MatchResultArray>([]);
const [matchCount, setMatchCount] = useState(0);
// 高级选项
const [showGroups, setShowGroups] = useState(true);
const [regexError, setRegexError] = useState<string | null>(null);
// 复制状态
const [copiedRegex, setCopiedRegex] = useState(false);
// 安全处理HTML转义
const escapeHtml = (text: string) => {
if (text === undefined || text === null) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
// 常用正则表达式示例
const examples = [
{ name: t('tools.regex_tester.examples.email'), pattern: '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', flags: 'g', testText: '[email protected], invalid-email, [email protected]' },
{ name: t('tools.regex_tester.examples.phone'), pattern: '1[3-9]\\d{9}', flags: 'g', testText: t('tools.regex_tester.example_texts.phone') },
{ name: t('tools.regex_tester.examples.url'), pattern: 'https?://[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&//=]*)', flags: 'g', testText: t('tools.regex_tester.example_texts.url') },
{ name: t('tools.regex_tester.examples.ip'), pattern: '\\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\b', flags: 'g', testText: t('tools.regex_tester.example_texts.ip') },
{ name: t('tools.regex_tester.examples.chinese'), pattern: '[\\u4e00-\\u9fa5]', flags: 'g', testText: t('tools.regex_tester.example_texts.chinese') },
];
// 当输入改变时更新测试结果
useEffect(() => {
testRegex();
}, [regexString, flags, testString, showGroups]);
// 测试正则表达式
const testRegex = () => {
if (!regexString || !testString) {
setMatches([]);
setMatchCount(0);
setRegexError(null);
return;
}
try {
// 验证正则表达式是否有效
new RegExp(regexString, flags);
setRegexError(null);
if (flags.includes('g')) {
// 获取所有匹配
const allMatches: MatchResultArray = [];
let match: RegExpExecArray | null;
const regexWithGroups = new RegExp(regexString, flags);
// 收集所有匹配和捕获组
while ((match = regexWithGroups.exec(testString)) !== null) {
allMatches.push(match);
// 防止无限循环,如果匹配长度为0,手动增加索引
if (match.index === regexWithGroups.lastIndex) {
regexWithGroups.lastIndex++;
}
}
setMatches(allMatches);
setMatchCount(allMatches.length);
} else {
// 单次匹配模式
const regexWithoutG = new RegExp(regexString, flags.replace('g', ''));
const execMatch = regexWithoutG.exec(testString);
if (execMatch) {
setMatches([execMatch]);
setMatchCount(1);
} else {
setMatches([]);
setMatchCount(0);
}
}
} catch (error) {
console.error(t('tools.regex_tester.regex_error'), error);
setRegexError((error as Error).message);
setMatches([]);
setMatchCount(0);
}
};
// 复制正则表达式
const copyRegex = () => {
const regexText = `/${regexString}/${flags}`;
navigator.clipboard.writeText(regexText)
.then(() => {
setCopiedRegex(true);
setTimeout(() => setCopiedRegex(false), 2000);
})
.catch(err => console.error(t('tools.regex_tester.copy_failed'), err));
};
// 应用示例
const applyExample = (example: { pattern: string; flags: string; testText: string }) => {
setRegexString(example.pattern);
setFlags(example.flags);
setTestString(example.testText);
};
// 清空所有内容
const clearAll = () => {
setRegexString('');
setFlags('g');
setTestString('');
setMatches([]);
setMatchCount(0);
setRegexError(null);
};
// 切换标志位
const toggleFlag = (flag: string) => {
if (flags.includes(flag)) {
setFlags(flags.replace(flag, ''));
} else {
setFlags(flags + flag);
}
};
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
<ToolHeader
toolCode="regex_tester"
title=""
description=""
icon={faKey}
/>
{/* 主内容区 */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* 左侧面板 - 正则表达式输入和选项 */}
<div className="lg:col-span-1 space-y-6">
{/* 常用示例 */}
<div className={styles.card}>
<h2 className="text-md font-medium text-primary mb-4">{t('tools.regex_tester.examples.title')}</h2>
<div className="space-y-2">
{examples.map((example, index) => (
<button
key={index}
className="text-left w-full px-3 py-2 rounded-md text-sm text-secondary hover:bg-block-hover transition-colors"
onClick={() => applyExample(example)}
>
{example.name}
</button>
))}
</div>
</div>
{/* 正则选项 */}
<div className={styles.card}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-md font-medium text-primary">{t('tools.regex_tester.options')}</h2>
<button
className="text-tertiary hover:text-error transition-colors"
onClick={clearAll}
>
<FontAwesomeIcon icon={faTrash} className="mr-1" />
{t('tools.regex_tester.clear')}
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm text-secondary mb-2">{t('tools.regex_tester.flags')}</label>
<div className="flex flex-wrap gap-2">
<button
className={flags.includes('g') ? styles.buttonActive : styles.buttonInactive}
onClick={() => toggleFlag('g')}
>
g ({t('tools.regex_tester.flag_descriptions.global')})
</button>
<button
className={flags.includes('i') ? styles.buttonActive : styles.buttonInactive}
onClick={() => toggleFlag('i')}
>
i ({t('tools.regex_tester.flag_descriptions.case_insensitive')})
</button>
<button
className={flags.includes('m') ? styles.buttonActive : styles.buttonInactive}
onClick={() => toggleFlag('m')}
>
m ({t('tools.regex_tester.flag_descriptions.multiline')})
</button>
<button
className={flags.includes('s') ? styles.buttonActive : styles.buttonInactive}
onClick={() => toggleFlag('s')}
>
s ({t('tools.regex_tester.flag_descriptions.dotall')})
</button>
</div>
</div>
<div>
<label className="flex items-center text-sm text-secondary">
<input
type="checkbox"
checked={showGroups}
onChange={() => setShowGroups(!showGroups)}
className="mr-2"
/>
{t('tools.regex_tester.show_capture_groups')}
</label>
</div>
</div>
</div>
</div>
{/* 右侧面板 - 测试区域 */}
<div className="lg:col-span-3 space-y-6">
{/* 正则表达式输入 */}
<div className={styles.card}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-md font-medium text-primary">{t('tools.regex_tester.regex_expression')}</h2>
<button
className={styles.iconButton}
onClick={copyRegex}
disabled={!regexString}
>
<FontAwesomeIcon icon={copiedRegex ? faCheck : faCopy} className="mr-1" />
{copiedRegex ? t('common.copySuccess') : t('tools.regex_tester.copy')}
</button>
</div>
<div className="relative">
<div className="absolute left-3 top-[13px] text-tertiary">/</div>
<input
type="text"
value={regexString}
onChange={(e) => setRegexString(e.target.value)}
placeholder={t('tools.regex_tester.enter_regex')}
className="search-input pl-7 pr-14"
/>
<div className="absolute right-14 top-[13px] text-tertiary">/</div>
<input
type="text"
value={flags}
onChange={(e) => setFlags(e.target.value)}
placeholder="flags"
className="absolute right-3 top-[13px] w-8 bg-transparent border-none outline-none text-tertiary"
/>
</div>
{regexError && (
<div className="mt-2 text-sm text-error flex items-center gap-2">
<FontAwesomeIcon icon={faExclamationTriangle} />
<span>{regexError}</span>
</div>
)}
</div>
{/* 测试输入 */}
<div className={styles.card}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-md font-medium text-primary">{t('tools.regex_tester.test_text')}</h2>
<div className="text-sm text-secondary">
{t('tools.regex_tester.character_count')}: <span className="text-purple">{testString.length}</span>
</div>
</div>
<textarea
value={testString}
onChange={(e) => setTestString(e.target.value)}
placeholder={t('tools.regex_tester.enter_test_text')}
className="search-input min-h-[150px] w-full resize-y"
/>
</div>
{/* 匹配结果 */}
<div className={styles.card}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-md font-medium text-primary">{t('tools.regex_tester.match_results')}</h2>
<div className="text-sm text-secondary">
{t('tools.regex_tester.match_count')}: <span className="text-purple">{matchCount}</span>
</div>
</div>
{testString && (
<div className="space-y-4">
{/* 高亮显示的匹配文本 */}
<div className="bg-block-strong rounded-md p-4 whitespace-pre-wrap font-mono text-sm">
{testString && (
<div className="match-results">
{matchCount > 0 ? (
<>
<div className="mb-3 text-tertiary text-xs flex items-center justify-between">
<span>
{t('tools.regex_tester.found')} <span className="text-purple font-medium">{matchCount}</span> {t('tools.regex_tester.matches')}
</span>
<span className="text-tertiary text-xs">
{t('tools.regex_tester.original_text_length')}: {testString.length} {t('tools.regex_tester.result_characters')}
</span>
</div>
{(() => {
// 创建包含高亮的全文显示
let result = '';
let lastIndex = 0;
// 按索引顺序排序匹配项
const sortedMatches = [...matches].sort((a, b) => a.index - b.index);
// 遍历每个匹配项
sortedMatches.forEach(match => {
// 添加匹配前的文本
result += escapeHtml(testString.substring(lastIndex, match.index));
// 添加高亮的匹配内容
result += `<span style="background-color:rgba(139, 92, 246, 0.5); color:white; font-weight:bold; padding:0 4px; border-radius:3px;">${escapeHtml(match[0])}</span>`;
// 更新lastIndex
lastIndex = match.index + match[0].length;
});
// 添加最后一个匹配后的文本
if (lastIndex < testString.length) {
result += escapeHtml(testString.substring(lastIndex));
}
return <div dangerouslySetInnerHTML={{ __html: result }} />;
})()}
</>
) : (
<span className="text-tertiary">{t('tools.regex_tester.no_matches')}</span>
)}
</div>
)}
{!testString && (
<span className="text-tertiary">{t('tools.regex_tester.no_matches')}</span>
)}
</div>
{/* 捕获组详情 */}
{showGroups && matchCount > 0 && (
<div>
<h3 className="text-sm font-medium text-primary mb-2">{t('tools.regex_tester.capture_groups')}</h3>
<div className="space-y-2">
{Array.isArray(matches) && matches.map((match, index) => (
<div key={index} className="bg-block-strong rounded-md p-3">
<div className="text-xs text-tertiary mb-2">
{t('tools.regex_tester.match')} #{index + 1} ({t('tools.regex_tester.position')}: {match.index})
</div>
<div className="space-y-1">
{match.length > 0 && (
<div className="flex items-start gap-2">
<span className="text-xs text-tertiary min-w-[40px]">{t('tools.regex_tester.full')}:</span>
<code className="text-sm text-primary bg-purple-glow/10 px-1 rounded break-all">
{escapeHtml(match[0] || '')}
</code>
</div>
)}
{match.length > 1 && Array.from({ length: match.length - 1 }, (_, i) => i + 1).map(group => (
<div key={group} className="flex items-start gap-2">
<span className="text-xs text-tertiary min-w-[40px]">{t('tools.regex_tester.group')} {group}:</span>
<code className="text-sm text-primary bg-purple-glow/10 px-1 rounded break-all">
{match[group] ? escapeHtml(match[group]) : t('tools.regex_tester.empty')}
</code>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{!testString && (
<div className="flex items-center justify-center p-4 text-tertiary">
<FontAwesomeIcon icon={faInfoCircle} className="mr-2" />
{t('tools.regex_tester.enter_text_prompt')}
</div>
)}
</div>
</div>
</div>
</div>
);
}
+282
View File
@@ -0,0 +1,282 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalculator, faCopy, faCheck, faTrash, faInfoCircle } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
card: "card p-6",
input: "search-input w-full",
textarea: "w-full p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-[#6366F1] focus:outline-none focus:ring-1 focus:ring-[#6366F1] transition-all resize-none",
label: "text-secondary font-medium",
secondaryText: "text-sm text-tertiary",
resultItem: "flex justify-between items-center py-2 border-b border-purple-glow/10",
resultLabel: "text-sm text-secondary",
resultValue: "text-sm text-primary font-semibold",
iconButton: "text-tertiary hover:text-purple transition-colors",
button: "text-left w-full px-3 py-2 rounded-md text-sm text-secondary hover:bg-block-hover transition-colors",
}
// 字数统计结果类型
interface CountResult {
characters: number;
charactersNoSpaces: number;
words: number;
chineseWords: number;
englishWords: number;
sentences: number;
paragraphs: number;
lines: number;
chineseCharacters: number;
}
export default function TextCounter() {
const { t } = useLanguage();
// 输入文本
const [text, setText] = useState('');
// 统计结果
const [counts, setCounts] = useState<CountResult>({
characters: 0,
charactersNoSpaces: 0,
words: 0,
chineseWords: 0,
englishWords: 0,
sentences: 0,
paragraphs: 0,
lines: 0,
chineseCharacters: 0
});
// 复制状态
const [copied, setCopied] = useState(false);
// 文本变化时更新统计结果
useEffect(() => {
countText(text);
}, [text]);
// 统计文本
const countText = (value: string) => {
if (!value) {
setCounts({
characters: 0,
charactersNoSpaces: 0,
words: 0,
chineseWords: 0,
englishWords: 0,
sentences: 0,
paragraphs: 0,
lines: 0,
chineseCharacters: 0
});
return;
}
// 总字符数
const characters = value.length;
// 不含空格的字符数
const charactersNoSpaces = value.replace(/\s/g, '').length;
// 中文字符数
const chineseCharacters = (value.match(/[\u4e00-\u9fa5]/g) || []).length;
// 英文单词数
const englishWords = value.match(/[a-zA-Z]+/g)?.length || 0;
// 中文词数 (根据标点符号和空格分隔)
const chineseText = value.match(/[\u4e00-\u9fa5]+/g)?.join('') || '';
// 中文大约每2个字一个词
const chineseWords = Math.ceil(chineseText.length / 2);
// 总词数 (简单估算)
const words = englishWords + chineseWords;
// 句子数 (根据句号、问号、感叹号统计)
const sentences = (value.match(/[.!?。!?]+/g) || []).length || (value.length > 0 ? 1 : 0);
// 段落数 (根据空行分隔)
const paragraphs = value.split(/\n\s*\n/).filter(Boolean).length || (value.length > 0 ? 1 : 0);
// 行数
const lines = value.split('\n').length;
setCounts({
characters,
charactersNoSpaces,
words,
chineseWords,
englishWords,
sentences,
paragraphs,
lines,
chineseCharacters
});
};
// 复制统计结果
const copyResults = () => {
const resultText = t('tools.text_counter.copy_result_text')
.replace('{characters}', counts.characters.toString())
.replace('{charactersNoSpaces}', counts.charactersNoSpaces.toString())
.replace('{chineseCharacters}', counts.chineseCharacters.toString())
.replace('{words}', counts.words.toString())
.replace('{chineseWords}', counts.chineseWords.toString())
.replace('{englishWords}', counts.englishWords.toString())
.replace('{sentences}', counts.sentences.toString())
.replace('{paragraphs}', counts.paragraphs.toString())
.replace('{lines}', counts.lines.toString());
navigator.clipboard.writeText(resultText)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => console.error(t('tools.text_counter.copy_failed'), err));
};
// 清空输入
const clearText = () => {
setText('');
};
// 加载示例文本
const loadExample = (type: 'chinese' | 'english') => {
if (type === 'chinese') {
setText('这是一个中文文本示例。\n\n这是第二段落,包含了一些中文内容。这个工具可以统计文本中的字符数、词数和其他信息。\n\n第三段落结束。');
} else {
setText('This is an English text example.\n\nThis is the second paragraph, containing English content. This tool can count characters, words, and other information in the text.\n\nThe third paragraph ends here.');
}
};
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
<ToolHeader
toolCode="text_counter"
icon={faCalculator}
title=""
description=""
/>
{/* 主内容区 */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-6">
{/* 统计结果 */}
<div className="md:col-span-4 space-y-6 order-2 md:order-1">
<div className={styles.card}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-md font-medium text-primary">{t('tools.text_counter.statistics_results')}</h2>
<button
className={styles.iconButton}
onClick={copyResults}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} className="mr-1" />
{copied ? t('tools.text_counter.copied') : t('tools.text_counter.copy_results')}
</button>
</div>
<div className="space-y-1">
{/* 字符统计 */}
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.text_counter.statistics.total_characters')}</span>
<span className={styles.resultValue}>{counts.characters}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.text_counter.statistics.characters_no_spaces')}</span>
<span className={styles.resultValue}>{counts.charactersNoSpaces}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.text_counter.statistics.chinese_characters')}</span>
<span className={styles.resultValue}>{counts.chineseCharacters}</span>
</div>
{/* 单词统计 */}
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.text_counter.statistics.total_words')}</span>
<span className={styles.resultValue}>{counts.words}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.text_counter.statistics.chinese_words')}</span>
<span className={styles.resultValue}>{counts.chineseWords}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.text_counter.statistics.english_words')}</span>
<span className={styles.resultValue}>{counts.englishWords}</span>
</div>
{/* 其他统计 */}
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.text_counter.statistics.sentences')}</span>
<span className={styles.resultValue}>{counts.sentences}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.text_counter.statistics.paragraphs')}</span>
<span className={styles.resultValue}>{counts.paragraphs}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.text_counter.statistics.lines')}</span>
<span className={styles.resultValue}>{counts.lines}</span>
</div>
</div>
</div>
<div className={styles.card}>
<h2 className="text-md font-medium text-primary mb-4">{t('tools.text_counter.tool_options')}</h2>
<div className="space-y-2">
<button
className={styles.button}
onClick={() => loadExample('chinese')}
>
{t('tools.text_counter.load_chinese_example')}
</button>
<button
className={styles.button}
onClick={() => loadExample('english')}
>
{t('tools.text_counter.load_english_example')}
</button>
</div>
</div>
</div>
{/* 文本输入 */}
<div className="md:col-span-8 order-1 md:order-2">
<div className={styles.card}>
<h2 className="text-md font-medium text-primary mb-4">{t('tools.text_counter.input_text')}</h2>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={t('tools.text_counter.input_placeholder')}
className={styles.textarea}
rows={20}
/>
<div className="flex justify-end mt-4">
<button
className="btn-secondary"
onClick={clearText}
>
<FontAwesomeIcon icon={faTrash} className="mr-2" />
{t('tools.text_counter.clear')}
</button>
</div>
</div>
{!text && (
<div className="flex items-center justify-center p-4 mt-4 text-tertiary">
<FontAwesomeIcon icon={faInfoCircle} className="mr-2" />
{t('tools.text_counter.empty_notice')}
</div>
)}
</div>
</div>
</div>
);
}
+360
View File
@@ -0,0 +1,360 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faClock, faExchangeAlt, faSync, faCopy, faCheck } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
card: "card p-6",
input: "search-input w-full",
secondaryBtn: "btn-secondary text-xs px-3 py-1",
primaryText: "text-primary",
secondaryText: "text-secondary",
tertiaryText: "text-tertiary",
formattedBlock: "p-2 bg-block rounded-md border border-purple-glow w-full",
codeBlock: "bg-block px-1 rounded",
iconButton: "text-tertiary hover:text-purple transition-colors",
swapBtn: "text-tertiary hover:text-purple transition-colors p-3 rounded-full hover:bg-purple-glow/10",
}
export default function TimestampConverter() {
const { t } = useLanguage();
// 当前系统时间
const [currentTime, setCurrentTime] = useState(new Date());
// 时间戳和日期时间的状态
const [timestamp, setTimestamp] = useState('');
const [dateTime, setDateTime] = useState('');
const [formattedDateTime, setFormattedDateTime] = useState('');
// 是否交换位置
const [swapped, setSwapped] = useState(false);
// 常用时间格式列表
const [commonTimestamps, setCommonTimestamps] = useState<{ label: string; value: number }[]>([]);
// 复制状态
const [copiedTimestamp, setCopiedTimestamp] = useState(false);
const [copiedDateTime, setCopiedDateTime] = useState(false);
// 添加初始化标记
const [isInitialized, setIsInitialized] = useState(false);
// 首次加载时初始化数据
useEffect(() => {
if (!isInitialized) {
const now = new Date();
// 只初始化当前时间和常用时间戳列表,不设置输入框的初始值
setCurrentTime(now);
updateCommonTimestamps(now);
// 标记为已初始化
setIsInitialized(true);
}
}, []);
// 设置定时器每秒更新当前时间
useEffect(() => {
// 启动定时器每秒更新当前时间
const timer = setInterval(updateCurrentTime, 1000);
// 清理函数
return () => clearInterval(timer);
}, []);
// 更新当前时间和相关值
const updateCurrentTime = () => {
const now = new Date();
setCurrentTime(now);
// 只更新常用时间戳列表,不修改用户输入
updateCommonTimestamps(now);
};
// 更新常用时间戳列表
const updateCommonTimestamps = (date: Date) => {
const nowTs = Math.floor(date.getTime() / 1000);
const commonTs = [
{ label: t('tools.timestamp_converter.current_time'), value: nowTs },
{ label: t('tools.timestamp_converter.today_zero'), value: Math.floor(new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000) },
{ label: t('tools.timestamp_converter.this_monday'), value: Math.floor(new Date(date.getFullYear(), date.getMonth(), date.getDate() - date.getDay() + 1).getTime() / 1000) },
{ label: t('tools.timestamp_converter.this_month_start'), value: Math.floor(new Date(date.getFullYear(), date.getMonth(), 1).getTime() / 1000) },
{ label: t('tools.timestamp_converter.this_year_start'), value: Math.floor(new Date(date.getFullYear(), 0, 1).getTime() / 1000) },
];
setCommonTimestamps(commonTs);
};
// 时间戳转日期时间
const timestampToDateTime = (ts: string) => {
if (!ts) return;
try {
// 如果时间戳是毫秒级的(13位),转换为秒级
let timestampInSeconds = parseInt(ts);
if (ts.length >= 13) {
timestampInSeconds = Math.floor(timestampInSeconds / 1000);
}
const date = new Date(timestampInSeconds * 1000);
// 检查日期是否有效
if (isNaN(date.getTime())) {
return;
}
// 格式化日期时间
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
setDateTime(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}`);
setFormattedDateTime(`${year}-${month}-${day} ${hours}:${minutes}:${seconds}`);
} catch (error) {
console.error(t('tools.timestamp_converter.timestamp_conversion_error'), error);
}
};
// 日期时间转时间戳
const dateTimeToTimestamp = (dt: string) => {
if (!dt) return;
try {
const date = new Date(dt);
// 检查日期是否有效
if (isNaN(date.getTime())) {
return;
}
const ts = Math.floor(date.getTime() / 1000);
setTimestamp(ts.toString());
} catch (error) {
console.error(t('tools.timestamp_converter.datetime_conversion_error'), error);
}
};
// 处理时间戳输入变化
const handleTimestampChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setTimestamp(value);
timestampToDateTime(value);
};
// 处理日期时间输入变化
const handleDateTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setDateTime(value);
// 更新格式化的日期时间显示
if (value) {
try {
const date = new Date(value);
if (!isNaN(date.getTime())) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
setFormattedDateTime(`${year}-${month}-${day} ${hours}:${minutes}:${seconds}`);
}
} catch (error) {
console.error(t('tools.timestamp_converter.datetime_format_error'), error);
}
}
dateTimeToTimestamp(value);
};
// 刷新计算,使用当前时间
const refreshWithCurrentTime = () => {
const now = new Date();
const currentTimestamp = Math.floor(now.getTime() / 1000);
setTimestamp(currentTimestamp.toString());
timestampToDateTime(currentTimestamp.toString());
};
// 使用常用时间戳
const handleUseCommonTimestamp = (ts: number) => {
const tsStr = ts.toString();
setTimestamp(tsStr);
timestampToDateTime(tsStr);
};
// 复制时间戳到剪贴板
const copyTimestamp = () => {
if (!timestamp) return;
navigator.clipboard.writeText(timestamp)
.then(() => {
setCopiedTimestamp(true);
setTimeout(() => setCopiedTimestamp(false), 2000);
})
.catch(err => console.error(t('tools.timestamp_converter.copy_failed'), err));
};
// 复制日期时间到剪贴板
const copyDateTime = () => {
if (!formattedDateTime) return;
navigator.clipboard.writeText(formattedDateTime)
.then(() => {
setCopiedDateTime(true);
setTimeout(() => setCopiedDateTime(false), 2000);
})
.catch(err => console.error(t('tools.timestamp_converter.copy_failed'), err));
};
// 交换时间戳和日期时间位置
const swapPositions = () => {
// 添加动画过渡效果
const container = document.getElementById('converter-container');
if (container) {
container.classList.add('animate-pulse');
setTimeout(() => {
container.classList.remove('animate-pulse');
}, 500);
}
// 切换位置状态
setSwapped(!swapped);
};
// 渲染时间戳区域
const renderTimestampSection = () => (
<div id="timestamp-section" className="flex flex-col gap-4 transition-all duration-300">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium text-primary">{t('tools.timestamp_converter.timestamp')}</h2>
<div className="flex items-center text-xs text-tertiary">
<span className="mr-1">{t('tools.timestamp_converter.current_time_colon')}</span>
<span>{Math.floor(currentTime.getTime() / 1000)}</span>
</div>
</div>
<div className="relative">
<input
type="text"
value={timestamp}
onChange={handleTimestampChange}
placeholder={t('tools.timestamp_converter.enter_unix_timestamp')}
className={styles.input}
/>
<button
className="absolute right-3 top-1/2 -translate-y-1/2 text-tertiary hover:text-purple transition-colors"
onClick={refreshWithCurrentTime}
title={t('tools.timestamp_converter.use_current_time')}
>
<FontAwesomeIcon icon={faSync} />
</button>
</div>
<div className="text-sm text-tertiary flex gap-2 flex-wrap items-center">
<span className="whitespace-nowrap">{t('tools.timestamp_converter.common_timestamps')}:</span>
<div className="flex flex-wrap gap-2">
{commonTimestamps.map((ts, index) => (
<button
key={index}
className={styles.secondaryBtn}
onClick={() => handleUseCommonTimestamp(ts.value)}
>
{ts.label}
</button>
))}
</div>
</div>
{timestamp && (
<div className="flex items-center justify-between gap-2">
<div className={styles.formattedBlock}>
<code>{timestamp}</code>
</div>
<button
onClick={copyTimestamp}
className={styles.iconButton}
title={t('tools.timestamp_converter.copy_timestamp')}
>
<FontAwesomeIcon icon={copiedTimestamp ? faCheck : faCopy} />
</button>
</div>
)}
</div>
);
// 渲染日期时间区域
const renderDateTimeSection = () => (
<div id="datetime-section" className="flex flex-col gap-4 transition-all duration-300">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium text-primary">{t('tools.timestamp_converter.datetime')}</h2>
</div>
<div className="relative">
<input
type="datetime-local"
value={dateTime}
onChange={handleDateTimeChange}
className={styles.input}
step="1"
/>
</div>
{formattedDateTime && (
<div className="flex items-center justify-between gap-2">
<div className={styles.formattedBlock}>
<code>{formattedDateTime}</code>
</div>
<button
onClick={copyDateTime}
className={styles.iconButton}
title={t('tools.timestamp_converter.copy_datetime')}
>
<FontAwesomeIcon icon={copiedDateTime ? faCheck : faCopy} />
</button>
</div>
)}
</div>
);
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
<ToolHeader
icon={faClock}
toolCode="timestamp_converter"
title=""
description=""
/>
<div id="converter-container" className="relative card p-6">
<div className={`grid gap-6 ${swapped ? 'grid-rows-[auto_auto]' : 'grid-rows-[auto_auto]'}`}>
{swapped ? (
<>
{renderDateTimeSection()}
{renderTimestampSection()}
</>
) : (
<>
{renderTimestampSection()}
{renderDateTimeSection()}
</>
)}
</div>
<button
className={`absolute left-1/2 -translate-x-1/2 ${swapped ? 'top-[calc(50%+1rem)]' : 'top-[calc(50%+1rem)]'} ${styles.swapBtn}`}
onClick={swapPositions}
title={t('tools.timestamp_converter.swap_positions')}
>
<FontAwesomeIcon icon={faExchangeAlt} className={`${swapped ? 'rotate-90' : 'rotate-90'} transition-transform`} />
</button>
</div>
</div>
);
}
+382
View File
@@ -0,0 +1,382 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faClock, faCopy, faCheck } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
card: "card p-6",
input: "search-input w-full",
textarea: "w-full p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-[#6366F1] focus:outline-none focus:ring-1 focus:ring-[#6366F1] transition-all resize-none",
label: "text-secondary font-medium",
secondaryText: "text-sm text-tertiary",
resultItem: "flex justify-between items-center py-2 border-b border-purple-glow/10",
resultLabel: "text-sm text-secondary",
resultValue: "text-sm text-primary font-semibold",
iconButton: "text-tertiary hover:text-purple transition-colors",
button: "text-left w-full px-3 py-2 rounded-md text-sm text-secondary hover:bg-block-hover transition-colors",
formGroup: "mb-6",
resultBox: "p-2 bg-block rounded border border-purple-glow/30 break-all",
timezoneList: "bg-block-strong rounded-lg p-4 text-xs text-tertiary",
}
// 获取所有时区
const getTimezones = (): string[] => {
return [
'UTC',
'America/New_York', // 美国东部
'America/Chicago', // 美国中部
'America/Denver', // 美国山地
'America/Los_Angeles', // 美国西部
'Europe/London', // 英国
'Europe/Paris', // 法国
'Europe/Berlin', // 德国
'Europe/Moscow', // 俄罗斯
'Asia/Shanghai', // 中国
'Asia/Tokyo', // 日本
'Asia/Seoul', // 韩国
'Asia/Singapore', // 新加坡
'Asia/Dubai', // 迪拜
'Asia/Kolkata', // 印度
'Australia/Sydney', // 澳大利亚悉尼
'Pacific/Auckland', // 新西兰
'America/Sao_Paulo', // 巴西
];
};
// 时区分组
const timezoneGroups = [
{
name: 'asia_pacific',
zones: [
{ name: 'china', value: 'Asia/Shanghai', offset: '+08:00' },
{ name: 'japan', value: 'Asia/Tokyo', offset: '+09:00' },
{ name: 'korea', value: 'Asia/Seoul', offset: '+09:00' },
{ name: 'singapore', value: 'Asia/Singapore', offset: '+08:00' },
{ name: 'india', value: 'Asia/Kolkata', offset: '+05:30' },
{ name: 'australia', value: 'Australia/Sydney', offset: '+10:00/+11:00' },
]
},
{
name: 'europe',
zones: [
{ name: 'uk', value: 'Europe/London', offset: '+00:00/+01:00' },
{ name: 'france', value: 'Europe/Paris', offset: '+01:00/+02:00' },
{ name: 'germany', value: 'Europe/Berlin', offset: '+01:00/+02:00' },
{ name: 'russia', value: 'Europe/Moscow', offset: '+03:00' },
]
},
{
name: 'americas',
zones: [
{ name: 'us_eastern', value: 'America/New_York', offset: '-05:00/-04:00' },
{ name: 'us_central', value: 'America/Chicago', offset: '-06:00/-05:00' },
{ name: 'us_western', value: 'America/Los_Angeles', offset: '-08:00/-07:00' },
{ name: 'brazil', value: 'America/Sao_Paulo', offset: '-03:00/-02:00' },
]
}
];
// 获取指定时区当前时间
const getCurrentTimeInTimezone = (timezone: string): string => {
try {
const now = new Date();
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: timezone
};
return new Intl.DateTimeFormat('zh-CN', options).format(now).replace(/\//g, '-');
} catch (error) {
console.error('时区时间错误:', error);
return '无法获取时区时间';
}
};
export default function TimezoneConverter() {
const { t } = useLanguage();
// 日期时间字符串
const [dateTimeString, setDateTimeString] = useState('');
// 源时区
const [sourceTimezone, setSourceTimezone] = useState('Asia/Shanghai');
// 目标时区
const [targetTimezone, setTargetTimezone] = useState('America/New_York');
// 转换结果
const [convertedTime, setConvertedTime] = useState('');
// 详细转换结果
const [conversionDetails, setConversionDetails] = useState('');
// 可用时区列表
const [timezones, setTimezones] = useState<string[]>([]);
// 复制状态
const [copied, setCopied] = useState(false);
// 初始化时区列表
useEffect(() => {
setTimezones(getTimezones());
// 初始化当前时间
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
setDateTimeString(`${year}-${month}-${day}T${hours}:${minutes}`);
}, []);
// 当输入变化时更新转换结果
useEffect(() => {
if (dateTimeString && sourceTimezone && targetTimezone) {
convertTimezone();
}
}, [dateTimeString, sourceTimezone, targetTimezone]);
// 时区转换
const convertTimezone = () => {
try {
if (!dateTimeString) return;
// 创建源时区的日期对象
const sourceDate = new Date(dateTimeString);
// 检查日期是否有效
if (isNaN(sourceDate.getTime())) {
setConvertedTime(t('tools.timezone_converter.invalid_date_time'));
setConversionDetails(t('tools.timezone_converter.please_enter_valid_date_time'));
return;
}
// 获取源时区的时间表示
const sourceFormatter = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: sourceTimezone
});
// 获取目标时区的时间表示
const targetFormatter = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: targetTimezone
});
const sourceFormatted = sourceFormatter.format(sourceDate).replace(/\//g, '-');
const targetFormatted = targetFormatter.format(sourceDate).replace(/\//g, '-');
setConvertedTime(targetFormatted);
// 构建详细结果
const sourceTimezoneInfo = Intl.DateTimeFormat('zh-CN', { timeZoneName: 'long', timeZone: sourceTimezone }).format(sourceDate);
const targetTimezoneInfo = Intl.DateTimeFormat('zh-CN', { timeZoneName: 'long', timeZone: targetTimezone }).format(sourceDate);
const details = `${t('tools.timezone_converter.source_time')}: ${sourceFormatted} (${sourceTimezoneInfo})
${t('tools.timezone_converter.target_time')}: ${targetFormatted} (${targetTimezoneInfo})
${t('tools.timezone_converter.timestamp')}: ${Math.floor(sourceDate.getTime() / 1000)}
${t('tools.timezone_converter.iso_format')}: ${sourceDate.toISOString()}`;
setConversionDetails(details);
} catch (error) {
console.error(t('tools.timezone_converter.timezone_conversion_error')+':', error);
setConvertedTime(t('tools.timezone_converter.timezone_conversion_error'));
setConversionDetails(t('tools.timezone_converter.timezone_conversion_error'));
}
};
// 复制转换结果
const copyResult = () => {
if (!convertedTime) return;
navigator.clipboard.writeText(convertedTime)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => console.error(t('tools.timezone_converter.copy_failed')+':', err));
};
// 使用当前时间
const useCurrentTime = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
setDateTimeString(`${year}-${month}-${day}T${hours}:${minutes}`);
};
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
<ToolHeader
toolCode="timezone_converter"
icon={faClock}
title=""
description=""
/>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* 左侧面板 */}
<div className="lg:col-span-7 space-y-6">
{/* 日期时间输入 */}
<div className={styles.card}>
<h2 className="text-lg font-medium text-primary">{t('tools.timezone_converter.date_time')}</h2>
<div className="mt-4 flex items-center gap-2">
<input
type="datetime-local"
value={dateTimeString}
onChange={(e) => setDateTimeString(e.target.value)}
className={styles.input}
/>
<button
className="btn-secondary whitespace-nowrap"
onClick={useCurrentTime}
title={t('tools.timezone_converter.use_current_time')}
>
<FontAwesomeIcon icon={faClock} className="mr-2" />
{t('tools.timezone_converter.current_time')}
</button>
</div>
{/* 源时区选择 */}
<div className="mt-6">
<h2 className="text-lg font-medium text-primary">{t('tools.timezone_converter.source_timezone')}</h2>
<div className="mt-4">
<select
value={sourceTimezone}
onChange={(e) => setSourceTimezone(e.target.value)}
className={styles.input}
>
{timezones.map(timezone => (
<option key={timezone} value={timezone}>
{timezone} ({getCurrentTimeInTimezone(timezone)})
</option>
))}
</select>
</div>
</div>
{/* 目标时区选择 */}
<div className="mt-6">
<h2 className="text-lg font-medium text-primary">{t('tools.timezone_converter.target_timezone')}</h2>
<div className="mt-4">
<select
value={targetTimezone}
onChange={(e) => setTargetTimezone(e.target.value)}
className={styles.input}
>
{timezones.map(timezone => (
<option key={timezone} value={timezone}>
{timezone} ({getCurrentTimeInTimezone(timezone)})
</option>
))}
</select>
</div>
</div>
</div>
{/* 转换结果 */}
<div className={styles.card}>
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium text-primary">{t('tools.timezone_converter.conversion_result')}</h2>
<button
onClick={copyResult}
className={styles.iconButton}
disabled={!convertedTime}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} className="mr-1" />
{copied ? t('tools.timezone_converter.copied') : t('tools.timezone_converter.copy')}
</button>
</div>
{convertedTime ? (
<div className="mt-4 space-y-4">
<div>
<h3 className="text-sm font-medium text-secondary">{t('tools.timezone_converter.converted_time')}</h3>
<div className="text-primary">
{convertedTime}
</div>
</div>
<div>
<h3 className="text-sm font-medium text-secondary">{t('tools.timezone_converter.detailed_result')}</h3>
<div className={styles.resultBox}>
<pre className="font-mono text-sm text-primary whitespace-pre-wrap">
{conversionDetails}
</pre>
</div>
<div className="mt-2 flex flex-col gap-1 text-xs text-tertiary">
<p>{t('tools.timezone_converter.timezone_display_note')}</p>
</div>
</div>
</div>
) : (
<div className={styles.timezoneList}>
{t('tools.timezone_converter.input_date_time_select_timezone')}
</div>
)}
<div className="mt-2 text-xs text-tertiary">
<p>{t('tools.timezone_converter.timezone_note')}</p>
</div>
</div>
</div>
{/* 右侧面板 */}
<div className="lg:col-span-5">
<div className={styles.card}>
<h3 className="text-sm font-medium text-secondary mb-2">{t('tools.timezone_converter.common_timezone_info')}</h3>
<div className={styles.timezoneList}>
{timezoneGroups.map((group) => (
<div key={group.name} className="mb-4">
<h4 className="font-medium mb-2">{t(`tools.timezone_converter.${group.name}`)}</h4>
<div className="pl-2 border-l-2 border-purple-glow/50">
{group.zones.map((zone) => (
<div key={zone.value} className="mb-2">
<div className="font-medium">{t(`tools.timezone_converter.${zone.name}`)}</div>
<div className="flex justify-between text-xs">
<span>{zone.value}</span>
<span>{zone.offset}</span>
</div>
</div>
))}
</div>
</div>
))}
<div className="text-xs">
<p className="mb-2">{t('tools.timezone_converter.about_timezone')}</p>
<ul className="list-disc pl-4 space-y-1">
<li>{t('tools.timezone_converter.timezone_offset_info')}</li>
<li>{t('tools.timezone_converter.timezone_dst_info')}</li>
<li>{t('tools.timezone_converter.dst_implementation')}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+277
View File
@@ -0,0 +1,277 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFont, faExchangeAlt, faSyncAlt, faCopy, faCheck, faExclamationTriangle, faEraser } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import tools from '@/config/tools';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
card: "card p-6",
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
input: "search-input w-full",
textarea: "w-full p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple transition-all resize-none font-mono",
label: "text-sm text-secondary font-medium",
secondaryText: "text-sm text-tertiary",
secondaryBtn: "flex items-center gap-1 text-sm px-2 py-1 rounded bg-block-strong hover:bg-block-hover text-secondary transition-colors",
exchangeBtn: "bg-purple-glow/10 text-purple p-2 rounded-full hover:bg-purple-glow/20 transition-colors",
error: "p-3 bg-red-900/20 border border-red-700/30 rounded-lg text-error",
tabButton: "px-3 py-2 text-sm font-medium transition-all",
activeTab: "bg-block text-primary shadow-sm",
inactiveTab: "text-tertiary",
actionBtn: "btn-secondary flex items-center gap-2",
flexBetween: "flex flex-col sm:flex-row gap-4 justify-between items-center",
}
export default function UnicodeConverter() {
const { t } = useLanguage();
// 从工具配置中获取当前工具信息
const toolConfig = tools.find(tool => tool.code === 'unicode_converter');
// 状态管理
const [inputText, setInputText] = useState('');
const [outputText, setOutputText] = useState('');
const [operation, setOperation] = useState('encode'); // 'encode' 或 'decode'
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
// 当输入文本或操作变化时,自动执行转换
useEffect(() => {
processConversion();
}, [inputText, operation]);
// 执行转换操作
const processConversion = () => {
setError(null);
if (!inputText) {
setOutputText('');
return;
}
try {
if (operation === 'encode') {
// 文本转Unicode编码
const result = Array.from(inputText)
.map(char => {
const code = char.charCodeAt(0);
// 只转换非ASCII字符
if (code > 127) {
return `\\u${code.toString(16).padStart(4, '0')}`;
}
return char;
})
.join('');
setOutputText(result);
} else {
// Unicode编码转文本
const result = inputText.replace(/\\u([0-9a-fA-F]{4})/g, (match, group) => {
return String.fromCharCode(parseInt(group, 16));
});
setOutputText(result);
}
} catch (err) {
console.error(t('tools.unicode_converter.conversion_error'), err);
setError(`${t('tools.unicode_converter.conversion_error')}${(err as Error).message}`);
setOutputText('');
}
};
// 复制输出内容到剪贴板
const copyToClipboard = () => {
if (!outputText) return;
navigator.clipboard.writeText(outputText)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => console.error(t('tools.unicode_converter.copy_failed'), err));
};
// 清空输入和输出
const clearAll = () => {
setInputText('');
setOutputText('');
setError(null);
};
// 切换操作类型(编码/解码)
const toggleOperation = () => {
// 交换输入和输出文本
const newOperation = operation === 'encode' ? 'decode' : 'encode';
const temp = inputText;
setInputText(outputText);
setOutputText(temp);
setOperation(newOperation);
setError(null);
};
// 加载示例文本
const loadExample = () => {
const examples = {
encode: '你好,世界!Hello, World!',
decode: '\\u4f60\\u597d\\uff0c\\u4e16\\u754c\\uff01Hello, World!'
};
setInputText(examples[operation as keyof typeof examples]);
};
return (
<div className={styles.container}>
{toolConfig && (
<ToolHeader
toolCode="unicode_converter"
title=""
description=""
icon={toolConfig.icon || faFont}
/>
)}
{/* 主要内容区域 */}
<div className={styles.card}>
<div className="space-y-6">
{/* 操作类型切换 */}
<div className={styles.flexBetween}>
<div className="flex items-center gap-4">
<div className="flex items-center bg-block-strong rounded-md p-1">
<button
className={`${styles.tabButton} ${operation === 'encode' ? styles.activeTab : styles.inactiveTab}`}
onClick={() => setOperation('encode')}
>
{t('tools.unicode_converter.text_to_unicode')}
</button>
<button
className={`${styles.tabButton} ${operation === 'decode' ? styles.activeTab : styles.inactiveTab}`}
onClick={() => setOperation('decode')}
>
{t('tools.unicode_converter.unicode_to_text')}
</button>
</div>
<div className={styles.secondaryText}>
{operation === 'encode'
? t('tools.unicode_converter.text_to_unicode_description')
: t('tools.unicode_converter.unicode_to_text_description')}
</div>
</div>
<div className="flex gap-2">
<button
className={styles.actionBtn}
onClick={loadExample}
>
<FontAwesomeIcon icon={faSyncAlt} />
{t('tools.unicode_converter.load_example')}
</button>
<button
className={styles.actionBtn}
onClick={clearAll}
>
<FontAwesomeIcon icon={faEraser} />
{t('tools.unicode_converter.clear')}
</button>
</div>
</div>
{/* 输入输出区域 */}
<div className="grid grid-cols-2 gap-6">
{/* 输入框 */}
<div className="space-y-2">
<div className="flex justify-between items-center h-8">
<label className={styles.label}>
{operation === 'encode'
? t('tools.unicode_converter.original_text')
: t('tools.unicode_converter.unicode_encoding')}
</label>
</div>
<textarea
className={styles.textarea}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder={operation === 'encode'
? t('tools.unicode_converter.text_to_unicode_placeholder')
: t('tools.unicode_converter.unicode_to_text_placeholder')}
rows={10}
/>
</div>
{/* 输出框 */}
<div className="space-y-2">
<div className="flex justify-between items-center h-8">
<div className="flex items-center">
<label className={styles.label}>
{operation === 'encode'
? t('tools.unicode_converter.unicode_encoding')
: t('tools.unicode_converter.converted_text')}
</label>
<button
onClick={toggleOperation}
className={styles.exchangeBtn}
title={t('tools.unicode_converter.swap_input_output')}
>
<FontAwesomeIcon icon={faExchangeAlt} />
</button>
</div>
<div>
<button
onClick={copyToClipboard}
className={styles.secondaryBtn}
disabled={!outputText}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
{copied ? t('tools.unicode_converter.copied') : t('tools.unicode_converter.copy')}
</button>
</div>
</div>
<textarea
className={styles.textarea}
value={outputText}
readOnly
placeholder={operation === 'encode'
? t('tools.unicode_converter.unicode_result_placeholder')
: t('tools.unicode_converter.text_result_placeholder')}
rows={10}
/>
</div>
</div>
{/* 错误提示 */}
{error && (
<div className={styles.error}>
<FontAwesomeIcon icon={faExclamationTriangle} className="mr-2" />
{error}
</div>
)}
{/* 功能说明 */}
<div className="bg-block p-4 rounded-lg">
<h3 className="text-primary font-medium mb-2">{t('tools.unicode_converter.feature_intro')}</h3>
<p className={styles.secondaryText}>
{t('tools.unicode_converter.unicode_description')}
</p>
<p className={styles.secondaryText}>
{t('tools.unicode_converter.supported_operations')}
</p>
<ul className="list-disc pl-5 text-sm text-tertiary">
<li>{t('tools.unicode_converter.operation_text_to_unicode')}</li>
<li>{t('tools.unicode_converter.operation_unicode_to_text')}</li>
</ul>
<p className={styles.secondaryText}>
{t('tools.unicode_converter.note')}
</p>
</div>
</div>
</div>
{/* 回到顶部按钮 */}
<BackToTop />
</div>
);
}
+398
View File
@@ -0,0 +1,398 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExchangeAlt, faCopy, faCheck, faEraser, faHistory } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import tools from '@/config/tools';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
card: "card p-6",
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
textarea: "w-full p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple transition-all",
label: "text-sm text-secondary font-medium",
secondaryText: "text-sm text-tertiary",
errorBox: "p-3 bg-red-900/20 border border-red-700/30 rounded-lg text-error",
successBox: "p-3 bg-green-900/20 border border-green-700/30 rounded-lg text-success",
iconButton: "p-1 text-secondary hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed",
directionIndicator: (active: boolean) => active ? "text-primary" : "text-tertiary",
historyItem: "flex px-3 py-2 text-sm rounded-lg cursor-pointer text-secondary hover:bg-block-hover transition-colors",
noHistoryText: "text-sm text-tertiary py-2 text-center italic",
swapButton: "bg-block p-2 rounded-full hover:bg-block-hover transition-colors",
encodeLabel: (active: boolean) => `text-sm ${active ? 'text-purple' : 'text-tertiary'} cursor-pointer`,
clearButton: "btn-secondary flex items-center gap-2",
copyButton: "btn-primary flex items-center gap-2",
historyButton: "btn-secondary flex items-center gap-2",
}
// 类型定义
type Direction = 'encode' | 'decode';
type EncodeMode = 'url' | 'component';
// 历史记录项目接口
interface HistoryItem {
id: string;
input: string;
output: string;
direction: Direction;
mode: EncodeMode;
timestamp: number;
}
export default function UrlEncoder() {
const { t } = useLanguage();
// 从工具配置中获取当前工具信息
const toolConfig = tools.find(tool => tool.code === 'url_encoder');
// 状态管理
const [inputText, setInputText] = useState('');
const [outputText, setOutputText] = useState('');
const [direction, setDirection] = useState<Direction>('encode');
const [encodeMode, setEncodeMode] = useState<EncodeMode>('url');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [copied, setCopied] = useState(false);
const [history, setHistory] = useState<HistoryItem[]>([]);
const [showHistory, setShowHistory] = useState(false);
// 从localStorage加载历史记录
useEffect(() => {
const savedHistory = localStorage.getItem('urlEncoderHistory');
if (savedHistory) {
try {
setHistory(JSON.parse(savedHistory));
} catch (e) {
console.error(t('tools.url_encoder.load_history_error'), e);
}
}
}, [t]);
// 保存历史记录到localStorage
useEffect(() => {
if (history.length > 0) {
localStorage.setItem('urlEncoderHistory', JSON.stringify(history));
}
}, [history]);
// 转换文本
useEffect(() => {
if (!inputText.trim()) {
setOutputText('');
setError('');
return;
}
try {
let result = '';
if (direction === 'encode') {
result = encodeMode === 'url'
? encodeURI(inputText)
: encodeURIComponent(inputText);
} else {
result = encodeMode === 'url'
? decodeURI(inputText)
: decodeURIComponent(inputText);
}
setOutputText(result);
setError('');
} catch (err) {
if (err instanceof Error) {
setError(err.message);
} else {
setError(t('tools.url_encoder.error_processing'));
}
setOutputText('');
}
}, [inputText, direction, encodeMode, t]);
// 反转转换方向
const swapDirection = () => {
setDirection(prev => prev === 'encode' ? 'decode' : 'encode');
setInputText(outputText);
setOutputText('');
setError('');
};
// 复制输出结果
const copyOutput = () => {
if (!outputText) return;
navigator.clipboard.writeText(outputText)
.then(() => {
setCopied(true);
setSuccess(t('tools.url_encoder.copied_to_clipboard'));
// 添加到历史记录
if (inputText.trim() && outputText.trim()) {
const newItem: HistoryItem = {
id: Date.now().toString(),
input: inputText,
output: outputText,
direction,
mode: encodeMode,
timestamp: Date.now()
};
setHistory(prev => [newItem, ...prev.slice(0, 9)]); // 保留最近10条记录
}
setTimeout(() => {
setCopied(false);
setSuccess('');
}, 2000);
})
.catch(err => {
console.error(t('tools.url_encoder.copy_failed'), err);
setError(t('tools.url_encoder.copy_failed'));
});
};
// 清空输入输出
const clearAll = () => {
setInputText('');
setOutputText('');
setError('');
setSuccess('');
};
// 从历史记录中恢复
const restoreFromHistory = (item: HistoryItem) => {
setInputText(item.input);
setDirection(item.direction);
setEncodeMode(item.mode);
setShowHistory(false);
};
// 清空历史记录
const clearHistory = () => {
setHistory([]);
localStorage.removeItem('urlEncoderHistory');
};
// 格式化时间
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleString();
};
return (
<div className={styles.container}>
{/* 工具头部 */}
<ToolHeader
toolCode="url_encoder"
icon={toolConfig?.icon || faExchangeAlt}
title=""
description=""
/>
{/* 主内容区 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧面板 - 输入区域 */}
<div className="lg:col-span-2 space-y-6">
{/* 转换选项 */}
<div className={styles.card}>
<div className="flex flex-col md:flex-row items-center justify-center space-y-4 md:space-y-0 md:space-x-6 mb-2">
{/* 转换方向选择器 */}
<div className="flex items-center space-x-4">
<span className={styles.directionIndicator(direction === 'encode')}>
{t('tools.url_encoder.original_text')}
</span>
<button onClick={swapDirection} className={styles.swapButton}>
<FontAwesomeIcon
icon={faExchangeAlt}
className={`${direction === 'encode' ? 'text-purple' : 'text-purple rotate-180'} transition-transform`}
/>
</button>
<span className={styles.directionIndicator(direction === 'decode')}>
{t('tools.url_encoder.url_encoded_text')}
</span>
</div>
{/* 编码模式选择器 */}
<div className="flex items-center space-x-4">
<label className={styles.encodeLabel(encodeMode === 'url')}>
<input
type="radio"
name="encodeMode"
value="url"
checked={encodeMode === 'url'}
onChange={() => setEncodeMode('url')}
className="sr-only"
/>
<span>{t('tools.url_encoder.uri_encoding')}</span>
</label>
<label className={styles.encodeLabel(encodeMode === 'component')}>
<input
type="radio"
name="encodeMode"
value="component"
checked={encodeMode === 'component'}
onChange={() => setEncodeMode('component')}
className="sr-only"
/>
<span>{t('tools.url_encoder.uri_component_encoding')}</span>
</label>
</div>
</div>
<div className="mt-4 text-sm text-tertiary">
<p>
{encodeMode === 'url'
? t('tools.url_encoder.uri_encoding_description')
: t('tools.url_encoder.uri_component_description')}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 输入文本区域 */}
<div className="space-y-3">
<label className={styles.label}>
{direction === 'encode' ? t('tools.url_encoder.input_original_text') : t('tools.url_encoder.input_encoded_text')}
</label>
<textarea
className={styles.textarea}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder={direction === 'encode' ? t('tools.url_encoder.enter_text_to_encode') : t('tools.url_encoder.enter_text_to_decode')}
rows={12}
/>
<div className="flex justify-between">
<button
onClick={clearAll}
disabled={!inputText}
className={styles.clearButton}
>
<FontAwesomeIcon icon={faEraser} />
{t('tools.url_encoder.clear')}
</button>
<button
onClick={() => setShowHistory(!showHistory)}
className={styles.historyButton}
>
<FontAwesomeIcon icon={faHistory} />
{showHistory ? t('tools.url_encoder.hide_history') : t('tools.url_encoder.show_history')}
</button>
</div>
</div>
{/* 输出文本区域 */}
<div className="space-y-3">
<label className={styles.label}>
{direction === 'encode' ? t('tools.url_encoder.encoding_result') : t('tools.url_encoder.decoding_result')}
</label>
<textarea
className={styles.textarea}
value={outputText}
readOnly
placeholder={direction === 'encode' ? t('tools.url_encoder.encoded_result_placeholder') : t('tools.url_encoder.decoded_result_placeholder')}
rows={12}
/>
<div className="flex justify-end">
<button
onClick={copyOutput}
disabled={!outputText}
className={styles.copyButton}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
{copied ? t('tools.url_encoder.copied') : t('tools.url_encoder.copy_result')}
</button>
</div>
{/* 消息区域 */}
{error && <div className={styles.errorBox}>{error}</div>}
{success && <div className={styles.successBox}>{success}</div>}
</div>
</div>
</div>
{/* 右侧面板 - 历史记录 */}
<div className="space-y-6">
<div className={styles.card}>
<div className="flex justify-between items-center mb-4">
<h2 className="font-medium text-primary">{t('tools.url_encoder.history')}</h2>
{history.length > 0 && (
<button
onClick={clearHistory}
className="text-sm text-secondary hover:text-primary transition-colors"
>
{t('tools.url_encoder.clear_history')}
</button>
)}
</div>
{history.length > 0 ? (
<div className="space-y-2 max-h-[500px] overflow-y-auto">
{history.map(item => (
<div
key={item.id}
className={styles.historyItem}
onClick={() => restoreFromHistory(item)}
>
<div className="flex-1 truncate">
<div className="font-medium mb-1 truncate">{item.input.substring(0, 30)}{item.input.length > 30 ? '...' : ''}</div>
<div className="flex items-center justify-between">
<span className="text-xs text-tertiary">
{item.direction === 'encode' ? t('tools.url_encoder.encode') : t('tools.url_encoder.decode')}
{item.mode === 'url' ? ' URI' : ' URI Component'}
</span>
<span className="text-xs text-tertiary">{formatTime(item.timestamp)}</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className={styles.noHistoryText}>
{t('tools.url_encoder.no_history')}
</div>
)}
</div>
{/* 说明卡片 */}
<div className={styles.card}>
<h2 className="font-medium text-primary mb-4">{t('tools.url_encoder.url_encoding_explanation')}</h2>
<div className="space-y-4 text-sm text-tertiary">
<div>
<h3 className="font-medium text-secondary mb-2">{t('tools.url_encoder.uri_vs_component')}</h3>
<ul className="space-y-2">
<li><span className="text-primary">encodeURI</span>: {t('tools.url_encoder.encode_uri_description')}</li>
<li><span className="text-primary">encodeURIComponent</span>: {t('tools.url_encoder.encode_uri_component_description')}</li>
</ul>
</div>
<div>
<h3 className="font-medium text-secondary mb-2">{t('tools.url_encoder.usage_scenarios')}</h3>
<ul className="space-y-2">
<li>{t('tools.url_encoder.scenario_1')}</li>
<li>{t('tools.url_encoder.scenario_2')}</li>
<li>{t('tools.url_encoder.scenario_3')}</li>
</ul>
</div>
<div>
<h3 className="font-medium text-secondary mb-2">{t('tools.url_encoder.encoding_rules')}</h3>
<p>{t('tools.url_encoder.rules_1')}</p>
<p>{t('tools.url_encoder.rules_2')}</p>
</div>
</div>
</div>
</div>
</div>
{/* 回到顶部按钮 */}
<BackToTop />
</div>
);
}