add
This commit is contained in:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>工具已添加到收藏夹,点击导航栏中的"我的收藏"查看</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: '<div class="example">HTML示例 & 特殊字符</div>'
|
||||
}
|
||||
};
|
||||
|
||||
// 根据当前编码类型和操作选择示例
|
||||
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">"{value}"</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">"{key}"</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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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('无法转换为CSV:JSON必须是对象或数组');
|
||||
}
|
||||
} 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 辅助函数:转义CSV字段
|
||||
function escapeCSV(value: string): string {
|
||||
// 如果字段包含逗号、双引号或换行符,需要用双引号包围并处理
|
||||
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
||||
// 双引号内的双引号需要再次转义
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
// 常用正则表达式示例
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user