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

After

Width:  |  Height:  |  Size: 25 KiB

+501
View File
@@ -0,0 +1,501 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 添加字体图标占位,防止布局闪烁 */
.fontawesome-i2svg-active body .icon-container .icon {
opacity: 1;
transition: opacity 0.2s ease-in;
}
.fontawesome-i2svg-pending body .icon-container .icon {
opacity: 0;
}
:root {
/* 主题色 */
--color-primary: 99, 102, 241; /* #6366F1 - 深邃紫色 */
--color-primary-hover: 139, 92, 246; /* #8B5CF6 - 紫罗兰 */
--color-primary-light: 129, 140, 248; /* #818CF8 - 亮紫色 */
/* 背景色系 */
--color-bg-main: 18, 24, 39; /* #121827 - 深炭黑 */
--color-bg-card: 30, 41, 59; /* #1E293B - 暗灰蓝 */
--color-bg-secondary: 45, 55, 72; /* #2D3748 - 深靛蓝 */
/* 文字颜色 */
--color-text-primary: 241, 245, 249; /* #F1F5F9 - 银月白 */
--color-text-secondary: 203, 213, 225; /* #CBD5E1 - 淡灰蓝 */
--color-text-tertiary: 148, 163, 184; /* #94A3B8 - 冷钢灰 */
/* 点缀色 */
--color-success: 16, 185, 129; /* #10B981 - 暗翠绿 */
--color-warning: 245, 158, 11; /* #F59E0B - 暗琥珀 */
--color-error: 239, 68, 68; /* #EF4444 - 暗珊瑚 */
/* 块级元素背景色 */
--color-block: 38, 53, 72; /* #263548 - 深灰蓝色块 */
--color-block-strong: 45, 55, 72; /* #2D3748 - 更深的块色 */
--color-block-hover: 61, 74, 92; /* #3D4A5C - 悬停态块色 */
/* 边框和阴影 */
--color-border: rgba(99, 102, 241, 0.15);
--color-shadow: rgba(0, 0, 0, 0.25);
--purple-glow: rgba(99, 102, 241, 0.15);
}
/* 浅色主题变量 */
[data-theme='light'] {
/* 主题色保持不变,以保持品牌一致性 */
--color-primary: 99, 102, 241; /* #6366F1 - 深邃紫色 */
--color-primary-hover: 139, 92, 246; /* #8B5CF6 - 紫罗兰 */
--color-primary-light: 124, 58, 237; /* #7C3AED - 更深的紫色,提高对比度 */
/* 背景色系 */
--color-bg-main: 249, 250, 251; /* #F9FAFB - 浅灰背景 */
--color-bg-card: 255, 255, 255; /* #FFFFFF - 纯白卡片 */
--color-bg-secondary: 243, 244, 246; /* #F3F4F6 - 次级灰背景 */
/* 文字颜色 */
--color-text-primary: 17, 24, 39; /* #111827 - 近黑色 */
--color-text-secondary: 55, 65, 81; /* #374151 - 深灰色 */
--color-text-tertiary: 107, 114, 128; /* #6B7280 - 中灰色 */
/* 点缀色保持不变 */
--color-success: 16, 185, 129; /* #10B981 - 暗翠绿 */
--color-warning: 245, 158, 11; /* #F59E0B - 暗琥珀 */
--color-error: 239, 68, 68; /* #EF4444 - 暗珊瑚 */
/* 块级元素背景色 */
--color-block: 241, 245, 249; /* #F1F5F9 - 浅灰色块 */
--color-block-strong: 226, 232, 240; /* #E2E8F0 - 更强调的块色 */
--color-block-hover: 203, 213, 225; /* #CBD5E1 - 悬停态块色 */
/* 边框和阴影 */
--color-border: rgba(99, 102, 241, 0.2);
--color-shadow: rgba(0, 0, 0, 0.1);
--purple-glow: rgba(99, 102, 241, 0.2);
}
/* 全局样式 */
body {
color: rgb(var(--color-text-primary));
background: rgb(var(--color-bg-main));
min-height: 100vh;
transition: color 0.3s ease, background-color 0.3s ease;
}
/* 自定义滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(var(--color-bg-secondary), 0.3);
}
::-webkit-scrollbar-thumb {
background: rgba(var(--color-primary), 0.4);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(var(--color-primary-hover), 0.6);
}
/* Firefox滚动条样式 */
* {
scrollbar-width: thin;
scrollbar-color: rgba(var(--color-primary), 0.4) rgba(var(--color-bg-secondary), 0.3);
}
/* 主题通用文本颜色类 */
.text-primary {
color: rgb(var(--color-text-primary));
}
.text-secondary {
color: rgb(var(--color-text-secondary));
}
.text-tertiary {
color: rgb(var(--color-text-tertiary));
}
.text-purple {
color: rgb(var(--color-primary-light));
}
.text-error {
color: rgb(var(--color-error));
}
.text-success {
color: rgb(var(--color-success));
}
.text-warning {
color: rgb(var(--color-warning));
}
/* 主题通用背景色类 */
.bg-main {
background-color: rgb(var(--color-bg-main));
}
.bg-card {
background-color: rgb(var(--color-bg-card));
}
.bg-secondary {
background-color: rgb(var(--color-bg-secondary));
}
.bg-block {
background-color: rgb(var(--color-block));
}
.bg-block-strong {
background-color: rgb(var(--color-block-strong));
}
.bg-block-hover {
background-color: rgb(var(--color-block-hover));
}
.bg-purple-glow {
background-color: var(--purple-glow);
}
/* 通用组件样式 */
@layer components {
/* 卡片基础样式 */
.card {
@apply rounded-lg border shadow-lg transition-all duration-300;
background-color: rgb(var(--color-bg-card));
border-color: rgba(var(--color-primary), 0.15);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
/* 卡片悬停效果 */
.card:hover {
border-color: rgba(var(--color-primary), 0.3);
background-color: color-mix(in srgb, rgb(var(--color-bg-card)) 95%, rgb(var(--color-primary)) 5%);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
/* 主按钮样式 */
.btn-primary {
@apply bg-gradient-to-r text-white rounded-md px-4 py-2 font-medium transition-all;
background-image: linear-gradient(to right, rgb(var(--color-primary)), rgb(var(--color-primary-hover)));
}
/* 次按钮样式 */
.btn-secondary {
@apply rounded-md px-4 py-2 font-medium transition-all border;
background-color: rgb(var(--color-bg-secondary));
color: rgb(var(--color-primary-light));
border-color: rgb(var(--color-primary));
}
/* 按钮悬停效果 */
.btn-primary:hover, .btn-secondary:hover {
@apply shadow-md scale-[1.02];
box-shadow: 0 4px 6px rgba(var(--color-primary), 0.25);
border-color: rgb(var(--color-primary-light));
}
/* 分类标签样式 */
.category-tag {
@apply px-2 py-1 text-xs rounded-full inline-block border;
background-color: rgba(var(--color-primary), 0.15);
color: rgb(var(--color-primary-light));
border-color: rgba(var(--color-primary), 0.2);
}
/* 搜索框样式 */
.search-input {
@apply w-full px-4 py-2.5 pl-10 text-sm focus:outline-none focus:ring-1 rounded-md shadow-sm transition-all;
background-color: rgb(var(--color-bg-secondary));
color: rgb(var(--color-text-primary));
border: 1px solid rgba(var(--color-primary), 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.search-input:focus {
border-color: rgb(var(--color-primary));
--tw-ring-color: rgb(var(--color-primary));
}
/* 导航栏样式 */
.nav-bar {
@apply backdrop-blur-md border-b p-4 rounded-lg;
background-color: rgba(var(--color-bg-secondary), 0.7);
border-color: rgba(var(--color-primary), 0.2);
}
/* 图标容器 */
.icon-container {
@apply w-10 h-10 rounded-lg flex items-center justify-center;
background-color: rgba(var(--color-primary), 0.1);
}
/* 图标样式 */
.icon {
@apply text-lg;
color: rgb(var(--color-primary-light));
filter: drop-shadow(0 0 3px rgba(var(--color-primary), 0.3));
}
/* 圆角按钮 */
.rounded-button {
@apply rounded-md;
}
[data-theme='light'] .rounded-button {
@apply rounded-lg;
}
}
/* 淡入动画 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-fadeIn {
animation: fadeIn 0.2s ease-in-out forwards;
}
/* 缩放动画 */
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-scaleIn {
animation: scaleIn 0.2s ease-out forwards;
}
/* 脉冲动画 */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.animate-pulse {
animation: pulse 2s infinite;
}
/* JSON编辑器基础样式 */
.vanilla-jsoneditor-react {
width: 100%;
height: 100%;
overflow: hidden;
}
/* 添加Markdown预览样式 */
.prose {
color: rgb(var(--color-text-primary));
max-width: 65ch;
font-size: 1rem;
line-height: 1.75;
position: relative;
z-index: 0;
overflow-wrap: break-word;
}
.prose a {
color: rgb(var(--color-primary-light));
text-decoration: underline;
text-underline-offset: 2px;
}
.prose a:hover {
color: rgb(var(--color-primary));
}
.prose strong {
color: rgb(var(--color-text-primary));
font-weight: 600;
}
.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
color: rgb(var(--color-text-primary));
font-weight: 600;
margin-top: 1.5em;
margin-bottom: 0.75em;
line-height: 1.25;
}
.prose h1 {
font-size: 2em;
margin-top: 0;
}
.prose h2 {
font-size: 1.5em;
}
.prose h3 {
font-size: 1.25em;
}
.prose ul, .prose ol {
padding-left: 1.5em;
}
.prose ul li, .prose ol li {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
.prose blockquote {
border-left: 4px solid #6366F1;
padding-left: 1em;
margin-left: 0;
color: #CBD5E1;
}
.prose code {
background-color: rgba(99, 102, 241, 0.1);
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.9em;
}
.prose pre {
background-color: #2D3748;
padding: 1em;
border-radius: 6px;
overflow-x: auto;
margin: 1em 0;
}
.prose pre code {
background-color: transparent;
padding: 0;
font-size: 0.9em;
color: #CBD5E1;
}
.prose table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
}
.prose th, .prose td {
border: 1px solid #4B5563;
padding: 0.5em 0.75em;
text-align: left;
}
.prose th {
background-color: #2D3748;
font-weight: 600;
}
.prose hr {
border: 0;
border-top: 1px solid #4B5563;
margin: 2em 0;
}
.prose img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.prose-invert {
color: #F1F5F9;
}
/* 改进Markdown预览样式隔离 */
.markdown-preview-container {
all: revert;
font-family: inherit;
color: inherit;
line-height: inherit;
}
.markdown-preview-container * {
max-width: 100%;
}
.markdown-preview-container a {
color: #818CF8;
text-decoration: underline;
text-underline-offset: 2px;
}
.markdown-preview-container img {
max-width: 100%;
height: auto;
}
.markdown-preview-container pre,
.markdown-preview-container code {
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
.markdown-preview-container table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
overflow-x: auto;
display: block;
}
.markdown-preview-container th,
.markdown-preview-container td {
border: 1px solid #4B5563;
padding: 0.5em;
}
.markdown-preview-container th {
background-color: #2D3748;
}
.markdown-preview-container iframe,
.markdown-preview-container embed,
.markdown-preview-container object {
max-width: 100%;
border: none;
}
.markdown-preview-container blockquote {
margin-left: 0;
padding-left: 1em;
border-left: 4px solid #6366F1;
color: #CBD5E1;
}
/* 限制预览区域的内容交互 */
.markdown-preview-container form,
.markdown-preview-container button,
.markdown-preview-container input,
.markdown-preview-container textarea,
.markdown-preview-container select {
pointer-events: none;
}
+51
View File
@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import "./globals.css";
import { config } from '@fortawesome/fontawesome-svg-core';
import '@fortawesome/fontawesome-svg-core/styles.css';
import { LanguageProvider } from '@/context/LanguageContext';
// 阻止Font Awesome自动插入CSS,避免闪烁
config.autoAddCss = false;
export const metadata: Metadata = {
title: {
default: "极速箱 | Jisuxiang - 高效开发工具集成平台 | 程序员必备在线工具箱",
template: "%s | 极速箱 | Jisuxiang"
},
description: "提供高效开发工具集成平台,程序员必备在线工具箱,包含JSON处理、编码转换、加密解密、时间转换等提升编程效率的神器",
openGraph: {
title: "极速箱 | Jisuxiang - 高效开发工具集成平台 | 程序员必备在线工具箱",
description: "提供高效开发工具集成平台,程序员必备在线工具箱,包含JSON处理、编码转换、加密解密、时间转换等提升编程效率的神器",
type: "website",
locale: "zh_CN",
alternateLocale: "en_US",
},
alternates: {
languages: {
'zh-CN': '/',
'en-US': '/'
}
}
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<head>
{/* 预加载字体和图标资源 */}
<link rel="preload" href="/_next/static/media/fa-brands-400.ttf" as="font" type="font/ttf" crossOrigin="anonymous" />
<link rel="preload" href="/_next/static/media/fa-solid-900.ttf" as="font" type="font/ttf" crossOrigin="anonymous" />
<link rel="preload" href="/_next/static/media/fa-regular-400.ttf" as="font" type="font/ttf" crossOrigin="anonymous" />
</head>
<body>
<LanguageProvider>
{children}
</LanguageProvider>
</body>
</html>
);
}
+769
View File
@@ -0,0 +1,769 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faStar, faSearch, faTimes, faChevronDown, faBook, faCode, faCloud, faBell, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons';
import { faStar as farStar } from '@fortawesome/free-regular-svg-icons';
import { faGithub } from '@fortawesome/free-brands-svg-icons';
import categories from '@/config/categories';
import tools from '@/config/tools';
import { useRouter } from 'next/navigation';
import ThemeToggle from '@/components/ThemeToggle';
import LanguageToggle from '@/components/LanguageToggle';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
export default function Home() {
const { t, language } = useLanguage();
const [activeCategory, setActiveCategory] = useState(() => {
// 从 sessionStorage 获取上次选择的分类,如果没有则默认为 "all"
if (typeof window !== 'undefined') {
return sessionStorage.getItem('lastActiveCategory') || "all";
}
return "all";
});
const [searchTerm, setSearchTerm] = useState("");
const [iconsLoaded, setIconsLoaded] = useState(false);
const [favoriteTools, setFavoriteTools] = useState<string[]>([]);
const [showFavorites, setShowFavorites] = useState(false);
const [showNotification, setShowNotification] = useState(false);
const [firstFavoriteAdded, setFirstFavoriteAdded] = useState(false);
const [prefetchedTools, setPrefetchedTools] = useState<Set<string>>(new Set());
const [loadingProgress, setLoadingProgress] = useState<{ current: number, total: number }>({ current: 0, total: 0 });
const [showProductsDropdown, setShowProductsDropdown] = useState(false);
const router = useRouter();
// 产品推荐列表
const recommendedProducts = [
{ title: "ShowDoc", url: "https://www.showdoc.com.cn/", description: "API文档、技术文档工具", icon: faBook },
{ title: "RunApi", url: "https://www.runapi.com.cn/", description: "接口管理与测试平台", icon: faCode },
{ title: "大风云", url: "https://www.dfyun.com.cn/", description: "性价比巨高的CDN服务", icon: faCloud },
{ title: "Push", url: "https://push.showdoc.com.cn/", description: "消息推送服务", icon: faBell }
];
// 点击其他区域关闭产品推荐下拉菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
if (!target.closest('#products-dropdown-container')) {
setShowProductsDropdown(false);
}
};
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
}, []);
// 切换产品推荐下拉菜单的显示状态
const toggleProductsDropdown = (e: React.MouseEvent) => {
e.stopPropagation();
setShowProductsDropdown(prev => !prev);
};
// 批量工具预加载管理
useEffect(() => {
let isMounted = true;
// 页面完全加载后开始批量预加载
const startBatchPrefetch = () => {
// 获取收藏的工具列表
const savedFavorites = localStorage.getItem('favoriteTools');
const userFavorites = savedFavorites ? JSON.parse(savedFavorites) : [];
// 按优先级排序工具:1. 收藏的工具 2. 常用工具 3. 其他工具
const favoriteTools = tools.filter(tool => userFavorites.includes(tool.code));
const commonTools = tools.filter(tool =>
tool.category.includes('common') && !userFavorites.includes(tool.code)
);
const otherTools = tools.filter(tool =>
!tool.category.includes('common') && !userFavorites.includes(tool.code)
);
// 按优先级顺序合并工具列表
const orderedTools = [...favoriteTools, ...commonTools, ...otherTools];
if (!isMounted) return;
// 设置总数
setLoadingProgress({ current: 0, total: orderedTools.length });
// 创建预加载队列
const prefetchQueue = async () => {
const newPrefetchedSet = new Set(prefetchedTools);
for (let i = 0; i < orderedTools.length; i++) {
if (!isMounted) return;
const tool = orderedTools[i];
// 如果已经预加载过,跳过
if (newPrefetchedSet.has(tool.code)) {
setLoadingProgress(prev => ({ ...prev, current: prev.current + 1 }));
continue;
}
try {
// 计算优先级:收藏 > 常用 > 其他
const isPriority = userFavorites.includes(tool.code) ||
(i < favoriteTools.length + commonTools.length);
// 使用 fetch 预加载页面内容
const prefetchUrl = `/tools/${tool.code}`;
const response = await fetch(prefetchUrl, {
priority: isPriority ? 'high' : 'low',
method: 'GET',
cache: 'default'
});
if (!isMounted) return;
if (response.ok) {
// 预解析响应以确保它被缓存
await response.text();
// 更新已预加载的工具集合
newPrefetchedSet.add(tool.code);
// 更新进度
setLoadingProgress(prev => ({ ...prev, current: prev.current + 1 }));
// 更新预加载状态
if (isMounted) {
setPrefetchedTools(prev => {
const updatedSet = new Set(prev);
updatedSet.add(tool.code);
return updatedSet;
});
}
} else {
// 即使请求不成功也更新进度
setLoadingProgress(prev => ({ ...prev, current: prev.current + 1 }));
}
} catch (error) {
console.warn(`批量预加载工具 ${tool.code} 失败:`, error);
// 即使失败也更新进度
setLoadingProgress(prev => ({ ...prev, current: prev.current + 1 }));
}
// 调整不同优先级工具的预加载延迟
if (i < orderedTools.length - 1) {
// 收藏与常用工具间隔短一些,其他工具间隔长一些
const delay = i < favoriteTools.length + commonTools.length ? 150 : 300;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
// 预加载完成后,在开发环境打印信息
if (process.env.NODE_ENV === 'development') {
console.log(`预加载完成! 收藏工具: ${favoriteTools.length}, 常用工具: ${commonTools.length}, 其他工具: ${otherTools.length}`);
}
};
// 开始预加载队列
prefetchQueue();
};
// 等待页面完全加载后开始预加载
if (document.readyState === 'complete') {
// 给页面内容完全渲染一些时间,然后开始批量预加载
setTimeout(startBatchPrefetch, 2000);
} else {
window.addEventListener('load', () => {
// 页面加载完成后延迟一段时间再开始预加载
setTimeout(startBatchPrefetch, 2000);
});
}
// 清理函数
return () => {
isMounted = false;
};
}, []);
// 添加预加载工具页面的函数
const prefetchTool = (toolCode: string) => {
// 如果已经预加载过,则不再重复预加载
if (prefetchedTools.has(toolCode)) {
return;
}
// 使用 fetch 预加载页面内容
const prefetchUrl = `/tools/${toolCode}`;
fetch(prefetchUrl, { priority: 'low' })
.then(response => {
if (!response.ok) {
throw new Error(`预加载失败: ${response.status}`);
}
return response.text();
})
.then(() => {
// 成功预加载后更新状态
setPrefetchedTools(prev => {
const newSet = new Set(prev);
newSet.add(toolCode);
return newSet;
});
})
.catch(error => {
console.warn(`工具 ${toolCode} 预加载失败:`, error);
});
};
// 处理工具导航并显示加载指示器
const navigateToTool = (toolCode: string) => {
// 检查是否已预加载 - 使用最新状态重新检查一次
const isPrefetched = prefetchedTools.has(toolCode);
// 检查是否是首次访问该工具
const isFirstVisit = !sessionStorage.getItem(`visited-${toolCode}`);
// 记录已访问工具
sessionStorage.setItem(`visited-${toolCode}`, 'true');
// 打印调试信息 - 开发环境
if (process.env.NODE_ENV === 'development') {
console.log(`导航到工具: ${toolCode}, 预加载状态: ${isPrefetched ? '已预加载' : '未预加载'}`);
}
// 如果已预加载,直接导航,不显示loading
if (isPrefetched) {
// 在导航前存储一个标志,表示这是从主页导航过来的
sessionStorage.setItem('from_homepage', 'true');
router.push(`/tools/${toolCode}`);
return;
}
// 在导航前存储一个标志,表示这是从主页导航过来的
sessionStorage.setItem('from_homepage', 'true');
const loadingId = 'loading-indicator-' + Date.now();
const loadingEl = document.createElement('div');
loadingEl.id = loadingId;
loadingEl.className = 'fixed inset-0 flex items-center justify-center bg-[rgba(0,0,0,0.3)] z-50 backdrop-blur-sm transition-opacity duration-300';
// 使用自适应主题的加载指示器
const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--color-bg-card').trim();
const borderColor = getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim();
const textColor = getComputedStyle(document.documentElement).getPropertyValue('--color-text-primary').trim();
loadingEl.innerHTML = `<div style="background-color: rgb(${bgColor}); border: 1px solid rgba(${borderColor}, 0.3);" class="p-4 rounded-lg shadow-lg">
<div class="flex items-center gap-3">
<div style="border-color: rgb(${borderColor}); border-top-color: transparent;" class="w-6 h-6 border-2 rounded-full animate-spin"></div>
<div style="color: rgb(${textColor});">加载中...</div>
</div>
</div>`;
document.body.appendChild(loadingEl);
const removeLoadingIndicator = () => {
const indicator = document.getElementById(loadingId);
if (indicator) {
indicator.classList.add('opacity-0');
setTimeout(() => {
indicator.remove();
}, 200);
}
};
// 立即导航
router.push(`/tools/${toolCode}`);
// 超短超时检测,针对缓存页面快速检测
setTimeout(() => {
// 如果页面已变化并且不是首次访问,很可能是使用了缓存,可以快速移除loading
if (window.location.pathname.includes(`/tools/${toolCode}`) && !isFirstVisit) {
removeLoadingIndicator();
return;
}
// 否则继续进行DOM检测
startDomCheck();
}, 20);
// 启动DOM监测
const startDomCheck = () => {
let checkCount = 0;
const maxChecks = isFirstVisit ? 20 : 10; // 首次访问检查更多次
const baseDelay = isFirstVisit ? 50 : 30; // 首次访问间隔更长
const checkForPageLoad = () => {
// 检查是否已经导航到新页面
if (window.location.pathname.includes(`/tools/${toolCode}`)) {
// 检查页面内容是否已经渲染
const content = document.querySelector('main') || document.querySelector('#tool-content');
if (content) {
removeLoadingIndicator();
return;
}
}
checkCount++;
if (checkCount < maxChecks) {
// 最开始快速检查,逐渐减慢检查频率
const delay = Math.min(baseDelay + checkCount * 5, isFirstVisit ? 100 : 60);
setTimeout(checkForPageLoad, delay);
}
};
// 立即开始检查
checkForPageLoad();
};
// 兜底保障,确保加载指示器一定会被移除
// 对于首次访问等待更长时间,后续访问缩短时间
setTimeout(removeLoadingIndicator, isFirstVisit ? 800 : 300);
};
// 图标加载处理
useEffect(() => {
// 设置小延迟确保图标已加载
const timer = setTimeout(() => {
setIconsLoaded(true);
}, 200);
return () => clearTimeout(timer);
}, []);
// 加载收藏工具和首次收藏状态
useEffect(() => {
const savedFavorites = localStorage.getItem('favoriteTools');
const hasSeenFirstFavoriteNotification = localStorage.getItem('hasSeenFirstFavoriteNotification') === 'true';
setFirstFavoriteAdded(hasSeenFirstFavoriteNotification);
if (savedFavorites) {
setFavoriteTools(JSON.parse(savedFavorites));
setShowFavorites(true);
}
}, []);
// 收藏/取消收藏工具
const toggleFavorite = (toolCode: string, e: React.MouseEvent) => {
e.stopPropagation();
// 检查是否要添加到收藏
const isAdding = !favoriteTools.includes(toolCode);
const updatedFavorites = isAdding
? [...favoriteTools, toolCode]
: favoriteTools.filter(code => code !== toolCode);
setFavoriteTools(updatedFavorites);
localStorage.setItem('favoriteTools', JSON.stringify(updatedFavorites));
// 更新收藏分类的显示状态
setShowFavorites(updatedFavorites.length > 0);
// 如果是添加收藏且是第一次收藏,显示通知
if (isAdding && !firstFavoriteAdded) {
setShowNotification(true);
setFirstFavoriteAdded(true);
localStorage.setItem('hasSeenFirstFavoriteNotification', 'true');
// 5秒后自动关闭通知
setTimeout(() => {
setShowNotification(false);
}, 5000);
}
};
// 关闭通知
const closeNotification = () => {
setShowNotification(false);
};
// 切换到收藏分类
const viewFavorites = () => {
setActiveCategory('favorites');
};
// 过滤工具列表
const filteredTools = () => tools.filter(tool => {
// 根据搜索词过滤
if (searchTerm && !t(`tools.${tool.code}.title`).toLowerCase().includes(searchTerm.toLowerCase()) &&
!t(`tools.${tool.code}.description`).toLowerCase().includes(searchTerm.toLowerCase()) &&
!tool.keywords?.some(keyword => keyword.toLowerCase().includes(searchTerm.toLowerCase()))) {
return false;
}
// 过滤收藏分类
if (activeCategory === 'favorites') {
return favoriteTools.includes(tool.code);
}
// 根据类别过滤
if (activeCategory !== "all" && !tool.category.includes(activeCategory)) {
return false;
}
return true;
}).sort((a, b) => {
// 搜索优先级排序
if (searchTerm) {
// 计算匹配得分 (越高越靠前)
const getScore = (tool: typeof tools[0]) => {
let score = 0;
const term = searchTerm.toLowerCase();
const title = t(`tools.${tool.code}.title`).toLowerCase();
const description = t(`tools.${tool.code}.description`).toLowerCase();
// 标题匹配权重最高
if (title.includes(term)) {
score += 100;
// 标题精确匹配给额外加分
if (title === term) {
score += 50;
}
}
// 关键词匹配权重次之
if (tool.keywords?.some(keyword => keyword.toLowerCase() === term)) {
// 关键词精确匹配
score += 80;
} else if (tool.keywords?.some(keyword => keyword.toLowerCase().includes(term))) {
// 关键词部分匹配
score += 60;
}
// 描述匹配权重稍低
if (description.includes(term)) {
score += 40;
}
return score;
};
const scoreA = getScore(a);
const scoreB = getScore(b);
// 按分数降序排列
if (scoreA !== scoreB) {
return scoreB - scoreA;
}
}
// 在全部工具视图中,将常用工具排在前面
if (activeCategory === "all") {
const aIsCommon = a.category.includes('common');
const bIsCommon = b.category.includes('common');
if (aIsCommon && !bIsCommon) return -1;
if (!aIsCommon && bIsCommon) return 1;
}
return 0;
});
// 构建分类列表(添加动态的"我的收藏"分类)
const allCategories = [
...categories.slice(0, 2), // 全部工具和常用工具
...(showFavorites ? [{ code: "favorites", name: t('common.favorites'), active: false }] : []),
...categories.slice(2) // 其余分类
];
// 更新分类选择处理函数
const handleCategoryChange = (categoryCode: string) => {
setActiveCategory(categoryCode);
// 保存到 sessionStorage
sessionStorage.setItem('lastActiveCategory', categoryCode);
};
return (
<div className={`min-h-screen flex flex-col max-w-[1440px] mx-auto px-4 sm:px-8 py-4 sm:py-6 ${!iconsLoaded ? 'opacity-90' : 'opacity-100 transition-opacity duration-300'}`}>
{/* 通知提示 */}
{showNotification && (
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 flex items-center gap-2 bg-gradient-to-r from-[rgb(var(--color-primary))] to-[rgb(var(--color-primary-hover))] text-white px-4 py-3 rounded-lg shadow-lg animate-fadeIn">
<span>&quot;&quot;</span>
<button
className="ml-2 text-white hover:text-[#F1F5F9] transition-colors"
onClick={closeNotification}
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
)}
{/* 添加预加载进度提示(仅在开发环境显示) */}
{process.env.NODE_ENV === 'development' && loadingProgress.total > 0 && (
<div className="fixed bottom-4 left-4 z-40 bg-[rgb(var(--color-bg-card))] border border-[rgba(var(--color-primary),0.3)] rounded-lg p-2 text-xs opacity-80">
<div className="flex items-center gap-2">
<div>: {loadingProgress.current}/{loadingProgress.total}</div>
<div className="w-20 h-1.5 bg-[rgb(var(--color-bg-secondary))] rounded-full overflow-hidden">
<div
className="h-full bg-[rgb(var(--color-primary))]"
style={{width: `${(loadingProgress.current / loadingProgress.total) * 100}%`}}
></div>
</div>
</div>
</div>
)}
{/* 头部 */}
<header className="sticky top-0 z-10 backdrop-blur-md border-b border-[rgba(var(--color-primary),0.1)] bg-[rgba(var(--color-bg-secondary),0.8)]">
<div className="container mx-auto px-4 py-3 flex flex-col md:flex-row md:items-center md:justify-between">
<div className="flex items-center justify-between">
<div className="flex items-center">
<h1 className="text-2xl font-bold text-[rgb(var(--color-primary))]">
{t('common.siteName')}
</h1>
<span className="mx-2 text-sm text-[rgb(var(--color-text-secondary))]">|</span>
<p className="text-sm text-[rgb(var(--color-text-secondary))]">{t('common.siteDesc')}</p>
</div>
<div className="flex items-center space-x-2 md:hidden">
<button
className="btn-primary w-8 h-8 md:w-10 md:h-10 rounded-full flex items-center justify-center"
onClick={viewFavorites}
title={t('common.favorites')}
>
<FontAwesomeIcon icon={faStar} className="text-sm md:text-base" />
</button>
<div className="w-px h-6 bg-[rgba(var(--color-text-secondary),0.2)]"></div>
<LanguageToggle />
<ThemeToggle />
{language === 'zh' && (
<>
<div className="w-px h-6 bg-[rgba(var(--color-text-secondary),0.2)]"></div>
<div className="relative">
<button
className="btn-secondary w-8 h-8 md:w-10 md:h-10 rounded-full flex items-center justify-center relative"
onClick={toggleProductsDropdown}
title={t('common.productRecommend')}
>
<FontAwesomeIcon icon={faExternalLinkAlt} className="text-sm md:text-base" />
<FontAwesomeIcon icon={faChevronDown} className="absolute text-[0.5rem] md:text-[0.6rem] bottom-0.5 md:bottom-1 right-0.5 md:right-1" />
</button>
</div>
</>
)}
<a
href="https://github.com/star7th/jisuxiang"
target="_blank"
rel="noopener noreferrer"
className="btn-secondary w-8 h-8 md:w-10 md:h-10 rounded-full flex items-center justify-center"
title="GitHub"
>
<FontAwesomeIcon icon={faGithub} className="text-sm md:text-base" />
</a>
</div>
</div>
<div className="mt-3 md:mt-0 relative w-full md:w-auto flex-1 md:max-w-md">
<div className="relative">
<input
type="text"
placeholder={t('common.search')}
className="w-full bg-[rgb(var(--color-bg-card))] rounded-full pl-10 pr-4 py-2 text-[rgb(var(--color-text-primary))] outline-none focus:ring-2 ring-[rgb(var(--color-primary))] transition-shadow shadow-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<FontAwesomeIcon
icon={faSearch}
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[rgb(var(--color-text-secondary))]"
/>
{searchTerm && (
<button
className="absolute right-3 top-1/2 transform -translate-y-1/2"
onClick={() => setSearchTerm('')}
>
<FontAwesomeIcon
icon={faTimes}
className="text-[rgb(var(--color-text-secondary))] hover:text-[rgb(var(--color-text-primary))] transition-colors"
/>
</button>
)}
</div>
</div>
<div className="hidden md:flex items-center space-x-3">
<button
className="btn-primary w-10 h-10 rounded-full flex items-center justify-center group relative"
onClick={viewFavorites}
title={t('common.favorites')}
>
<FontAwesomeIcon icon={faStar} />
<span className="absolute left-1/2 -translate-x-1/2 top-full mt-2 px-2 py-1 rounded-md whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity duration-300 text-sm z-10"
style={{
backgroundColor: 'rgb(var(--color-bg-secondary))',
color: 'rgb(var(--color-text-primary))'
}}>
{t('common.favorites')}
</span>
</button>
<div className="w-px h-6 bg-[rgba(var(--color-text-secondary),0.2)]"></div>
<LanguageToggle />
<ThemeToggle />
<div className="w-px h-6 bg-[rgba(var(--color-text-secondary),0.2)]"></div>
{/* GitHub链接按钮 */}
<a
href="https://github.com/star7th/jisuxiang"
target="_blank"
rel="noopener noreferrer"
className="btn-secondary w-10 h-10 rounded-full flex items-center justify-center group relative"
title="GitHub"
>
<FontAwesomeIcon icon={faGithub} />
<span className="absolute left-1/2 -translate-x-1/2 top-full mt-2 px-2 py-1 rounded-md whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity duration-300 text-sm z-10"
style={{
backgroundColor: 'rgb(var(--color-bg-secondary))',
color: 'rgb(var(--color-text-primary))'
}}>
GitHub
</span>
</a>
{/* 产品推荐下拉菜单 - 仅在中文语言下显示 */}
{language === 'zh' && (
<div id="products-dropdown-container" className="relative">
<button
className="btn-secondary w-10 h-10 rounded-full flex items-center justify-center group relative"
onClick={toggleProductsDropdown}
title={t('common.productRecommend')}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<FontAwesomeIcon icon={faChevronDown} className={`absolute text-[0.6rem] bottom-1 right-1 transition-transform duration-200 ${showProductsDropdown ? 'rotate-180' : ''}`} />
<span className="absolute left-1/2 -translate-x-1/2 top-full mt-2 px-2 py-1 rounded-md whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity duration-300 text-sm z-10"
style={{
backgroundColor: 'rgb(var(--color-bg-secondary))',
color: 'rgb(var(--color-text-primary))'
}}>
{t('common.productRecommend')}
</span>
</button>
{showProductsDropdown && (
<div className="absolute right-0 top-full mt-1 w-56 rounded-md shadow-lg border z-50 animate-fadeIn overflow-hidden"
style={{
backgroundColor: 'rgb(var(--color-bg-card))',
borderColor: 'rgba(var(--color-primary), 0.2)'
}}>
<div className="py-1">
{recommendedProducts.map((product, index) => (
<a
key={index}
href={product.url}
target="_blank"
rel="noopener noreferrer"
className="block px-4 py-3 hover:bg-[rgba(var(--color-primary),0.1)] transition-colors"
style={{color: 'rgb(var(--color-text-primary))'}}
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center" style={{backgroundColor: 'rgba(var(--color-primary), 0.1)'}}>
<FontAwesomeIcon icon={product.icon} style={{color: 'rgb(var(--color-primary))'}} />
</div>
<div>
<div className="font-medium">{product.title}</div>
<div className="text-xs mt-1" style={{color: 'rgb(var(--color-text-tertiary))'}}>{product.description}</div>
</div>
</div>
</a>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
</header>
{/* 主内容区域 */}
<main className="flex-1 container mx-auto">
{/* 分类导航 - 独立导航栏 */}
<div className="flex flex-wrap nav-bar gap-2 sm:gap-4 mb-6 sm:mb-8 mt-6">
{allCategories.map((category, index) => (
<button
key={index}
className={`px-4 py-2 rounded-button whitespace-nowrap transition-all ${
activeCategory === category.code
? 'bg-gradient-to-r from-[rgb(var(--color-primary))] to-[rgb(var(--color-primary-hover))] text-white shadow-sm'
: 'btn-secondary'
}`}
onClick={() => handleCategoryChange(category.code)}
>
{t(`categories.${category.code}`)}
</button>
))}
</div>
{/* 工具列表 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6">
{filteredTools().map((tool, index) => (
<div
key={index}
className="card p-6 flex flex-col cursor-pointer min-h-[170px]"
onClick={() => navigateToTool(tool.code)}
onMouseEnter={() => prefetchTool(tool.code)}
>
{/* 添加不可见的 Link 组件用于 Next.js 原生预加载 */}
<Link
href={`/tools/${tool.code}`}
prefetch={true}
className="hidden"
aria-hidden="true"
/>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<div className="icon-container w-10 h-10 flex-shrink-0">
<FontAwesomeIcon icon={tool.icon} className="icon" />
</div>
<div>
<h3 className="text-lg font-medium" style={{color: 'rgb(var(--color-text-primary))'}}>{t(`tools.${tool.code}.title`)}</h3>
<div className="flex flex-wrap gap-1 mt-2">
{tool.category.map((catCode, catIndex) => (
<span
key={catIndex}
className="category-tag"
>
{t(`categories.${catCode}`)}
</span>
))}
</div>
</div>
</div>
<button
className="transition-colors"
style={{color: 'rgb(var(--color-text-tertiary))'}}
onClick={(e) => toggleFavorite(tool.code, e)}
>
<FontAwesomeIcon
icon={favoriteTools.includes(tool.code) ? faStar : farStar}
className={favoriteTools.includes(tool.code) ? 'text-[rgb(var(--color-primary-light))]' : ''}
/>
</button>
</div>
<p className="text-sm mt-auto" style={{color: 'rgb(var(--color-text-secondary))'}}>{t(`tools.${tool.code}.description`)}</p>
</div>
))}
</div>
</main>
{/* 底部 */}
<footer className="mt-auto py-6 sm:py-8 border-t" style={{borderColor: 'rgb(var(--color-bg-secondary))'}}>
<div className="flex justify-center items-center text-sm" style={{color: 'rgb(var(--color-text-tertiary))'}}>
<span> </span>
<a
href="https://github.com/star7th/jisuxiang"
target="_blank"
rel="noopener noreferrer"
className="mx-1 text-[rgb(var(--color-primary-light))] hover:underline transition-colors"
>
</a>
<span> </span>
</div>
</footer>
</div>
);
}
+744
View File
@@ -0,0 +1,744 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFileCode, faCopy, faCheck, faRedo, faDownload, faExclamationTriangle, faInfoCircle, faCode } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import { useLanguage } from '@/context/LanguageContext';
// 删除未使用的导入
// import BackToTop from '@/components/BackToTop';
// import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
// import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
// import tools from '@/config/tools';
// 添加CSS变量样式
const _styles = {
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
card: "card p-6",
textarea: "w-full h-64 p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple transition-all font-mono resize-y",
label: "text-sm text-secondary font-medium",
error: "p-3 bg-red-900/20 border border-red-700/30 rounded-lg text-error",
info: "text-sm text-tertiary",
success: "p-3 bg-green-900/20 border border-green-700/30 rounded-lg text-success",
actionBtn: "btn-secondary flex items-center gap-2",
actionBtnPrimary: "btn-primary flex items-center gap-2",
loading: "text-purple animate-pulse",
tabButton: "px-3 py-2 text-sm font-medium rounded-md transition-all",
tabButtonActive: "bg-gradient-to-r from-[rgb(var(--color-primary))] to-[rgb(var(--color-primary-hover))] text-white shadow-sm",
tabButtonInactive: "btn-secondary",
tabContainer: "flex items-center rounded-md p-1 bg-block-strong",
flexBetween: "flex flex-col sm:flex-row gap-4 justify-between items-center",
highlighter: "rounded-lg overflow-hidden text-sm font-mono border border-purple-glow/10",
};
// 代码语言类型
type CodeLanguage =
| 'javascript'
| 'typescript'
| 'jsx'
| 'tsx'
| 'html'
| 'css'
| 'json'
| 'markdown'
| 'yaml'
| 'graphql'
| 'sql';
// 语言配置
const languages: { [key in CodeLanguage]: { name: string; parser: string; tabWidth: number } } = {
javascript: { name: 'JavaScript', parser: 'babel', tabWidth: 2 },
typescript: { name: 'TypeScript', parser: 'typescript', tabWidth: 2 },
jsx: { name: 'JSX', parser: 'babel', tabWidth: 2 },
tsx: { name: 'TSX', parser: 'typescript', tabWidth: 2 },
html: { name: 'HTML', parser: 'html', tabWidth: 2 },
css: { name: 'CSS', parser: 'css', tabWidth: 2 },
json: { name: 'JSON', parser: 'json', tabWidth: 2 },
markdown: { name: 'Markdown', parser: 'markdown', tabWidth: 2 },
yaml: { name: 'YAML', parser: 'yaml', tabWidth: 2 },
graphql: { name: 'GraphQL', parser: 'graphql', tabWidth: 2 },
sql: { name: 'SQL', parser: 'sql', tabWidth: 2 },
};
// 格式化选项
interface FormatOptions {
printWidth: number;
tabWidth: number;
useTabs: boolean;
semi: boolean;
singleQuote: boolean;
trailingComma: 'none' | 'es5' | 'all';
bracketSpacing: boolean;
arrowParens: 'avoid' | 'always';
proseWrap: 'always' | 'never' | 'preserve';
}
// 添加全局接口,使prettier可以在window上使用
declare global {
interface Window {
prettier: {
format: (source: string, options: Record<string, unknown>) => string;
[key: string]: unknown;
};
prettierPlugins: {
[key: string]: unknown;
};
}
}
// prettier解析器映射 (这是实际会用到的映射)
const _parserMapping: Record<string, string> = {
javascript: 'babel',
typescript: 'typescript',
html: 'html',
css: 'css',
json: 'json',
xml: 'xml',
sql: 'sql',
java: 'java',
python: 'python',
cpp: 'cpp',
csharp: 'csharp',
php: 'php',
yaml: 'yaml',
markdown: 'markdown',
jsx: 'babel',
golang: 'go',
bash: 'bash',
rust: 'rust'
};
// 加载prettier和对应插件的脚本映射
const _scriptMapping: Record<string, string[]> = {
javascript: ['/lib/prettier/standalone.js', '/lib/prettier/parser-babel.js'],
typescript: ['/lib/prettier/standalone.js', '/lib/prettier/parser-typescript.js'],
html: ['/lib/prettier/standalone.js', '/lib/prettier/parser-html.js'],
css: ['/lib/prettier/standalone.js', '/lib/prettier/parser-postcss.js'],
json: ['/lib/prettier/standalone.js', '/lib/prettier/parser-babel.js'],
xml: ['/lib/prettier/standalone.js', '/lib/prettier/parser-xml.js'],
sql: ['/lib/prettier/standalone.js', '/lib/prettier/parser-sql.js'],
java: ['/lib/prettier/standalone.js', '/lib/prettier/parser-java.js'],
python: ['/lib/prettier/standalone.js', '/lib/prettier/parser-python.js'],
cpp: ['/lib/prettier/standalone.js', '/lib/prettier/parser-cpp.js'],
csharp: ['/lib/prettier/standalone.js', '/lib/prettier/parser-csharp.js'],
php: ['/lib/prettier/standalone.js', '/lib/prettier/parser-php.js'],
yaml: ['/lib/prettier/standalone.js', '/lib/prettier/parser-yaml.js'],
markdown: ['/lib/prettier/standalone.js', '/lib/prettier/parser-markdown.js'],
jsx: ['/lib/prettier/standalone.js', '/lib/prettier/parser-babel.js'],
golang: ['/lib/prettier/standalone.js', '/lib/prettier/parser-go.js'],
bash: ['/lib/prettier/standalone.js', '/lib/prettier/parser-bash.js'],
rust: ['/lib/prettier/standalone.js', '/lib/prettier/parser-rust.js']
};
// CDN备选地址 (保留,以便后续按需加载)
const _cdnScriptMapping: Record<string, string[]> = {
javascript: [
'https://cdn.jsdelivr.net/npm/[email protected]/standalone.js',
'https://cdn.jsdelivr.net/npm/[email protected]/parser-babel.js'
],
typescript: [
'https://cdn.jsdelivr.net/npm/[email protected]/standalone.js',
'https://cdn.jsdelivr.net/npm/[email protected]/parser-typescript.js'
],
html: [
'https://cdn.jsdelivr.net/npm/[email protected]/standalone.js',
'https://cdn.jsdelivr.net/npm/[email protected]/parser-html.js'
],
css: [
'https://cdn.jsdelivr.net/npm/[email protected]/standalone.js',
'https://cdn.jsdelivr.net/npm/[email protected]/parser-postcss.js'
],
json: [
'https://cdn.jsdelivr.net/npm/[email protected]/standalone.js',
'https://cdn.jsdelivr.net/npm/[email protected]/parser-babel.js'
],
yaml: [
'https://cdn.jsdelivr.net/npm/[email protected]/standalone.js',
'https://cdn.jsdelivr.net/npm/[email protected]/parser-yaml.js'
],
markdown: [
'https://cdn.jsdelivr.net/npm/[email protected]/standalone.js',
'https://cdn.jsdelivr.net/npm/[email protected]/parser-markdown.js'
],
jsx: [
'https://cdn.jsdelivr.net/npm/[email protected]/standalone.js',
'https://cdn.jsdelivr.net/npm/[email protected]/parser-babel.js'
],
tsx: [
'https://cdn.jsdelivr.net/npm/[email protected]/standalone.js',
'https://cdn.jsdelivr.net/npm/[email protected]/parser-typescript.js'
],
graphql: [
'https://cdn.jsdelivr.net/npm/[email protected]/standalone.js',
'https://cdn.jsdelivr.net/npm/[email protected]/parser-graphql.js'
]
};
export default function CodeFormatter() {
const { t } = useLanguage();
// 输入与输出
const [inputCode, setInputCode] = useState('');
const [outputCode, setOutputCode] = useState('');
const [selectedLanguage, setSelectedLanguage] = useState<CodeLanguage>('javascript');
// 格式化选项 - 使用默认值,不再提供UI界面修改
const [formatOptions, setFormatOptions] = useState<FormatOptions>({
printWidth: 80,
tabWidth: 2,
useTabs: false,
semi: true,
singleQuote: false,
trailingComma: 'es5',
bracketSpacing: true,
arrowParens: 'always',
proseWrap: 'preserve',
});
// 其他状态
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [isFormatting, setIsFormatting] = useState(false);
const [loadingModules, setLoadingModules] = useState(false);
const [prettierLoaded, setPrettierLoaded] = useState(false);
const [fileMissingWarning, setFileMissingWarning] = useState<string | null>(null);
// 检查Prettier所需的文件是否存在
useEffect(() => {
// 检查核心库
fetch('/lib/prettier/standalone.js')
.then(res => {
if (!res.ok) {
setFileMissingWarning(t('tools.code_formatter.warning_missing_files'));
}
})
.catch(() => {
setFileMissingWarning(t('tools.code_formatter.warning_missing_files'));
});
}, [t]);
// 动态加载Prettier库
useEffect(() => {
if (typeof window === 'undefined' || prettierLoaded) return;
setLoadingModules(true);
// 检查Prettier全局对象是否已存在
if (window.prettier && window.prettierPlugins) {
console.log('Prettier已加载,使用已有实例');
setPrettierLoaded(true);
setLoadingModules(false);
return;
}
// 动态创建脚本标签加载prettier库
const loadPrettier = () => {
// 创建prettier核心脚本
const prettierScript = document.createElement('script');
prettierScript.src = '/lib/prettier/standalone.js';
prettierScript.async = true;
prettierScript.onload = () => {
console.log(t('tools.code_formatter.prettier_core_loaded'));
// 创建babel解析器脚本(也包含estree插件)
const babelScript = document.createElement('script');
babelScript.src = '/lib/prettier/parser-babel.js';
babelScript.async = true;
babelScript.onload = () => {
console.log(t('tools.code_formatter.babel_parser_loaded'));
// 创建html解析器脚本
const htmlScript = document.createElement('script');
htmlScript.src = '/lib/prettier/parser-html.js';
htmlScript.async = true;
htmlScript.onload = () => {
console.log(t('tools.code_formatter.html_parser_loaded'));
// 创建postcss解析器脚本
const cssScript = document.createElement('script');
cssScript.src = '/lib/prettier/parser-postcss.js';
cssScript.async = true;
cssScript.onload = () => {
console.log(t('tools.code_formatter.css_parser_loaded'));
// 创建typescript解析器脚本
const tsScript = document.createElement('script');
tsScript.src = '/lib/prettier/parser-typescript.js';
tsScript.async = true;
tsScript.onload = () => {
console.log(t('tools.code_formatter.typescript_parser_loaded'));
// 创建markdown解析器脚本
const mdScript = document.createElement('script');
mdScript.src = '/lib/prettier/parser-markdown.js';
mdScript.async = true;
mdScript.onload = () => {
console.log(t('tools.code_formatter.markdown_parser_loaded'));
// 创建yaml解析器脚本
const yamlScript = document.createElement('script');
yamlScript.src = '/lib/prettier/parser-yaml.js';
yamlScript.async = true;
yamlScript.onload = () => {
console.log(t('tools.code_formatter.yaml_parser_loaded'));
// 创建graphql解析器脚本
const graphqlScript = document.createElement('script');
graphqlScript.src = '/lib/prettier/parser-graphql.js';
graphqlScript.async = true;
graphqlScript.onload = () => {
console.log(t('tools.code_formatter.graphql_parser_loaded'));
// 所有脚本加载完成
console.log(t('tools.code_formatter.all_modules_loaded'));
setPrettierLoaded(true);
setLoadingModules(false);
};
document.body.appendChild(graphqlScript);
};
document.body.appendChild(yamlScript);
};
document.body.appendChild(mdScript);
};
document.body.appendChild(tsScript);
};
document.body.appendChild(cssScript);
};
document.body.appendChild(htmlScript);
};
document.body.appendChild(babelScript);
};
prettierScript.onerror = (error) => {
console.error(t('tools.code_formatter.load_error'), error);
setError(t('tools.code_formatter.load_failed'));
setLoadingModules(false);
};
document.body.appendChild(prettierScript);
}
// 调用加载函数
loadPrettier();
return () => {
// 清理函数不需要移除脚本,因为它们会一直被缓存和重用
};
}, [prettierLoaded, t]);
// 语言改变时更新 tabWidth
useEffect(() => {
// 根据语言自动设置tabWidth
setFormatOptions(prev => ({
...prev,
tabWidth: languages[selectedLanguage].tabWidth,
}));
}, [selectedLanguage]);
// 格式化代码函数
const formatCode = async () => {
if (!inputCode.trim()) {
setError(t('tools.code_formatter.error_empty_input'));
setOutputCode('');
return;
}
setIsFormatting(true);
setError(null);
try {
// 确保在客户端环境
if (typeof window === 'undefined') {
throw new Error(t('tools.code_formatter.error_browser_only'));
}
// 确保prettier已加载
if (!window.prettier || !window.prettierPlugins) {
throw new Error(t('tools.code_formatter.error_library_loading'));
}
// 获取当前语言的解析器
const parser = languages[selectedLanguage]?.parser;
if (!parser) {
throw new Error(t('tools.code_formatter.error_unsupported_language').replace('{language}', selectedLanguage));
}
// 处理特殊情况
let actualParser = parser;
if (parser === 'json') {
actualParser = 'json';
}
// SQL格式化特殊处理,使用babel解析器
if (parser === 'sql') {
try {
// 简单的SQL格式化处理
const sqlFormatter = inputCode
.replace(/\s+/g, ' ')
.replace(/\(\s+/g, '(')
.replace(/\s+\)/g, ')')
.replace(/\s*,\s*/g, ', ')
.replace(/\s*=\s*/g, ' = ')
.replace(/\s*>\s*/g, ' > ')
.replace(/\s*<\s*/g, ' < ')
.replace(/\s*>\s*=\s*/g, ' >= ')
.replace(/\s*<\s*=\s*/g, ' <= ')
.replace(/\s*!=\s*/g, ' != ')
.replace(/\s*<>\s*/g, ' <> ')
.replace(/SELECT/gi, 'SELECT\n ')
.replace(/FROM/gi, '\nFROM\n ')
.replace(/WHERE/gi, '\nWHERE\n ')
.replace(/GROUP BY/gi, '\nGROUP BY\n ')
.replace(/HAVING/gi, '\nHAVING\n ')
.replace(/ORDER BY/gi, '\nORDER BY\n ')
.replace(/LIMIT/gi, '\nLIMIT ')
.replace(/JOIN/gi, '\nJOIN\n ')
.replace(/UNION/gi, '\n\nUNION\n\n')
.replace(/INSERT INTO/gi, 'INSERT INTO\n ')
.replace(/VALUES/gi, '\nVALUES\n ')
.replace(/UPDATE/gi, 'UPDATE\n ')
.replace(/SET/gi, '\nSET\n ')
.replace(/DELETE FROM/gi, 'DELETE FROM\n ')
.replace(/CREATE TABLE/gi, 'CREATE TABLE\n ')
.replace(/ALTER TABLE/gi, 'ALTER TABLE\n ')
.replace(/DROP TABLE/gi, 'DROP TABLE\n ')
.replace(/AND/gi, '\n AND')
.replace(/OR/gi, '\n OR')
.replace(/ON/gi, '\n ON')
.replace(/\n\s*\n/g, '\n')
.trim();
setOutputCode(sqlFormatter);
return;
} catch (sqlError) {
console.error('SQL格式化错误:', sqlError);
}
}
try {
console.log(t('tools.code_formatter.using_parser'), actualParser);
console.log(t('tools.code_formatter.available_plugins'), Object.keys(window.prettierPlugins));
// 使用全局prettier对象格式化代码
let formattedCode = '';
// 格式化选项
const options = {
parser: actualParser,
plugins: window.prettierPlugins,
printWidth: formatOptions.printWidth,
tabWidth: formatOptions.tabWidth,
useTabs: formatOptions.useTabs,
semi: formatOptions.semi,
singleQuote: formatOptions.singleQuote,
trailingComma: formatOptions.trailingComma,
bracketSpacing: formatOptions.bracketSpacing,
arrowParens: formatOptions.arrowParens,
proseWrap: formatOptions.proseWrap
};
formattedCode = window.prettier.format(inputCode, options);
// 更新输出
setOutputCode(formattedCode);
} catch (prettierError) {
console.error(t('tools.code_formatter.prettier_error_log'), prettierError);
throw new Error(t('tools.code_formatter.error_prettier').replace('{message}', (prettierError as Error)?.message || t('tools.code_formatter.error_initialization')));
}
} catch (err) {
console.error(t('tools.code_formatter.error_formatting').replace('{message}', 'Error'), err);
setError(t('tools.code_formatter.error_formatting').replace('{message}', (err as Error).message || t('tools.code_formatter.error_unknown')));
setOutputCode('');
} finally {
setIsFormatting(false);
}
};
// 复制格式化后的代码
const copyFormattedCode = () => {
if (!outputCode) return;
navigator.clipboard.writeText(outputCode)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => console.error(t('tools.code_formatter.copy_failed'), err));
};
// 下载格式化后的代码
const downloadFormattedCode = () => {
if (!outputCode) return;
// 确定文件扩展名
let extension = '.txt';
switch (selectedLanguage) {
case 'javascript':
extension = '.js';
break;
case 'typescript':
extension = '.ts';
break;
case 'jsx':
extension = '.jsx';
break;
case 'tsx':
extension = '.tsx';
break;
case 'html':
extension = '.html';
break;
case 'css':
extension = '.css';
break;
case 'json':
extension = '.json';
break;
case 'markdown':
extension = '.md';
break;
case 'yaml':
extension = '.yaml';
break;
case 'graphql':
extension = '.graphql';
break;
case 'sql':
extension = '.sql';
break;
}
// 创建并下载文件
const blob = new Blob([outputCode], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `formatted_code${extension}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// 清除所有内容
const clearAll = () => {
setInputCode('');
setOutputCode('');
setError(null);
};
// 示例代码
const getExampleCode = () => {
switch (selectedLanguage) {
case 'javascript':
return `function add(a,b) {return a+b;}\nconst x={foo:"bar",baz:42,qux:true};\nconsole.log(add(1,2));`;
case 'typescript':
return `function greet(name: string): string {return "Hello, " + name;}\ninterface User {id: number; name: string; isActive: boolean;}\nconst user: User = {id: 1,name: "John",isActive: true};`;
case 'jsx':
return `function App() {return (<div className="container"><header><h1>Hello World</h1></header><main><p>Welcome to my app</p></main></div>);}`;
case 'tsx':
return `interface Props {name: string;}\nfunction Greeting({name}: Props) {return <h1>Hello, {name}!</h1>;}\nconst App = () => (<div><Greeting name="World" /><p>Welcome to TypeScript and React</p></div>);`;
case 'html':
return `<!DOCTYPE html><html><head><title>Document</title></head><body><div><h1>Hello World</h1><p>This is a paragraph</p></div></body></html>`;
case 'css':
return `.container { width: 100%; max-width: 1200px; margin: 0 auto; }\n.header { background-color: #f0f0f0; padding: 20px; }\n.button { display: inline-block; padding: 10px 15px; background: #4285f4; color: white; border-radius: 4px; }`;
case 'json':
return `{"name":"John","age":30,"isStudent":false,"courses":["Math","English","Science"],"address":{"street":"123 Main St","city":"Anytown","zip":"12345"}}`;
case 'markdown':
return `# Heading\n## Subheading\nThis is a paragraph with **bold** and *italic* text.\n- List item 1\n- List item 2\n> This is a blockquote.\n\`\`\`\ncode block\n\`\`\``;
case 'yaml':
return `server:\n port: 8080\n host: localhost\ndatabase:\n url: jdbc:mysql://localhost:3306/mydb\n username: root\n password: secret\nlogging:\n level: INFO`;
case 'graphql':
return `type Query {\n user(id: ID!): User\n users: [User!]!\n}\n\ntype User {\n id: ID!\n name: String!\n email: String\n posts: [Post!]\n}\n\ntype Post {\n id: ID!\n title: String!\n content: String\n author: User!\n}`;
case 'sql':
return `SELECT u.id, u.name, u.email, COUNT(o.id) as order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.status = 'active' AND o.created_at >= '2023-01-01' GROUP BY u.id, u.name, u.email HAVING COUNT(o.id) > 0 ORDER BY order_count DESC LIMIT 10;`;
default:
return `// 请输入要格式化的代码`;
}
};
// 加载示例代码
const loadExample = () => {
setInputCode(getExampleCode());
setOutputCode('');
setError(null);
};
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
<ToolHeader
title={t('tools.code_formatter.title')}
description={t('tools.code_formatter.description')}
icon={faFileCode}
toolCode="code_formatter"
/>
{/* 主内容区 */}
<div className="space-y-6">
{/* 控制面板 */}
<div className="card p-4 overflow-hidden">
<div className="flex flex-wrap items-center justify-between gap-4 mb-4">
<div className="flex items-center gap-4">
<h2 className="text-md font-medium text-primary">{t('tools.code_formatter.title')}</h2>
{/* 语言选择 */}
<div className="relative">
<select
value={selectedLanguage}
onChange={(e) => setSelectedLanguage(e.target.value as CodeLanguage)}
className="bg-block text-primary border border-purple-glow/30 rounded-md px-3 py-1.5 text-sm appearance-none pr-8"
>
{Object.entries(languages).map(([key, { name }]) => (
<option key={key} value={key}>{name}</option>
))}
</select>
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-tertiary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 11l-4-4h8l-4 4z" />
</svg>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
className="btn-secondary text-xs px-3 py-1.5"
onClick={loadExample}
>
{t('tools.code_formatter.load_example')}
</button>
<button
className="btn-secondary text-xs px-3 py-1.5"
onClick={clearAll}
>
{t('tools.code_formatter.clear')}
</button>
</div>
</div>
{/* 移除格式化选项面板 */}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 输入区域 */}
<div className="flex flex-col h-full">
<h2 className="text-md font-medium text-primary mb-2">{t('tools.code_formatter.input_code')}</h2>
<div className="relative flex-grow">
<textarea
value={inputCode}
onChange={(e) => setInputCode(e.target.value)}
placeholder={t('tools.code_formatter.input_placeholder').replace('{language}', languages[selectedLanguage].name)}
className="bg-block text-primary border border-purple-glow/30 rounded-md p-4 w-full h-[400px] font-mono text-sm resize-none"
/>
</div>
</div>
{/* 输出区域 */}
<div className="flex flex-col h-full">
<div className="flex items-center justify-between mb-2">
<h2 className="text-md font-medium text-primary">{t('tools.code_formatter.formatted_result')}</h2>
<div className="flex items-center gap-2">
<button
className="text-tertiary hover:text-purple transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={copyFormattedCode}
disabled={!outputCode}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} className="mr-1" />
{copied ? t('tools.code_formatter.copied') : t('tools.code_formatter.copy')}
</button>
<button
className="text-tertiary hover:text-purple transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={downloadFormattedCode}
disabled={!outputCode}
>
<FontAwesomeIcon icon={faDownload} className="mr-1" />
{t('tools.code_formatter.download')}
</button>
</div>
</div>
<div className="relative flex-grow">
{error ? (
<div className="bg-red-900/20 border border-red-700/30 rounded-md p-4 text-sm text-error flex items-start gap-2">
<FontAwesomeIcon icon={faExclamationTriangle} className="mt-0.5" />
<div>
<p className="font-medium mb-1">{t('tools.code_formatter.formatting_error_title')}</p>
<p>{error}</p>
</div>
</div>
) : outputCode ? (
<pre className="bg-block text-primary border border-purple-glow/30 rounded-md p-4 w-full h-[400px] font-mono text-sm overflow-auto whitespace-pre">{outputCode}</pre>
) : (
<div className="bg-block text-tertiary border border-purple-glow/30 rounded-md p-4 w-full h-[400px] flex flex-col items-center justify-center text-center">
<FontAwesomeIcon icon={faCode} className="text-3xl mb-2" />
<p>{t('tools.code_formatter.result_placeholder')}</p>
<p className="text-xs mt-2">{t('tools.code_formatter.click_format')}</p>
</div>
)}
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="flex justify-center mt-6">
<button
className="btn-primary px-6 py-2 flex items-center"
onClick={formatCode}
disabled={isFormatting || !inputCode || !prettierLoaded}
>
{isFormatting ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{t('tools.code_formatter.processing')}
</>
) : loadingModules ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{t('tools.code_formatter.loading_library')}
</>
) : (
<>
<FontAwesomeIcon icon={faRedo} className="mr-2" />
{t('tools.code_formatter.format')}
</>
)}
</button>
</div>
{loadingModules && !error && (
<div className="mt-4 text-center">
<p className="text-sm text-tertiary">{t('tools.code_formatter.first_time_loading')}</p>
</div>
)}
{fileMissingWarning && (
<div className="mt-4 bg-yellow-900/10 border border-yellow-700/30 rounded-md p-3 text-sm text-warning">
<p className="flex items-start gap-2">
<FontAwesomeIcon icon={faExclamationTriangle} className="mt-0.5" />
<span>{fileMissingWarning}</span>
</p>
</div>
)}
{/* 帮助信息 */}
<div className="bg-block-strong bg-opacity-60 rounded-md p-4 border border-purple-glow/15">
<h3 className="text-sm font-medium text-primary flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faInfoCircle} />
{t('tools.code_formatter.usage_guide')}
</h3>
<ul className="text-xs text-secondary space-y-1">
<li> {t('tools.code_formatter.usage_step1')}</li>
<li> {t('tools.code_formatter.usage_step2')}</li>
<li> {t('tools.code_formatter.usage_step3')}</li>
<li> {t('tools.code_formatter.usage_step4')}</li>
</ul>
</div>
</div>
</div>
);
}
+604
View File
@@ -0,0 +1,604 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPalette, faCopy, faCheck, faPlus, faTrash, faRandom, faInfoCircle } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import { useLanguage } from '@/context/LanguageContext';
// 颜色格式类型
type ColorFormat = 'hex' | 'rgb' | 'hsl';
// 调色板颜色类型
interface PaletteColor {
id: string;
hex: string;
name: string;
}
// 添加CSS变量样式
const styles = {
card: "card p-6",
heading: "text-lg font-medium text-primary mb-4",
subheading: "text-md font-medium text-primary mb-4",
secondaryText: "text-sm text-tertiary",
iconButton: "text-secondary hover:text-primary transition-colors",
deleteBtn: "text-secondary hover:text-error transition-colors",
colorInput: "w-16 h-16 cursor-pointer rounded-md overflow-hidden border-0",
colorTextInput: "w-full px-3 py-2 bg-block border border-purple-glow rounded-md text-primary pr-10",
formatButton: (active: boolean) => `text-xs px-3 py-1 ${active ? 'bg-purple-glow/20 text-purple' : 'bg-block-strong text-secondary'}`,
paletteItem: "flex items-center justify-between p-2 rounded-md hover:bg-block-hover transition-colors",
paletteColorBox: "w-8 h-8 rounded-md cursor-pointer border border-block-strong",
colorLabel: "text-sm text-primary",
colorValue: "text-xs text-secondary font-mono",
colorContrastBox: "p-3 rounded-md text-center",
colorPreview: "h-32 rounded-md mb-4 flex items-center justify-center relative overflow-hidden",
colorPreviewText: "bg-black bg-opacity-40 px-4 py-2 rounded-md text-white",
colorShadesBox: "h-16 rounded-md flex items-center justify-center transition-all duration-200 cursor-pointer hover:transform hover:scale-105",
harmonicColorBox: "h-24 rounded-md flex items-center justify-center transition-transform cursor-pointer hover:scale-105",
}
export default function ColorTools() {
const { t, language } = useLanguage();
// 主颜色输入
const [mainColor, setMainColor] = useState('#6366F1');
const [mainColorFormat, setMainColorFormat] = useState<ColorFormat>('hex');
// 颜色展示
const [colorValues, setColorValues] = useState({
hex: '#6366F1',
rgb: 'rgb(99, 102, 241)',
hsl: 'hsl(239, 84%, 67%)',
});
// 亮暗变体
const [colorShades, setColorShades] = useState<string[]>([]);
// 互补和谐色
const [complementaryColors, setComplementaryColors] = useState<string[]>([]);
const [analogousColors, setAnalogousColors] = useState<string[]>([]);
// 调色板
const [palette, setPalette] = useState<PaletteColor[]>([]);
const [showPaletteInput, setShowPaletteInput] = useState(false);
const [newPaletteName, setNewPaletteName] = useState('');
// 复制状态
const [copiedFormat, setCopiedFormat] = useState<string | null>(null);
// 默认示例调色板
const getExamplePalette = () => [
{ id: 'primary', hex: '#6366F1', name: t('tools.color_tools.example_palette.primary') },
{ id: 'secondary', hex: '#8B5CF6', name: t('tools.color_tools.example_palette.secondary') },
{ id: 'accent', hex: '#EC4899', name: t('tools.color_tools.example_palette.accent') },
{ id: 'dark', hex: '#1E293B', name: t('tools.color_tools.example_palette.dark') },
{ id: 'light', hex: '#F1F5F9', name: t('tools.color_tools.example_palette.light') },
];
// 初始化
useEffect(() => {
// 从本地存储加载调色板或使用示例
const savedPalette = localStorage.getItem('colorPalette');
if (savedPalette) {
setPalette(JSON.parse(savedPalette));
} else {
setPalette(getExamplePalette());
}
// 初始化颜色计算
updateColorValues(mainColor);
}, [language]);
// 当主颜色改变时,更新所有颜色值
useEffect(() => {
updateColorValues(mainColor);
}, [mainColor]);
// 使用特性的展示 - 在组件顶部定义可能用到的功能列表
const features = [
'HEX、RGB、HSL三种颜色格式的转换和复制',
'亮度色阶展示,快速选择不同亮度的相同颜色',
'互补色和邻近色展示,帮助设计和谐的配色方案',
'个性化调色板保存,支持添加和删除自定义颜色'
];
// 更新颜色值
const updateColorValues = (hexColor: string) => {
try {
// 验证颜色格式
if (!/^#[0-9A-Fa-f]{6}$/.test(hexColor)) {
return;
}
// 计算 RGB
const r = parseInt(hexColor.slice(1, 3), 16);
const g = parseInt(hexColor.slice(3, 5), 16);
const b = parseInt(hexColor.slice(5, 7), 16);
// RGB 格式
const rgbValue = `rgb(${r}, ${g}, ${b})`;
// 计算 HSL
const rNorm = r / 255;
const gNorm = g / 255;
const bNorm = b / 255;
const max = Math.max(rNorm, gNorm, bNorm);
const min = Math.min(rNorm, gNorm, bNorm);
const diff = max - min;
let h = 0;
if (max === min) {
h = 0;
} else if (max === rNorm) {
h = ((gNorm - bNorm) / diff + (gNorm < bNorm ? 6 : 0)) * 60;
} else if (max === gNorm) {
h = ((bNorm - rNorm) / diff + 2) * 60;
} else {
h = ((rNorm - gNorm) / diff + 4) * 60;
}
const l = (max + min) / 2;
const s = diff === 0 ? 0 : diff / (1 - Math.abs(2 * l - 1));
// HSL 格式
const hslValue = `hsl(${Math.round(h)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`;
// 更新所有颜色值
setColorValues({
hex: hexColor,
rgb: rgbValue,
hsl: hslValue,
});
// 计算颜色变体
calculateColorShades(hexColor);
// 计算互补和谐色
calculateComplementary(h, s, l);
calculateAnalogous(h, s, l);
} catch (error) {
console.error(t('tools.color_tools.copy_failed'), error);
}
};
// 计算颜色的亮度变体
const calculateColorShades = (hexColor: string) => {
const shades: string[] = [];
// 解析颜色
const r = parseInt(hexColor.slice(1, 3), 16);
const g = parseInt(hexColor.slice(3, 5), 16);
const b = parseInt(hexColor.slice(5, 7), 16);
// 创建9个亮度变体(从10%到90%)
for (let i = 0.1; i <= 0.9; i += 0.1) {
// 亮度变化
const factor = i < 0.5 ? i * 2 : 1; // 暗色处理
// 调整颜色
const newR = Math.round(r * factor);
const newG = Math.round(g * factor);
const newB = Math.round(b * factor);
// 转换回十六进制
const newHex = `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
shades.push(newHex);
}
setColorShades(shades.reverse()); // 从暗到亮排序
};
// 计算互补色
const calculateComplementary = (h: number, s: number, l: number) => {
const complementaryH = (h + 180) % 360;
const complementaryRgb = hslToRgb(complementaryH / 360, s, l);
const complementaryHex = rgbToHex(complementaryRgb[0], complementaryRgb[1], complementaryRgb[2]);
setComplementaryColors([complementaryHex]);
};
// 计算邻近和谐色
const calculateAnalogous = (h: number, s: number, l: number) => {
const analogous: string[] = [];
// 计算邻近色(-30°, +30°)
const angles = [-30, 30];
for (const angle of angles) {
const newH = (h + angle + 360) % 360;
const analogousRgb = hslToRgb(newH / 360, s, l);
const analogousHex = rgbToHex(analogousRgb[0], analogousRgb[1], analogousRgb[2]);
analogous.push(analogousHex);
}
setAnalogousColors(analogous);
};
// HSL 转 RGB 辅助函数
const hslToRgb = (h: number, s: number, l: number): [number, number, number] => {
let r, g, b;
if (s === 0) {
r = g = b = l; // 灰色
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
return [
Math.round(r * 255),
Math.round(g * 255),
Math.round(b * 255)
];
};
// RGB转Hex辅助函数
const rgbToHex = (r: number, g: number, b: number): string => {
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
};
// 处理主颜色输入变化
const handleColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setMainColor(e.target.value);
};
// 处理格式选择
const handleFormatChange = (format: ColorFormat) => {
setMainColorFormat(format);
};
// 复制颜色值
const copyColorValue = (format: string) => {
const textToCopy = colorValues[format as keyof typeof colorValues];
try {
navigator.clipboard.writeText(textToCopy).then(() => {
setCopiedFormat(format);
setTimeout(() => setCopiedFormat(null), 2000);
});
} catch (err) {
console.error(t('tools.color_tools.copy_failed'), err);
}
};
// 添加到调色板
const addToPalette = () => {
if (!newPaletteName.trim()) return;
const newColor: PaletteColor = {
id: Date.now().toString(),
hex: mainColor,
name: newPaletteName.trim()
};
const updatedPalette = [...palette, newColor];
setPalette(updatedPalette);
// 保存到本地存储
try {
localStorage.setItem('colorPalette', JSON.stringify(updatedPalette));
} catch (err) {
console.error(t('tools.color_tools.save_palette_error'), err);
}
setNewPaletteName('');
setShowPaletteInput(false);
};
// 从调色板删除颜色
const removeFromPalette = (id: string) => {
const updatedPalette = palette.filter(color => color.id !== id);
setPalette(updatedPalette);
// 更新本地存储
localStorage.setItem('colorPalette', JSON.stringify(updatedPalette));
};
// 从调色板选择颜色
const selectFromPalette = (hex: string) => {
setMainColor(hex);
};
// 生成随机颜色
const generateRandomColor = () => {
const randomHex = `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0')}`;
setMainColor(randomHex);
};
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
<ToolHeader
toolCode="color_tools"
icon={faPalette}
title=""
description=""
/>
{/* 主内容区 */}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* 左侧面板 - 调色板 */}
<div className="lg:col-span-2 space-y-6">
{/* 颜色选择和格式 */}
<div className={styles.card}>
<div className="flex items-center justify-between mb-4">
<h2 className={styles.subheading}>{t('tools.color_tools.color_selection')}</h2>
<button
className={styles.iconButton}
onClick={generateRandomColor}
>
<FontAwesomeIcon icon={faRandom} className="mr-1" />
{t('tools.color_tools.random_color')}
</button>
</div>
<div className="flex flex-row gap-4">
<input
type="color"
value={mainColor}
onChange={handleColorChange}
className={styles.colorInput}
/>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<div className="flex rounded-md overflow-hidden border border-block-strong">
{(['hex', 'rgb', 'hsl'] as ColorFormat[]).map((format) => (
<button
key={format}
className={styles.formatButton(mainColorFormat === format)}
onClick={() => handleFormatChange(format)}
>
{t(`tools.color_tools.color_formats.${format}`)}
</button>
))}
</div>
</div>
<div className="relative">
<input
type="text"
value={colorValues[mainColorFormat]}
readOnly
className={styles.colorTextInput}
/>
<button
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary hover:text-primary transition-colors"
onClick={() => copyColorValue(mainColorFormat)}
>
<FontAwesomeIcon icon={copiedFormat === mainColorFormat ? faCheck : faCopy} />
</button>
</div>
</div>
</div>
</div>
{/* 调色板 */}
<div className={styles.card}>
<div className="flex items-center justify-between mb-4">
<h2 className={styles.subheading}>{t('tools.color_tools.color_palette')}</h2>
<div className="flex items-center gap-2">
<button
className={styles.iconButton}
onClick={() => setShowPaletteInput(true)}
>
<FontAwesomeIcon icon={faPlus} className="mr-1" />
{t('tools.color_tools.palette_actions.add_color')}
</button>
</div>
</div>
{/* 添加新颜色 */}
{showPaletteInput && (
<div className="bg-block-hover rounded-md p-3 mb-4">
<div className="flex items-start gap-3">
<div
className="w-10 h-10 rounded-md border border-block-strong"
style={{ backgroundColor: mainColor }}
></div>
<div className="flex-1">
<label className="block text-sm text-secondary mb-1">
{t('tools.color_tools.palette_color_name')}
</label>
<input
type="text"
className="w-full px-2 py-1 bg-block border border-block-strong rounded-md text-primary text-sm"
placeholder={t('tools.color_tools.palette_color_name_input')}
value={newPaletteName}
onChange={(e) => setNewPaletteName(e.target.value)}
autoFocus
/>
</div>
</div>
<div className="flex justify-end mt-3 gap-2">
<button
className="text-xs px-3 py-1 bg-block-strong text-secondary rounded-md"
onClick={() => {
setShowPaletteInput(false);
setNewPaletteName('');
}}
>
{t('tools.color_tools.cancel')}
</button>
<button
className="text-xs px-3 py-1 bg-purple-glow/20 text-purple rounded-md"
onClick={addToPalette}
>
{t('tools.color_tools.add')}
</button>
</div>
</div>
)}
{/* 颜色列表 */}
<div className="space-y-1 max-h-[300px] overflow-y-auto">
{palette.map((color) => (
<div key={color.id} className={styles.paletteItem}>
<div className="flex items-center gap-2">
<div
className={styles.paletteColorBox}
style={{ backgroundColor: color.hex }}
onClick={() => selectFromPalette(color.hex)}
></div>
<div>
<div className={styles.colorLabel}>{color.name}</div>
<div className={styles.colorValue}>{color.hex}</div>
</div>
</div>
<button
className={styles.deleteBtn}
onClick={() => removeFromPalette(color.id)}
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
))}
</div>
</div>
</div>
{/* 右侧面板 - 颜色变体 */}
<div className="lg:col-span-3 space-y-6">
{/* 颜色预览 */}
<div className={styles.card}>
<h2 className={styles.subheading}>{t('tools.color_tools.color_preview')}</h2>
<div
className={styles.colorPreview}
style={{ backgroundColor: mainColor }}
>
<div className={styles.colorPreviewText}>
{colorValues[mainColorFormat]}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<h3 className="text-sm text-primary mb-2">{t('tools.color_tools.contrast_effects')}</h3>
<div className="space-y-2">
<div
className={styles.colorContrastBox}
style={{ backgroundColor: mainColor, color: '#FFFFFF' }}
>
{t('tools.color_tools.white_text')}
</div>
<div
className={styles.colorContrastBox}
style={{ backgroundColor: mainColor, color: '#000000' }}
>
{t('tools.color_tools.black_text')}
</div>
</div>
</div>
<div>
<h3 className="text-sm text-primary mb-2">{t('tools.color_tools.border_background')}</h3>
<div className="space-y-2">
<div
className={styles.colorContrastBox + " bg-block"}
style={{ border: `2px solid ${mainColor}` }}
>
<span className="text-primary">{t('tools.color_tools.border_effect')}</span>
</div>
<div
className={styles.colorContrastBox}
style={{ backgroundColor: `${mainColor}40` }}
>
<span className="text-primary">{t('tools.color_tools.transparent_background')}</span>
</div>
</div>
</div>
</div>
</div>
{/* 颜色变体 */}
<div className={styles.card}>
<h2 className={styles.subheading}>{t('tools.color_tools.color_variants')}</h2>
<div className="grid grid-cols-9 gap-1 mb-6">
{colorShades.map((shade, index) => (
<div
key={index}
className={styles.colorShadesBox}
style={{ backgroundColor: shade }}
onClick={() => setMainColor(shade)}
title={shade}
>
<span className="text-xs font-mono text-white bg-black bg-opacity-30 px-1 rounded">
{index * 10 + 10}%
</span>
</div>
))}
</div>
<div className="mb-6">
<h3 className="text-sm text-primary mb-2">{t('tools.color_tools.complementary_color')}</h3>
<div className="grid grid-cols-1 gap-2">
{complementaryColors.map((color, index) => (
<div
key={index}
className={styles.harmonicColorBox}
style={{ backgroundColor: color }}
onClick={() => setMainColor(color)}
>
<span className="text-sm font-mono text-white bg-black bg-opacity-30 px-2 py-1 rounded">
{color}
</span>
</div>
))}
</div>
</div>
<div>
<h3 className="text-sm text-primary mb-2">{t('tools.color_tools.analogous_harmony')}</h3>
<div className="grid grid-cols-2 gap-2">
{analogousColors.map((color, index) => (
<div
key={index}
className={styles.harmonicColorBox}
style={{ backgroundColor: color }}
onClick={() => setMainColor(color)}
>
<span className="text-sm font-mono text-white bg-black bg-opacity-30 px-2 py-1 rounded">
{color}
</span>
</div>
))}
</div>
</div>
</div>
{/* 说明 */}
<div className={styles.card}>
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faInfoCircle} className="text-purple" />
<h2 className="text-primary font-medium">{t('tools.color_tools.usage_guide.title')}</h2>
</div>
<p className={styles.secondaryText}>
{t('tools.color_tools.usage_guide.content')}
</p>
<ul className="list-disc pl-5 text-sm text-tertiary">
{features.map((feature, index) => (
<li key={index}>{t(`tools.color_tools.usage_guide.features.${index}`) || feature}</li>
))}
</ul>
</div>
</div>
</div>
{/* 回到顶部按钮 */}
<BackToTop position="bottom-right" offset={30} size="medium" />
</div>
);
}
+448
View File
@@ -0,0 +1,448 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faLock,
faCopy,
faCheck,
faInfoCircle,
faRedo,
faEraser
} from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import * as CryptoJS from 'crypto-js';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
card: "card p-6",
heading: "text-lg font-medium text-primary mb-1",
label: "block text-sm text-secondary font-medium mb-1",
input: "w-full p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple transition-all",
textarea: "w-full h-36 p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple transition-all font-mono resize-y",
actionBtn: "btn-secondary flex items-center gap-2",
actionBtnPrimary: "btn-primary flex items-center gap-2",
secondaryText: "text-sm text-tertiary",
error: "p-3 bg-red-900/20 border border-red-700/30 rounded-lg text-error",
success: "p-3 bg-green-900/20 border border-green-700/30 rounded-lg text-success",
tabButton: "px-3 py-2 text-sm font-medium transition-all",
activeTab: "bg-block text-primary shadow-sm",
inactiveTab: "text-tertiary hover:text-secondary",
flexBetween: "flex flex-col sm:flex-row gap-4 justify-between items-center",
twoColumns: "grid grid-cols-1 md:grid-cols-2 gap-6",
};
// 加密算法类型
type CryptoType = 'md5' | 'sha1' | 'sha256' | 'sha512' | 'aes' | 'base64';
export default function CryptoTools() {
// 使用多语言支持
const { t } = useLanguage();
// 状态管理
const [activeAlgorithm, setActiveAlgorithm] = useState<CryptoType>('md5');
const [inputText, setInputText] = useState('');
const [secretKey, setSecretKey] = useState('');
const [output, setOutput] = useState('');
const [isDecoding, setIsDecoding] = useState(false);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
// 清除状态提示的定时器
useEffect(() => {
let timer: NodeJS.Timeout;
if (error || success) {
timer = setTimeout(() => {
setError(null);
setSuccess(null);
}, 3000);
}
return () => {
if (timer) clearTimeout(timer);
};
}, [error, success]);
// 当算法更改时,重置解码状态
useEffect(() => {
if (!algorithms[activeAlgorithm].isEncodeDecode) {
setIsDecoding(false);
}
setOutput('');
setError(null);
}, [activeAlgorithm]);
// 处理加密或解密操作
const processOperation = () => {
setError(null);
setSuccess(null);
setOutput('');
if (!inputText.trim()) {
setError(t('tools.crypto_tools.input_required'));
return;
}
if (algorithms[activeAlgorithm].needsKey && !secretKey.trim()) {
setError(t('tools.crypto_tools.key_required'));
return;
}
try {
let result = '';
switch (activeAlgorithm) {
case 'md5':
result = CryptoJS.MD5(inputText).toString();
break;
case 'sha1':
result = CryptoJS.SHA1(inputText).toString();
break;
case 'sha256':
result = CryptoJS.SHA256(inputText).toString();
break;
case 'sha512':
result = CryptoJS.SHA512(inputText).toString();
break;
case 'aes':
if (isDecoding) {
// 解密操作
try {
const decrypted = CryptoJS.AES.decrypt(inputText, secretKey);
result = decrypted.toString(CryptoJS.enc.Utf8);
if (!result) {
throw new Error(t('tools.crypto_tools.decryption_failed'));
}
} catch {
throw new Error(t('tools.crypto_tools.decryption_failed'));
}
} else {
// 加密操作
result = CryptoJS.AES.encrypt(inputText, secretKey).toString();
}
break;
case 'base64':
if (isDecoding) {
// Base64解码
try {
result = CryptoJS.enc.Base64.parse(inputText).toString(CryptoJS.enc.Utf8);
} catch {
throw new Error(t('tools.crypto_tools.base64_decode_failed'));
}
} else {
// Base64编码
result = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(inputText));
}
break;
}
setOutput(result);
setSuccess(isDecoding ? t('tools.crypto_tools.decryption_success') : t('tools.crypto_tools.encryption_success'));
} catch (err) {
console.error('处理错误:', err);
setError(`${isDecoding ? t('tools.crypto_tools.decrypt') : t('tools.crypto_tools.encrypt')}失败: ${err instanceof Error ? err.message : '未知错误'}`);
}
};
// 复制结果到剪贴板
const copyToClipboard = () => {
if (!output) return;
navigator.clipboard.writeText(output)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => {
console.error('复制失败:', err);
setError(t('tools.crypto_tools.copy_failed'));
});
};
// 清空所有内容
const clearAll = () => {
setInputText('');
setSecretKey('');
setOutput('');
setError(null);
setSuccess(null);
};
// 加载示例
const loadExample = () => {
const examples: Record<CryptoType, { input: string; key?: string }> = {
md5: { input: 'Hello, World!' },
sha1: { input: 'Hello, World!' },
sha256: { input: 'Hello, World!' },
sha512: { input: 'Hello, World!' },
aes: { input: 'Hello, World!', key: 'secret-key-12345' },
base64: { input: 'Hello, World!' }
};
const example = examples[activeAlgorithm];
setInputText(example.input);
if (example.key) {
setSecretKey(example.key);
}
setOutput('');
setError(null);
setSuccess(null);
};
// 加密算法信息映射
const algorithms = {
md5: {
name: t('tools.crypto_tools.algorithms.md5.name'),
description: t('tools.crypto_tools.algorithms.md5.description'),
needsKey: false,
isEncodeDecode: false,
},
sha1: {
name: t('tools.crypto_tools.algorithms.sha1.name'),
description: t('tools.crypto_tools.algorithms.sha1.description'),
needsKey: false,
isEncodeDecode: false,
},
sha256: {
name: t('tools.crypto_tools.algorithms.sha256.name'),
description: t('tools.crypto_tools.algorithms.sha256.description'),
needsKey: false,
isEncodeDecode: false,
},
sha512: {
name: t('tools.crypto_tools.algorithms.sha512.name'),
description: t('tools.crypto_tools.algorithms.sha512.description'),
needsKey: false,
isEncodeDecode: false,
},
aes: {
name: t('tools.crypto_tools.algorithms.aes.name'),
description: t('tools.crypto_tools.algorithms.aes.description'),
needsKey: true,
isEncodeDecode: true,
},
base64: {
name: t('tools.crypto_tools.algorithms.base64.name'),
description: t('tools.crypto_tools.algorithms.base64.description'),
needsKey: false,
isEncodeDecode: true,
}
};
return (
<div className={styles.container}>
{/* 工具头部 */}
<ToolHeader
icon={faLock}
toolCode="crypto_tools"
title=""
description=""
/>
{/* 主内容区域 */}
<div className={styles.card}>
<div className="space-y-6">
{/* 算法选择 */}
<div className="bg-block-strong p-1 rounded-md flex flex-wrap">
{Object.entries(algorithms).map(([key, algo]) => (
<button
key={key}
className={`${styles.tabButton} ${activeAlgorithm === key ? styles.activeTab : styles.inactiveTab}`}
onClick={() => setActiveAlgorithm(key as CryptoType)}
>
{algo.name}
</button>
))}
</div>
{/* 算法描述 */}
<div className={styles.secondaryText}>
{algorithms[activeAlgorithm].description}
</div>
{/* 操作按钮和状态切换 */}
<div className={styles.flexBetween}>
{/* 编码/解码切换 */}
{algorithms[activeAlgorithm].isEncodeDecode && (
<div className="flex items-center bg-block-strong rounded-md p-1">
<button
className={`${styles.tabButton} ${!isDecoding ? styles.activeTab : styles.inactiveTab}`}
onClick={() => setIsDecoding(false)}
>
{activeAlgorithm === 'base64' ? t('tools.crypto_tools.encode') : t('tools.crypto_tools.encrypt')}
</button>
<button
className={`${styles.tabButton} ${isDecoding ? styles.activeTab : styles.inactiveTab}`}
onClick={() => setIsDecoding(true)}
>
{activeAlgorithm === 'base64' ? t('tools.crypto_tools.decode') : t('tools.crypto_tools.decrypt')}
</button>
</div>
)}
<div className="flex gap-2">
<button
onClick={loadExample}
className={styles.actionBtn}
>
<FontAwesomeIcon icon={faRedo} />
{t('tools.crypto_tools.load_example')}
</button>
<button
onClick={clearAll}
className={styles.actionBtn}
disabled={!inputText}
>
<FontAwesomeIcon icon={faEraser} />
{t('tools.crypto_tools.clear')}
</button>
</div>
</div>
{/* 输入输出部分 */}
<div className={styles.twoColumns}>
{/* 输入区域 */}
<div className="space-y-4">
{/* 输入文本 */}
<div>
<label className={styles.label}>
{isDecoding
? (activeAlgorithm === 'base64' ? t('tools.crypto_tools.base64_encoded') : t('tools.crypto_tools.encrypted_text'))
: t('tools.crypto_tools.input_text')}
</label>
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder={isDecoding
? (activeAlgorithm === 'base64' ? t('tools.crypto_tools.base64_decode_placeholder') : t('tools.crypto_tools.decrypt_placeholder'))
: t('tools.crypto_tools.input_placeholder')}
className={styles.textarea}
/>
</div>
{/* 密钥输入 */}
{algorithms[activeAlgorithm].needsKey && (
<div>
<label className={styles.label}>{t('tools.crypto_tools.secret_key')}</label>
<input
type="text"
value={secretKey}
onChange={(e) => setSecretKey(e.target.value)}
placeholder={t('tools.crypto_tools.key_placeholder')}
className={styles.input}
/>
</div>
)}
<div>
<button
onClick={processOperation}
className={styles.actionBtnPrimary}
disabled={!inputText}
>
<FontAwesomeIcon icon={faLock} />
{isDecoding
? (activeAlgorithm === 'base64' ? t('tools.crypto_tools.decode') : t('tools.crypto_tools.decrypt'))
: (activeAlgorithm === 'base64' ? t('tools.crypto_tools.encode') : (algorithms[activeAlgorithm].isEncodeDecode ? t('tools.crypto_tools.encrypt') : t('tools.crypto_tools.calculate')))}
</button>
</div>
</div>
{/* 输出区域 */}
<div className="space-y-4">
<div>
<label className={styles.label}>
{isDecoding
? t('tools.crypto_tools.decoded_result')
: (algorithms[activeAlgorithm].isEncodeDecode ? t('tools.crypto_tools.encrypted_result') : t('tools.crypto_tools.hash_result'))}
</label>
<textarea
value={output}
readOnly
placeholder={t('tools.crypto_tools.result_placeholder')}
className={styles.textarea}
/>
</div>
{output && (
<div className="flex justify-end">
<button
onClick={copyToClipboard}
className={styles.actionBtn}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
{copied ? t('tools.crypto_tools.copied') : t('tools.crypto_tools.copy_result')}
</button>
</div>
)}
</div>
</div>
{/* 状态消息 */}
{error && (
<div className={styles.error}>
<FontAwesomeIcon icon={faInfoCircle} className="mr-2" />
{error}
</div>
)}
{success && (
<div className={styles.success}>
<FontAwesomeIcon icon={faCheck} className="mr-2" />
{success}
</div>
)}
{/* 算法信息说明 */}
<div className="p-4 bg-block rounded-lg">
<h3 className="text-primary font-medium mb-2">{t('tools.crypto_tools.algorithm_info')}</h3>
<div className={styles.secondaryText}>
<p className="mb-2">
<strong>{algorithms[activeAlgorithm].name}:</strong>{' '}
{algorithms[activeAlgorithm].description}
</p>
{activeAlgorithm === 'md5' && (
<p>{t('tools.crypto_tools.algorithms.md5.additional_info')}</p>
)}
{activeAlgorithm === 'sha1' && (
<p>{t('tools.crypto_tools.algorithms.sha1.additional_info')}</p>
)}
{activeAlgorithm === 'sha256' && (
<p>{t('tools.crypto_tools.algorithms.sha256.additional_info')}</p>
)}
{activeAlgorithm === 'sha512' && (
<p>{t('tools.crypto_tools.algorithms.sha512.additional_info')}</p>
)}
{activeAlgorithm === 'aes' && (
<p>{t('tools.crypto_tools.algorithms.aes.additional_info')}</p>
)}
{activeAlgorithm === 'base64' && (
<p>{t('tools.crypto_tools.algorithms.base64.additional_info')}</p>
)}
</div>
</div>
</div>
</div>
{/* 回到顶部按钮 */}
<BackToTop />
</div>
);
}
@@ -0,0 +1,63 @@
import { render, screen, fireEvent } from '@testing-library/react';
import CssGradientGenerator from '../page';
// 模拟 FontAwesomeIcon 组件,因为它可能在测试环境中无法正确加载
jest.mock('@fortawesome/react-fontawesome', () => ({
FontAwesomeIcon: () => <span data-testid="mock-icon" />
}));
describe('CSS渐变生成器', () => {
test('渲染所有主要组件', () => {
render(<CssGradientGenerator />);
// 检查标题是否正确显示
expect(screen.getByText('CSS渐变生成器')).toBeInTheDocument();
// 检查所有主要控制按钮是否存在
expect(screen.getByText('线性渐变')).toBeInTheDocument();
expect(screen.getByText('径向渐变')).toBeInTheDocument();
// 检查渐变预览是否存在
expect(screen.getByText('渐变预览')).toBeInTheDocument();
// 检查颜色停止点编辑区是否存在
expect(screen.getByText('颜色停止点')).toBeInTheDocument();
expect(screen.getByText('添加色标')).toBeInTheDocument();
// 检查CSS代码区是否存在
expect(screen.getByText('CSS代码')).toBeInTheDocument();
expect(screen.getByText('复制')).toBeInTheDocument();
});
test('切换渐变类型', () => {
render(<CssGradientGenerator />);
// 初始应该是线性渐变
expect(screen.getByText('渐变方向')).toBeInTheDocument();
// 切换到径向渐变
fireEvent.click(screen.getByText('径向渐变'));
// 应该显示径向渐变的选项
expect(screen.getByText('渐变形状与位置')).toBeInTheDocument();
expect(screen.getByText('形状:')).toBeInTheDocument();
expect(screen.getByText('圆形')).toBeInTheDocument();
expect(screen.getByText('椭圆')).toBeInTheDocument();
expect(screen.getByText('位置:')).toBeInTheDocument();
});
test('添加和删除颜色停止点', () => {
render(<CssGradientGenerator />);
// 初始应该有2个颜色输入
const initialColorInputs = screen.getAllByRole('textbox');
expect(initialColorInputs.length).toBe(2);
// 添加一个新的颜色停止点
fireEvent.click(screen.getByText('添加色标'));
// 现在应该有3个颜色输入
const updatedColorInputs = screen.getAllByRole('textbox');
expect(updatedColorInputs.length).toBe(3);
});
});
@@ -0,0 +1,559 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCogs, faCopy, faCheck, faRandom, faTrash, faPlus, faAngleRight, faCircle } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import tools from '@/config/tools';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
card: "card p-6",
smallCard: "card p-4",
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
heading: "text-md font-medium text-primary mb-4",
subheading: "text-sm font-medium text-secondary mb-2",
label: "text-sm text-secondary mb-1",
previewBox: "relative rounded-lg h-48 overflow-hidden shadow-lg mb-4 transition-all duration-300 hover:shadow-xl border border-purple-glow/20",
optionBtn: (active: boolean) => `flex-1 px-3 py-2 rounded-md text-sm transition-colors ${active ? 'btn-primary' : 'btn-secondary'}`,
directionBtn: (active: boolean) => `w-8 h-8 rounded-md flex items-center justify-center ${active ? 'bg-purple-glow/30 text-purple' : 'bg-block-strong text-secondary'}`,
colorStopItem: "flex items-center gap-2 mb-3 relative",
colorInput: "w-8 h-8 rounded-md overflow-hidden cursor-pointer border border-purple-glow/20",
positionInput: "bg-block border border-purple-glow/20 rounded-md px-2 py-1 max-w-[4rem] text-primary text-center",
deleteBtn: "text-secondary hover:text-error transition-colors",
presetBtn: "w-8 h-8 rounded-md cursor-pointer border border-purple-glow/20 transition-all hover:scale-110",
codeBlock: "bg-block rounded-md p-4 text-sm font-mono text-primary overflow-auto",
codeComment: "text-tertiary",
copyBtn: "absolute top-2 right-2 bg-block-strong px-2 py-1 rounded text-xs text-secondary flex items-center gap-1 hover:bg-block-hover transition-colors",
rangeInput: "w-full bg-block-strong h-2 rounded-full appearance-none cursor-pointer",
inputGroup: "mb-4",
grid: "grid grid-cols-1 lg:grid-cols-3 gap-6",
flexRow: "flex space-x-2",
flexCenter: "flex items-center justify-center",
directionsGrid: "grid grid-cols-3 gap-2 mb-4",
};
// 渐变类型
type GradientType = 'linear' | 'radial';
// 渐变方向(线性)
type LinearDirection = '0deg' | '45deg' | '90deg' | '135deg' | '180deg' | '225deg' | '270deg' | '315deg' | 'custom';
// 渐变形状(径向)
type RadialShape = 'circle' | 'ellipse';
// 渐变位置(径向)
type RadialPosition = 'center' | 'top' | 'top right' | 'right' | 'bottom right' | 'bottom' | 'bottom left' | 'left' | 'top left';
// 渐变色标
interface ColorStop {
id: string;
color: string;
position: number;
}
export default function CssGradientGenerator() {
// 从工具配置中获取当前工具信息
const toolConfig = tools.find(tool => tool.code === 'css_gradient_generator');
// 使用多语言支持
const { t } = useLanguage();
// 渐变类型
const [gradientType, setGradientType] = useState<GradientType>('linear');
// 线性渐变方向
const [linearDirection, setLinearDirection] = useState<LinearDirection>('90deg');
const [customAngle, setCustomAngle] = useState<number>(90);
// 径向渐变设置
const [radialShape, setRadialShape] = useState<RadialShape>('circle');
const [radialPosition, setRadialPosition] = useState<RadialPosition>('center');
// 颜色停止点
const [colorStops, setColorStops] = useState<ColorStop[]>([
{ id: '1', color: '#6366F1', position: 0 },
{ id: '2', color: '#8B5CF6', position: 100 }
]);
// CSS 代码
const [cssCode, setCssCode] = useState<string>('');
// 复制状态
const [copied, setCopied] = useState<boolean>(false);
// 常用颜色组合
const presetColors = [
['#6366F1', '#8B5CF6'], // 极速箱默认紫色渐变
['#F472B6', '#EC4899'], // 粉红
['#10B981', '#059669'], // 绿色
['#3B82F6', '#2563EB'], // 蓝色
['#F59E0B', '#F97316'], // 橙色
['#6B7280', '#374151'], // 灰色
['#1E293B', '#0F172A'], // 深蓝灰
];
// 初始化效果
useEffect(() => {
generateCssCode();
}, [gradientType, linearDirection, customAngle, radialShape, radialPosition, colorStops]);
// 生成 CSS 代码
const generateCssCode = () => {
let cssText = '';
// 构建色标字符串
const stopsStr = colorStops
.sort((a, b) => a.position - b.position)
.map(stop => `${stop.color} ${stop.position}%`)
.join(', ');
// 根据渐变类型构建代码
if (gradientType === 'linear') {
const direction = linearDirection === 'custom' ? `${customAngle}deg` : linearDirection;
cssText = `background: ${colorStops[0].color};\n`;
cssText += `background: -webkit-linear-gradient(${direction}, ${stopsStr});\n`;
cssText += `background: linear-gradient(${direction}, ${stopsStr});`;
} else {
cssText = `background: ${colorStops[0].color};\n`;
cssText += `background: -webkit-radial-gradient(${radialPosition}, ${radialShape}, ${stopsStr});\n`;
cssText += `background: radial-gradient(${radialShape} at ${radialPosition}, ${stopsStr});`;
}
setCssCode(cssText);
};
// 添加新颜色停止点
const addColorStop = () => {
const id = Date.now().toString();
const colorCount = colorStops.length;
// 设置默认颜色和位置
const color = '#818CF8';
let position = 50;
// 如果有至少两个颜色,尝试在中间插入
if (colorCount >= 2) {
// 按位置排序
const sortedStops = [...colorStops].sort((a, b) => a.position - b.position);
// 找出最大间隔
let maxGap = 0;
let insertPosition = 50;
for (let i = 0; i < sortedStops.length - 1; i++) {
const gap = sortedStops[i + 1].position - sortedStops[i].position;
if (gap > maxGap) {
maxGap = gap;
insertPosition = sortedStops[i].position + gap / 2;
}
}
position = Math.round(insertPosition);
}
setColorStops([...colorStops, { id, color, position }]);
};
// 移除颜色停止点
const removeColorStop = (id: string) => {
// 确保至少保留2个颜色停止点
if (colorStops.length <= 2) {
return;
}
setColorStops(colorStops.filter(stop => stop.id !== id));
};
// 更新颜色停止点
const updateColorStop = (id: string, field: 'color' | 'position', value: string | number) => {
setColorStops(colorStops.map(stop => {
if (stop.id === id) {
if (field === 'position') {
// 确保位置在0-100范围内
const numValue = typeof value === 'string' ? parseInt(value, 10) : value;
return { ...stop, position: Math.max(0, Math.min(100, numValue)) };
}
return { ...stop, [field]: value as string };
}
return stop;
}));
};
// 应用预设颜色
const applyPreset = (colors: string[]) => {
const newStops = colorStops.map((stop, index) => {
// 只替换颜色,保持原有位置和ID
if (index < colors.length) {
return { ...stop, color: colors[index] };
}
return stop;
});
setColorStops(newStops);
};
// 生成随机渐变
const generateRandomGradient = () => {
// 生成随机颜色
const generateRandomColor = () => {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
};
// 更新颜色停止点
const newStops = colorStops.map(stop => ({
...stop,
color: generateRandomColor()
}));
// 随机渐变类型和方向
const newType = Math.random() > 0.5 ? 'linear' : 'radial';
setGradientType(newType);
if (newType === 'linear') {
const directions: LinearDirection[] = ['0deg', '45deg', '90deg', '135deg', '180deg', '225deg', '270deg', '315deg'];
const randomDirection = directions[Math.floor(Math.random() * directions.length)];
setLinearDirection(randomDirection);
} else {
const shapes: RadialShape[] = ['circle', 'ellipse'];
const positions: RadialPosition[] = ['center', 'top', 'right', 'bottom', 'left', 'top right', 'bottom right', 'bottom left', 'top left'];
setRadialShape(shapes[Math.floor(Math.random() * shapes.length)]);
setRadialPosition(positions[Math.floor(Math.random() * positions.length)]);
}
setColorStops(newStops);
};
// 复制 CSS 代码
const copyToClipboard = () => {
navigator.clipboard.writeText(cssCode)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => console.error(t('tools.css_gradient_generator.copy_failed'), err));
};
// 渐变预览样式
const gradientPreviewStyle = {
background: gradientType === 'linear'
? `linear-gradient(${linearDirection === 'custom' ? `${customAngle}deg` : linearDirection}, ${colorStops.sort((a, b) => a.position - b.position).map(stop => `${stop.color} ${stop.position}%`).join(', ')})`
: `radial-gradient(${radialShape} at ${radialPosition}, ${colorStops.sort((a, b) => a.position - b.position).map(stop => `${stop.color} ${stop.position}%`).join(', ')})`
};
return (
<div className={styles.container}>
{/* 工具头部 */}
{toolConfig && (
<ToolHeader
icon={toolConfig.icon || faCogs}
toolCode="css_gradient_generator"
title=""
description=""
/>
)}
{/* 主内容区 */}
<div className={styles.grid}>
{/* 左侧 - 控制面板 */}
<div className="lg:col-span-1 space-y-6">
{/* 渐变类型 */}
<div className={styles.smallCard}>
<h2 className={styles.heading}>{t('tools.css_gradient_generator.gradient_type')}</h2>
<div className={styles.flexRow}>
<button
className={styles.optionBtn(gradientType === 'linear')}
onClick={() => setGradientType('linear')}
>
{t('tools.css_gradient_generator.linear_gradient')}
</button>
<button
className={styles.optionBtn(gradientType === 'radial')}
onClick={() => setGradientType('radial')}
>
{t('tools.css_gradient_generator.radial_gradient')}
</button>
</div>
</div>
{/* 渐变参数 */}
<div className={styles.smallCard}>
<h2 className={styles.heading}>
{gradientType === 'linear'
? t('tools.css_gradient_generator.gradient_direction')
: t('tools.css_gradient_generator.gradient_shape_position')}
</h2>
{gradientType === 'linear' ? (
<div>
{/* 线性渐变方向 */}
<div className={styles.directionsGrid}>
<button
className={styles.directionBtn(linearDirection === '225deg')}
onClick={() => setLinearDirection('225deg')}
title={t('tools.css_gradient_generator.direction_titles.225deg')}
>
<FontAwesomeIcon icon={faAngleRight} className="transform rotate-[-135deg]" />
</button>
<button
className={styles.directionBtn(linearDirection === '270deg')}
onClick={() => setLinearDirection('270deg')}
title={t('tools.css_gradient_generator.direction_titles.270deg')}
>
<FontAwesomeIcon icon={faAngleRight} className="transform rotate-[-90deg]" />
</button>
<button
className={styles.directionBtn(linearDirection === '315deg')}
onClick={() => setLinearDirection('315deg')}
title={t('tools.css_gradient_generator.direction_titles.315deg')}
>
<FontAwesomeIcon icon={faAngleRight} className="transform rotate-[-45deg]" />
</button>
<button
className={styles.directionBtn(linearDirection === '180deg')}
onClick={() => setLinearDirection('180deg')}
title={t('tools.css_gradient_generator.direction_titles.180deg')}
>
<FontAwesomeIcon icon={faAngleRight} className="transform rotate-[180deg]" />
</button>
<div className={styles.flexCenter}>
<FontAwesomeIcon icon={faCircle} className="text-purple text-xs" />
</div>
<button
className={styles.directionBtn(linearDirection === '0deg')}
onClick={() => setLinearDirection('0deg')}
title={t('tools.css_gradient_generator.direction_titles.0deg')}
>
<FontAwesomeIcon icon={faAngleRight} />
</button>
<button
className={styles.directionBtn(linearDirection === '135deg')}
onClick={() => setLinearDirection('135deg')}
title={t('tools.css_gradient_generator.direction_titles.135deg')}
>
<FontAwesomeIcon icon={faAngleRight} className="transform rotate-[135deg]" />
</button>
<button
className={styles.directionBtn(linearDirection === '90deg')}
onClick={() => setLinearDirection('90deg')}
title={t('tools.css_gradient_generator.direction_titles.90deg')}
>
<FontAwesomeIcon icon={faAngleRight} className="transform rotate-[90deg]" />
</button>
<button
className={styles.directionBtn(linearDirection === '45deg')}
onClick={() => setLinearDirection('45deg')}
title={t('tools.css_gradient_generator.direction_titles.45deg')}
>
<FontAwesomeIcon icon={faAngleRight} className="transform rotate-[45deg]" />
</button>
</div>
{/* 自定义角度 */}
<div className={styles.inputGroup}>
<div className="flex items-center justify-between mb-2">
<label className={styles.label}>{t('tools.css_gradient_generator.custom_angle')}</label>
<span className="text-sm text-secondary">{customAngle}°</span>
</div>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="359"
value={customAngle}
onChange={(e) => {
setCustomAngle(parseInt(e.target.value, 10));
setLinearDirection('custom');
}}
className={styles.rangeInput}
/>
<button
className={styles.optionBtn(linearDirection === 'custom')}
onClick={() => setLinearDirection('custom')}
>
{t('tools.css_gradient_generator.apply')}
</button>
</div>
</div>
</div>
) : (
<div>
{/* 径向渐变形状 */}
<div className={styles.inputGroup}>
<label className={styles.label}>{t('tools.css_gradient_generator.gradient_shape')}</label>
<div className={styles.flexRow}>
<button
className={styles.optionBtn(radialShape === 'circle')}
onClick={() => setRadialShape('circle')}
>
{t('tools.css_gradient_generator.circle')}
</button>
<button
className={styles.optionBtn(radialShape === 'ellipse')}
onClick={() => setRadialShape('ellipse')}
>
{t('tools.css_gradient_generator.ellipse')}
</button>
</div>
</div>
{/* 径向渐变位置 */}
<div>
<label className={styles.label}>{t('tools.css_gradient_generator.gradient_position')}</label>
<div className="grid grid-cols-3 gap-2">
{['top left', 'top', 'top right', 'left', 'center', 'right', 'bottom left', 'bottom', 'bottom right'].map((pos) => (
<button
key={pos}
className={styles.directionBtn(radialPosition === pos)}
onClick={() => setRadialPosition(pos as RadialPosition)}
>
<div className="w-2 h-2 rounded-full bg-current"></div>
</button>
))}
</div>
</div>
</div>
)}
</div>
{/* 渐变颜色 */}
<div className={styles.smallCard}>
<div className="flex justify-between items-center mb-4">
<h2 className={styles.heading}>{t('tools.css_gradient_generator.gradient_colors')}</h2>
<div className="flex gap-2">
<button
className="btn-secondary text-xs px-3 py-1"
onClick={addColorStop}
title={t('tools.css_gradient_generator.add_color_stop')}
>
<FontAwesomeIcon icon={faPlus} />
</button>
<button
className="btn-secondary text-xs px-3 py-1"
onClick={generateRandomGradient}
title={t('tools.css_gradient_generator.random_gradient')}
>
<FontAwesomeIcon icon={faRandom} />
</button>
</div>
</div>
{/* 颜色停止点列表 */}
<div className="mb-4">
{colorStops.sort((a, b) => a.position - b.position).map((stop) => (
<div key={stop.id} className={styles.colorStopItem}>
<input
type="color"
value={stop.color}
onChange={(e) => updateColorStop(stop.id, 'color', e.target.value)}
title="选择颜色"
className={styles.colorInput}
/>
<div className="flex-1">
<input
type="text"
value={stop.color}
onChange={(e) => updateColorStop(stop.id, 'color', e.target.value)}
className="w-full bg-block border border-purple-glow/20 rounded-md px-2 py-1 text-sm text-primary"
/>
</div>
<input
type="number"
value={stop.position}
min="0"
max="100"
onChange={(e) => updateColorStop(stop.id, 'position', e.target.value)}
className={styles.positionInput}
/>
<span className="text-xs text-tertiary">%</span>
{colorStops.length > 2 && (
<button
onClick={() => removeColorStop(stop.id)}
title="删除"
className={styles.deleteBtn}
>
<FontAwesomeIcon icon={faTrash} />
</button>
)}
</div>
))}
</div>
{/* 预设颜色 */}
<div>
<h3 className={styles.subheading}>{t('tools.css_gradient_generator.preset_colors')}</h3>
<div className="flex flex-wrap gap-2">
{presetColors.map((colors, index) => (
<button
key={index}
className={styles.presetBtn}
onClick={() => applyPreset(colors)}
style={{
background: `linear-gradient(to right, ${colors[0]}, ${colors[1]})`
}}
title={t('tools.css_gradient_generator.apply_preset')}
></button>
))}
</div>
</div>
</div>
</div>
{/* 右侧 - 预览和代码 */}
<div className="lg:col-span-2 space-y-6">
{/* 渐变预览 */}
<div className={styles.card}>
<h2 className={styles.heading}>{t('tools.css_gradient_generator.gradient_preview')}</h2>
<div className={styles.previewBox} style={gradientPreviewStyle}>
</div>
<div className="text-sm text-tertiary">
<p>{t('tools.css_gradient_generator.preview_hint')}</p>
</div>
</div>
{/* CSS代码 */}
<div className={styles.card}>
<h2 className={styles.heading}>{t('tools.css_gradient_generator.css_code')}</h2>
<div className="relative">
<pre className={styles.codeBlock}>
<span className={styles.codeComment}>{t('tools.css_gradient_generator.css_comment')}</span>
<br />
{cssCode.split('\n').map((line, index) => (
<React.Fragment key={index}>
{line}
<br />
</React.Fragment>
))}
</pre>
<button
className={styles.copyBtn}
onClick={copyToClipboard}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
{copied ? t('tools.css_gradient_generator.copied') : t('tools.css_gradient_generator.copy_code')}
</button>
</div>
</div>
</div>
</div>
{/* 回到顶部按钮 */}
<BackToTop position="bottom-right" offset={30} size="medium" />
</div>
);
}
+609
View File
@@ -0,0 +1,609 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalendarAlt, faCopy, faCheck, faPlus, faMinus } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
card: "card p-6",
input: "search-input w-full",
textarea: "w-full p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-[#6366F1] focus:outline-none focus:ring-1 focus:ring-[#6366F1] transition-all",
label: "text-secondary font-medium",
secondaryText: "text-sm text-tertiary",
resultItem: "flex justify-between items-center py-2 border-b border-purple-glow/10",
resultLabel: "text-sm text-secondary",
resultValue: "text-sm text-primary font-semibold",
iconButton: "text-tertiary hover:text-purple transition-colors",
button: "text-left w-full px-3 py-2 rounded-md text-sm text-secondary hover:bg-block-hover transition-colors",
tabButton: "px-3 py-2 text-sm font-medium transition-all",
activeTab: "bg-block text-primary shadow-sm",
inactiveTab: "text-tertiary",
secondaryBtn: "flex items-center gap-1 text-sm px-2 py-1 rounded bg-block-strong hover:bg-block-hover text-secondary transition-colors",
formGroup: "mb-6",
dateInput: "search-input w-full text-primary bg-block border border-purple-glow rounded-md focus:border-purple focus:outline-none px-3 py-2",
resultBox: "p-3 bg-block rounded-md border border-purple-glow mb-4",
dateUnitSelector: "grid grid-cols-2 sm:grid-cols-4 gap-2 mb-4",
}
// 时间单位常量
enum TimeUnit {
YEARS = 'years',
MONTHS = 'months',
WEEKS = 'weeks',
DAYS = 'days',
HOURS = 'hours',
MINUTES = 'minutes',
}
// 格式化日期为显示格式
const formatDateForDisplay = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
};
// 格式化日期为输入框格式
const formatDateForInput = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
export default function DateCalculator() {
const { t } = useLanguage();
// 模式: 计算日期差值 or 添加/减去日期
const [mode, setMode] = useState<'diff' | 'add'>('diff');
// 日期差值计算的状态
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [diffResult, setDiffResult] = useState<{[key: string]: number}>({});
// 日期加减的状态
const [baseDate, setBaseDate] = useState<string>('');
const [timeAmount, setTimeAmount] = useState<number>(1);
const [timeUnit, setTimeUnit] = useState<TimeUnit>(TimeUnit.DAYS);
const [operation, setOperation] = useState<'add' | 'subtract'>('add');
const [addResult, setAddResult] = useState<string>('');
// 复制状态
const [copied, setCopied] = useState<string | null>(null);
// 初始化日期
useEffect(() => {
const now = new Date();
const oneWeekAgo = new Date(now);
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
setStartDate(formatDateForInput(oneWeekAgo));
setEndDate(formatDateForInput(now));
setBaseDate(formatDateForInput(now));
// 初始化时计算一次
calculateDateDiff(formatDateForInput(oneWeekAgo), formatDateForInput(now));
calculateDateAddition(formatDateForInput(now), timeAmount, timeUnit, operation);
}, []);
// 计算日期差值
const calculateDateDiff = (start: string, end: string) => {
if (!start || !end) return;
try {
const startDateTime = new Date(start);
const endDateTime = new Date(end);
if (isNaN(startDateTime.getTime()) || isNaN(endDateTime.getTime())) {
return;
}
// 计算毫秒差值
const diffMs = endDateTime.getTime() - startDateTime.getTime();
// 计算各个单位的差值
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
// 计算月份差
let months = (endDateTime.getFullYear() - startDateTime.getFullYear()) * 12;
months += endDateTime.getMonth() - startDateTime.getMonth();
// 计算年份差
const diffYears = Math.floor(months / 12);
// 设置结果
setDiffResult({
years: diffYears,
months: months,
weeks: diffWeeks,
days: diffDays,
hours: diffHours,
minutes: diffMinutes,
seconds: diffSeconds,
milliseconds: diffMs
});
} catch (error) {
console.error(t('tools.date_calculator.error.calculation_error'), error);
}
};
// 计算日期加减
const calculateDateAddition = (base: string, amount: number, unit: TimeUnit, op: 'add' | 'subtract') => {
if (!base || isNaN(amount)) return;
try {
const baseDateTime = new Date(base);
if (isNaN(baseDateTime.getTime())) {
return;
}
const resultDate = new Date(baseDateTime);
const sign = op === 'add' ? 1 : -1;
switch (unit) {
case TimeUnit.YEARS:
resultDate.setFullYear(resultDate.getFullYear() + sign * amount);
break;
case TimeUnit.MONTHS:
resultDate.setMonth(resultDate.getMonth() + sign * amount);
break;
case TimeUnit.WEEKS:
resultDate.setDate(resultDate.getDate() + sign * amount * 7);
break;
case TimeUnit.DAYS:
resultDate.setDate(resultDate.getDate() + sign * amount);
break;
case TimeUnit.HOURS:
resultDate.setHours(resultDate.getHours() + sign * amount);
break;
case TimeUnit.MINUTES:
resultDate.setMinutes(resultDate.getMinutes() + sign * amount);
break;
}
// 格式化结果
setAddResult(formatDateForDisplay(resultDate));
} catch (error) {
console.error(t('tools.date_calculator.error.calculation_error'), error);
}
};
// 处理开始日期变更
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setStartDate(value);
calculateDateDiff(value, endDate);
};
// 处理结束日期变更
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setEndDate(value);
calculateDateDiff(startDate, value);
};
// 处理基准日期变更
const handleBaseDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setBaseDate(value);
calculateDateAddition(value, timeAmount, timeUnit, operation);
};
// 处理时间数量变更
const handleTimeAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value);
if (!isNaN(value)) {
setTimeAmount(value);
calculateDateAddition(baseDate, value, timeUnit, operation);
}
};
// 处理时间单位变更
const handleTimeUnitChange = (unit: TimeUnit) => {
setTimeUnit(unit);
calculateDateAddition(baseDate, timeAmount, unit, operation);
};
// 处理操作变更
const handleOperationChange = (op: 'add' | 'subtract') => {
setOperation(op);
calculateDateAddition(baseDate, timeAmount, timeUnit, op);
};
// 复制结果
const copyToClipboard = (text: string, type: string) => {
navigator.clipboard.writeText(text)
.then(() => {
setCopied(type);
setTimeout(() => setCopied(null), 1500);
})
.catch(err => console.error(t('tools.date_calculator.error.copy_failed'), err));
};
// 设置开始日期为当前时间
const setStartDateToCurrent = () => {
const now = formatDateForInput(new Date());
setStartDate(now);
calculateDateDiff(now, endDate);
};
// 设置结束日期为当前时间
const setEndDateToCurrent = () => {
const now = formatDateForInput(new Date());
setEndDate(now);
calculateDateDiff(startDate, now);
};
// 设置基准日期为当前时间
const setBaseDateToCurrent = () => {
const now = formatDateForInput(new Date());
setBaseDate(now);
calculateDateAddition(now, timeAmount, timeUnit, operation);
};
// 交换开始和结束日期
const swapDates = () => {
const temp = startDate;
setStartDate(endDate);
setEndDate(temp);
calculateDateDiff(endDate, temp);
};
// 渲染时间单位选择器
const renderUnitSelector = () => {
const units = [
{ value: TimeUnit.YEARS, label: t('tools.date_calculator.diff_calculator.year_unit') },
{ value: TimeUnit.MONTHS, label: t('tools.date_calculator.diff_calculator.month_unit') },
{ value: TimeUnit.WEEKS, label: t('tools.date_calculator.diff_calculator.week_unit') },
{ value: TimeUnit.DAYS, label: t('tools.date_calculator.diff_calculator.day_unit') },
{ value: TimeUnit.HOURS, label: t('tools.date_calculator.diff_calculator.hour_unit') },
{ value: TimeUnit.MINUTES, label: t('tools.date_calculator.diff_calculator.minute_unit') },
];
return (
<div className={styles.dateUnitSelector}>
{units.map(unit => (
<button
key={unit.value}
className={`btn-secondary px-2 py-1 text-xs ${timeUnit === unit.value ? 'bg-purple-glow/20 border-purple text-primary' : ''}`}
onClick={() => handleTimeUnitChange(unit.value)}
>
{unit.label}
</button>
))}
</div>
);
};
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
<ToolHeader
icon={faCalendarAlt}
toolCode="date_calculator"
title=""
description=""
/>
<div className="flex flex-wrap gap-4 justify-between items-center mb-6">
<div className="flex items-center bg-block rounded-md p-1">
<button
className={`${styles.tabButton} ${mode === 'diff' ? styles.activeTab : styles.inactiveTab} rounded-l-md`}
onClick={() => setMode('diff')}
>
{t('tools.date_calculator.mode.diff')}
</button>
<button
className={`${styles.tabButton} ${mode === 'add' ? styles.activeTab : styles.inactiveTab} rounded-r-md`}
onClick={() => setMode('add')}
>
{t('tools.date_calculator.mode.add')}
</button>
</div>
</div>
{/* 日期差值计算 */}
{mode === 'diff' && (
<div className={styles.card}>
<h2 className="text-lg font-medium text-primary mb-4">{t('tools.date_calculator.diff_calculator.title')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 输入部分 */}
<div className="space-y-6">
<div>
<label className={styles.label}>{t('tools.date_calculator.diff_calculator.start_date')}</label>
<div className="mt-2 flex items-center gap-2">
<input
type="datetime-local"
className={styles.dateInput}
value={startDate}
onChange={handleStartDateChange}
/>
<button
className={styles.secondaryBtn}
onClick={setStartDateToCurrent}
>
{t('tools.date_calculator.diff_calculator.current')}
</button>
</div>
</div>
<div className="flex justify-center">
<button
className={styles.secondaryBtn}
onClick={swapDates}
>
<FontAwesomeIcon icon={faCalendarAlt} className="mr-2" />
{t('tools.date_calculator.diff_calculator.swap_dates')}
</button>
</div>
<div>
<label className={styles.label}>{t('tools.date_calculator.diff_calculator.end_date')}</label>
<div className="mt-2 flex items-center gap-2">
<input
type="datetime-local"
className={styles.dateInput}
value={endDate}
onChange={handleEndDateChange}
/>
<button
className={styles.secondaryBtn}
onClick={setEndDateToCurrent}
>
{t('tools.date_calculator.diff_calculator.current')}
</button>
</div>
</div>
</div>
{/* 结果部分 */}
<div className={styles.resultBox}>
<h3 className="text-primary font-medium mb-3">{t('tools.date_calculator.diff_calculator.result_title')}</h3>
<div className="space-y-2">
{Object.keys(diffResult).length > 0 ? (
<>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.date_calculator.diff_calculator.years')}</span>
<div className="flex items-center gap-2">
<span className={styles.resultValue}>{diffResult.years} {t('tools.date_calculator.diff_calculator.year_unit')}</span>
<button
onClick={() => copyToClipboard(diffResult.years.toString(), 'years')}
className={styles.iconButton}
>
<FontAwesomeIcon icon={copied === 'years' ? faCheck : faCopy} />
</button>
</div>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.date_calculator.diff_calculator.months')}</span>
<div className="flex items-center gap-2">
<span className={styles.resultValue}>{diffResult.months} {t('tools.date_calculator.diff_calculator.month_unit')}</span>
<button
onClick={() => copyToClipboard(diffResult.months.toString(), 'months')}
className={styles.iconButton}
>
<FontAwesomeIcon icon={copied === 'months' ? faCheck : faCopy} />
</button>
</div>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.date_calculator.diff_calculator.weeks')}</span>
<div className="flex items-center gap-2">
<span className={styles.resultValue}>{diffResult.weeks} {t('tools.date_calculator.diff_calculator.week_unit')}</span>
<button
onClick={() => copyToClipboard(diffResult.weeks.toString(), 'weeks')}
className={styles.iconButton}
>
<FontAwesomeIcon icon={copied === 'weeks' ? faCheck : faCopy} />
</button>
</div>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.date_calculator.diff_calculator.days')}</span>
<div className="flex items-center gap-2">
<span className={styles.resultValue}>{diffResult.days} {t('tools.date_calculator.diff_calculator.day_unit')}</span>
<button
onClick={() => copyToClipboard(diffResult.days.toString(), 'days')}
className={styles.iconButton}
>
<FontAwesomeIcon icon={copied === 'days' ? faCheck : faCopy} />
</button>
</div>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.date_calculator.diff_calculator.hours')}</span>
<div className="flex items-center gap-2">
<span className={styles.resultValue}>{diffResult.hours} {t('tools.date_calculator.diff_calculator.hour_unit')}</span>
<button
onClick={() => copyToClipboard(diffResult.hours.toString(), 'hours')}
className={styles.iconButton}
>
<FontAwesomeIcon icon={copied === 'hours' ? faCheck : faCopy} />
</button>
</div>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.date_calculator.diff_calculator.minutes')}</span>
<div className="flex items-center gap-2">
<span className={styles.resultValue}>{diffResult.minutes} {t('tools.date_calculator.diff_calculator.minute_unit')}</span>
<button
onClick={() => copyToClipboard(diffResult.minutes.toString(), 'minutes')}
className={styles.iconButton}
>
<FontAwesomeIcon icon={copied === 'minutes' ? faCheck : faCopy} />
</button>
</div>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.date_calculator.diff_calculator.seconds')}</span>
<div className="flex items-center gap-2">
<span className={styles.resultValue}>{diffResult.seconds} {t('tools.date_calculator.diff_calculator.second_unit')}</span>
<button
onClick={() => copyToClipboard(diffResult.seconds.toString(), 'seconds')}
className={styles.iconButton}
>
<FontAwesomeIcon icon={copied === 'seconds' ? faCheck : faCopy} />
</button>
</div>
</div>
</>
) : (
<div className="text-tertiary text-center py-4">
{t('tools.date_calculator.diff_calculator.no_valid_dates')}
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* 日期加减计算 */}
{mode === 'add' && (
<div className={styles.card}>
<h2 className="text-lg font-medium text-primary mb-4">{t('tools.date_calculator.add_calculator.title')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 输入部分 */}
<div className="space-y-6">
<div>
<label className={styles.label}>{t('tools.date_calculator.add_calculator.base_date')}</label>
<div className="mt-2 flex items-center gap-2">
<input
type="datetime-local"
className={styles.dateInput}
value={baseDate}
onChange={handleBaseDateChange}
/>
<button
className={styles.secondaryBtn}
onClick={setBaseDateToCurrent}
>
{t('tools.date_calculator.diff_calculator.current')}
</button>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.date_calculator.add_calculator.operation')}</label>
<div className="flex items-center gap-2 mt-2">
<button
className={`btn-secondary px-3 py-1 ${operation === 'add' ? 'bg-purple-glow/20 border-purple text-primary' : ''}`}
onClick={() => handleOperationChange('add')}
>
<FontAwesomeIcon icon={faPlus} className="mr-2" />
{t('tools.date_calculator.add_calculator.add')}
</button>
<button
className={`btn-secondary px-3 py-1 ${operation === 'subtract' ? 'bg-purple-glow/20 border-purple text-primary' : ''}`}
onClick={() => handleOperationChange('subtract')}
>
<FontAwesomeIcon icon={faMinus} className="mr-2" />
{t('tools.date_calculator.add_calculator.subtract')}
</button>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.date_calculator.add_calculator.time_amount')}</label>
<div className="mt-2">
<input
type="number"
min="1"
className={styles.dateInput}
value={timeAmount}
onChange={handleTimeAmountChange}
/>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.date_calculator.add_calculator.time_unit')}</label>
<div className="mt-2">
{renderUnitSelector()}
</div>
</div>
</div>
{/* 结果部分 */}
<div className={styles.resultBox}>
<h3 className="text-primary font-medium mb-4">{t('tools.date_calculator.add_calculator.result_title')}</h3>
{addResult ? (
<div className="space-y-4">
<div className="p-4 bg-block-strong rounded-md text-center">
<div className="text-tertiary text-sm mb-1">
{operation === 'add'
? t('tools.date_calculator.add_calculator.add_result').replace('{amount}', timeAmount.toString()).replace('{unit}',
timeUnit === TimeUnit.YEARS ? t('tools.date_calculator.diff_calculator.year_unit') :
timeUnit === TimeUnit.MONTHS ? t('tools.date_calculator.diff_calculator.month_unit') :
timeUnit === TimeUnit.WEEKS ? t('tools.date_calculator.diff_calculator.week_unit') :
timeUnit === TimeUnit.DAYS ? t('tools.date_calculator.diff_calculator.day_unit') :
timeUnit === TimeUnit.HOURS ? t('tools.date_calculator.diff_calculator.hour_unit') :
t('tools.date_calculator.diff_calculator.minute_unit'))
: t('tools.date_calculator.add_calculator.subtract_result').replace('{amount}', timeAmount.toString()).replace('{unit}',
timeUnit === TimeUnit.YEARS ? t('tools.date_calculator.diff_calculator.year_unit') :
timeUnit === TimeUnit.MONTHS ? t('tools.date_calculator.diff_calculator.month_unit') :
timeUnit === TimeUnit.WEEKS ? t('tools.date_calculator.diff_calculator.week_unit') :
timeUnit === TimeUnit.DAYS ? t('tools.date_calculator.diff_calculator.day_unit') :
timeUnit === TimeUnit.HOURS ? t('tools.date_calculator.diff_calculator.hour_unit') :
t('tools.date_calculator.diff_calculator.minute_unit'))}
</div>
<div className="text-xl font-medium text-primary">
{addResult}
</div>
</div>
<div className="flex justify-center">
<button
className="btn-secondary px-4 py-2"
onClick={() => copyToClipboard(addResult, 'result')}
>
<FontAwesomeIcon icon={copied === 'result' ? faCheck : faCopy} className="mr-2" />
{copied === 'result' ? t('tools.date_calculator.add_calculator.copied') : t('tools.date_calculator.add_calculator.copy_result')}
</button>
</div>
</div>
) : (
<div className="text-tertiary text-center py-4">
{t('tools.date_calculator.add_calculator.no_valid_input')}
</div>
)}
</div>
</div>
<div className="mt-6">
<h3 className="text-primary font-medium mb-2">{t('tools.date_calculator.add_calculator.notes_title')}</h3>
<ul className="list-disc pl-5 space-y-1 text-sm text-tertiary">
<li>{t('tools.date_calculator.add_calculator.note1')}</li>
<li>{t('tools.date_calculator.add_calculator.note2')}</li>
<li>{t('tools.date_calculator.add_calculator.note3')}</li>
</ul>
</div>
</div>
)}
</div>
);
}
+349
View File
@@ -0,0 +1,349 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExchangeAlt, faCopy, faCheck, faSyncAlt, faEraser } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import tools from '@/config/tools';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
card: "card p-6",
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
typeBtnActive: "px-4 py-2 rounded-md transition-all bg-gradient-to-r from-[rgb(var(--color-primary))] to-[rgb(var(--color-primary-hover))] text-white shadow-sm shadow-[rgba(var(--color-primary),0.3)]",
typeBtnInactive: "px-4 py-2 rounded-md transition-all btn-secondary",
toggleContainer: "flex items-center bg-block-strong rounded-md p-1",
toggleBtnActive: "px-4 py-2 rounded-md transition-all bg-block text-primary shadow-sm",
toggleBtnInactive: "px-4 py-2 rounded-md transition-all text-tertiary",
description: "text-sm text-tertiary",
textArea: "w-full h-48 p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple resize-y font-mono",
errorMsg: "py-2 px-3 bg-red-900/20 border border-red-700/30 text-error rounded-md",
flexRow: "flex flex-col sm:flex-row gap-4 justify-between items-center",
copyBtn: "flex items-center gap-1 px-3 py-1 text-sm rounded-md bg-block-strong hover:bg-block-hover text-secondary transition-colors",
swapBtn: "bg-purple-glow/10 text-purple p-2 rounded-full hover:bg-purple-glow/20 transition-colors"
};
export default function EncodingConverter() {
const { t } = useLanguage();
// 从工具配置中获取当前工具图标
const toolIcon = tools.find(tool => tool.code === 'encoding_converter')?.icon || faExchangeAlt;
// 编码类型选项
const encodingTypes = [
{ id: 'base64', name: 'Base64', description: t('tools.encoding_converter.base64_desc') },
{ id: 'url', name: 'URL', description: t('tools.encoding_converter.url_desc') },
{ id: 'unicode', name: 'Unicode', description: t('tools.encoding_converter.unicode_desc') },
{ id: 'html', name: 'HTML', description: t('tools.encoding_converter.html_desc') }
];
// 状态管理
const [inputText, setInputText] = useState('');
const [outputText, setOutputText] = useState('');
const [encodingType, setEncodingType] = useState('base64');
const [operation, setOperation] = useState('encode'); // 'encode' 或 'decode'
const [error, setError] = useState('');
const [copied, setCopied] = useState(false);
// 当输入文本、编码类型或操作变化时,自动执行转换
useEffect(() => {
if (inputText.trim() === '') {
setOutputText('');
setError('');
return;
}
try {
const result = processConversion(inputText, encodingType, operation);
setOutputText(result);
setError('');
} catch (err) {
if (err instanceof Error) {
setError(err.message);
} else {
setError(t('tools.encoding_converter.general_error'));
}
setOutputText('');
}
}, [inputText, encodingType, operation, t]);
// 执行编码或解码操作
const processConversion = (text: string, type: string, op: string): string => {
if (text.trim() === '') return '';
try {
if (type === 'base64') {
return op === 'encode'
? btoa(encodeURIComponent(text).replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(parseInt(p1, 16))))
: decodeURIComponent(Array.from(atob(text.replace(/\s/g, '')))
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join(''));
}
else if (type === 'url') {
return op === 'encode'
? encodeURIComponent(text)
: decodeURIComponent(text);
}
else if (type === 'unicode') {
if (op === 'encode') {
return Array.from(text)
.map(char => '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0'))
.join('');
} else {
return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) =>
String.fromCharCode(parseInt(hex, 16))
);
}
}
else if (type === 'html') {
if (op === 'encode') {
const el = document.createElement('div');
el.textContent = text;
return el.innerHTML;
} else {
const el = document.createElement('div');
el.innerHTML = text;
return el.textContent || '';
}
}
throw new Error(t('tools.encoding_converter.unsupported_type'));
} catch (err) {
if (type === 'base64' && op === 'decode') {
throw new Error(t('tools.encoding_converter.invalid_base64'));
} else if (type === 'url' && op === 'decode') {
throw new Error(t('tools.encoding_converter.invalid_url'));
} else if (type === 'unicode' && op === 'decode') {
throw new Error(t('tools.encoding_converter.invalid_unicode'));
} else if (type === 'html' && op === 'decode') {
throw new Error(t('tools.encoding_converter.invalid_html'));
}
throw err;
}
};
// 复制输出内容到剪贴板
const copyToClipboard = () => {
if (!outputText) return;
navigator.clipboard.writeText(outputText)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => {
console.error(t('tools.encoding_converter.copy_failed'), err);
setError(t('tools.encoding_converter.clipboard_error'));
});
};
// 清空输入和输出
const clearAll = () => {
setInputText('');
setOutputText('');
setError('');
};
// 切换操作类型(编码/解码)
const _toggleOperation = () => {
// 添加动画过渡效果
const container = document.getElementById('converter-container');
if (container) {
container.classList.add('animate-pulse');
setTimeout(() => {
container.classList.remove('animate-pulse');
}, 300);
}
// 交换输入和输出文本
const newOperation = operation === 'encode' ? 'decode' : 'encode';
// 使用输出文本替换输入文本
setInputText(outputText);
setOperation(newOperation);
setError('');
};
// 加载示例文本
const loadExample = () => {
const examples = {
base64: {
encode: t('tools.encoding_converter.example_text'),
decode: '5L2g5aW977yM5LiW55WM77yB'
},
url: {
encode: 'https://jisuxiang.com?query=' + t('tools.encoding_converter.hello') + '&lang=zh-CN',
decode: 'https%3A%2F%2Fjisuxiang.com%3Fquery%3D%E4%BD%A0%E5%A5%BD%26lang%3Dzh-CN'
},
unicode: {
encode: t('tools.encoding_converter.example_text'),
decode: '\\u4f60\\u597d\\uff0c\\u4e16\\u754c\\uff01'
},
html: {
encode: '<div class="example">' + t('tools.encoding_converter.html_example') + '</div>',
decode: '&lt;div class=&quot;example&quot;&gt;HTML示例 &amp; 特殊字符&lt;/div&gt;'
}
};
// 根据当前编码类型和操作选择示例
const exampleText = examples[encodingType as keyof typeof examples][operation as keyof typeof examples[keyof typeof examples]];
setInputText(exampleText);
};
return (
<div className={styles.container}>
{/* 使用 ToolHeader 组件 */}
<ToolHeader
toolCode="encoding_converter"
title=""
description=""
icon={toolIcon}
/>
{/* 主要内容区域 */}
<div className={styles.card} id="converter-container">
<div className="space-y-6">
{/* 编码类型选择 */}
<div className="flex flex-wrap gap-2">
{encodingTypes.map((type) => (
<button
key={type.id}
className={encodingType === type.id ? styles.typeBtnActive : styles.typeBtnInactive}
onClick={() => setEncodingType(type.id)}
>
{type.name}
</button>
))}
</div>
{/* 操作类型切换 */}
<div className={styles.flexRow}>
<div className="flex items-center gap-4">
<div className={styles.toggleContainer}>
<button
className={operation === 'encode' ? styles.toggleBtnActive : styles.toggleBtnInactive}
onClick={() => setOperation('encode')}
>
{t('tools.encoding_converter.encode')}
</button>
<button
className={operation === 'decode' ? styles.toggleBtnActive : styles.toggleBtnInactive}
onClick={() => setOperation('decode')}
>
{t('tools.encoding_converter.decode')}
</button>
</div>
<div className={styles.description}>
{encodingTypes.find(type => type.id === encodingType)?.description}
</div>
</div>
<div className="flex gap-2">
<button
className="btn-secondary"
onClick={loadExample}
>
<FontAwesomeIcon icon={faSyncAlt} className="mr-2" />
{t('tools.encoding_converter.load_example')}
</button>
<button
className="btn-secondary"
onClick={clearAll}
>
<FontAwesomeIcon icon={faEraser} className="mr-2" />
{t('tools.encoding_converter.clear_all')}
</button>
</div>
</div>
{/* 输入输出区域 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 输入框 */}
<div className="space-y-2">
<label className="block text-sm text-secondary font-medium">
{operation === 'encode' ? t('tools.encoding_converter.text_to_encode') : t('tools.encoding_converter.text_to_decode')}
</label>
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder={t('tools.encoding_converter.input_placeholder')}
className={styles.textArea}
/>
</div>
{/* 输出框 */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<label className="block text-sm text-secondary font-medium">
{operation === 'encode' ? t('tools.encoding_converter.encoded_result') : t('tools.encoding_converter.decoded_result')}
</label>
</div>
<div className="relative">
<textarea
value={outputText}
readOnly
placeholder={t('tools.encoding_converter.output_placeholder')}
className={styles.textArea}
/>
{outputText && (
<button
onClick={copyToClipboard}
className="absolute top-3 right-3 opacity-70 hover:opacity-100 transition-opacity"
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
</button>
)}
</div>
{/* 错误信息 */}
{error && (
<div className={styles.errorMsg}>
{error}
</div>
)}
{/* 复制按钮 */}
{outputText && (
<div className="flex justify-end">
<button
onClick={copyToClipboard}
className={styles.copyBtn}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
<span>{copied ? t('common.copySuccess') : t('tools.encoding_converter.copy')}</span>
</button>
</div>
)}
</div>
</div>
{/* 说明部分 */}
<div className="mt-6 p-4 bg-block rounded-lg">
<h3 className="text-md font-medium text-primary mb-2"></h3>
<p className="text-sm text-tertiary">
{encodingType === 'base64' && (
"Base64是一种基于64个可打印字符来表示二进制数据的表示方法,常用于在HTTP环境下传输二进制数据,如图片或其他媒体文件。"
)}
{encodingType === 'url' && (
"URL编码将字符转换为可在URL中安全传输的格式,例如将空格转换为%20,中文和特殊字符也会被转换为%后跟十六进制值。"
)}
{encodingType === 'unicode' && (
"Unicode编码使用\\u前缀后跟四位十六进制数字表示字符,可以表示几乎所有语言的字符,如中文、日文等。"
)}
{encodingType === 'html' && (
"HTML编码将特殊字符(如<、>、&等)转换为HTML实体,以防止它们被浏览器解释为HTML标签或特殊结构。"
)}
</p>
</div>
</div>
</div>
{/* 回到顶部按钮 */}
<BackToTop />
</div>
);
}
@@ -0,0 +1,484 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faFileCode,
faCopy,
faCheck,
faRedo,
faExchangeAlt,
faInfoCircle,
faEraser
} from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
card: "card p-6",
textarea: "w-full h-64 p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple transition-all font-mono resize-y",
label: "text-sm text-secondary font-medium",
error: "p-3 bg-red-900/20 border border-red-700/30 rounded-lg text-error",
info: "text-sm text-tertiary",
actionBtn: "btn-secondary flex items-center gap-2",
actionBtnPrimary: "btn-primary flex items-center gap-2",
loading: "text-purple animate-pulse",
moduleLoading: "p-3 bg-purple-glow/10 border border-purple-glow/30 rounded-lg text-secondary",
toggleBtn: "px-3 py-2 text-sm font-medium rounded-md transition-all",
toggleBtnActive: "bg-gradient-to-r from-[rgb(var(--color-primary))] to-[rgb(var(--color-primary-hover))] text-white shadow-sm",
toggleBtnInactive: "btn-secondary",
toggleContainer: "flex items-center rounded-md p-1 bg-block-strong",
flexBetween: "flex flex-col sm:flex-row gap-4 justify-between items-center",
exchangeBtn: "bg-purple-glow/10 text-purple p-2 rounded-full hover:bg-purple-glow/20 transition-colors",
};
// 添加全局接口,使marked和turndown可以在window上使用
declare global {
interface Window {
marked: {
parse: (markdown: string, options?: Record<string, unknown>) => string;
[key: string]: unknown;
};
TurndownService: {
new (options?: Record<string, unknown>): {
turndown: (html: string) => string;
[key: string]: unknown;
};
};
}
}
export default function HtmlMarkdownConverter() {
const { t } = useLanguage();
// 输入与输出
const [input, setInput] = useState('');
const [output, setOutput] = useState('');
const [mode, setMode] = useState<'html2md' | 'md2html'>('md2html');
// 其他状态
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [isConverting, setIsConverting] = useState(false);
const [loadingModules, setLoadingModules] = useState(false);
const [modulesLoaded, setModulesLoaded] = useState(false);
// 动态加载marked和turndown库
useEffect(() => {
if (typeof window !== 'undefined' && !modulesLoaded) {
setLoadingModules(true);
const loadScripts = async () => {
try {
// 加载Marked库
const markedScript = document.createElement('script');
markedScript.src = '/lib/markdown/marked.min.js';
markedScript.async = true;
const markedPromise = new Promise<void>((resolve, reject) => {
markedScript.onload = () => {
console.log('Marked库加载成功');
resolve();
};
markedScript.onerror = (error) => {
console.error('加载本地Marked库失败,尝试从CDN加载:', error);
// 从CDN加载失败时的备用方案
const cdnMarkedScript = document.createElement('script');
cdnMarkedScript.src = 'https://cdn.jsdelivr.net/npm/marked/marked.min.js';
cdnMarkedScript.async = true;
cdnMarkedScript.onload = () => {
console.log('从CDN加载Marked库成功');
resolve();
};
cdnMarkedScript.onerror = (cdnError) => {
console.error('从CDN加载Marked库失败:', cdnError);
reject(new Error('加载Marked库失败'));
};
document.body.appendChild(cdnMarkedScript);
};
});
document.body.appendChild(markedScript);
// 加载Turndown库
const turndownScript = document.createElement('script');
turndownScript.src = '/lib/markdown/turndown.js';
turndownScript.async = true;
const turndownPromise = new Promise<void>((resolve, reject) => {
turndownScript.onload = () => {
console.log('Turndown库加载成功');
resolve();
};
turndownScript.onerror = (error) => {
console.error('加载本地Turndown库失败,尝试从CDN加载:', error);
// 从CDN加载失败时的备用方案
const cdnTurndownScript = document.createElement('script');
cdnTurndownScript.src = 'https://cdn.jsdelivr.net/npm/turndown/dist/turndown.js';
cdnTurndownScript.async = true;
cdnTurndownScript.onload = () => {
console.log('从CDN加载Turndown库成功');
resolve();
};
cdnTurndownScript.onerror = (cdnError) => {
console.error('从CDN加载Turndown库失败:', cdnError);
reject(new Error('加载Turndown库失败'));
};
document.body.appendChild(cdnTurndownScript);
};
});
document.body.appendChild(turndownScript);
// 等待两个脚本都加载完成
await Promise.all([markedPromise, turndownPromise]);
console.log('所有模块加载完成!');
setModulesLoaded(true);
setLoadingModules(false);
} catch (error) {
console.error('加载库失败:', error);
setError(t('tools.html_markdown_converter.error_load'));
setLoadingModules(false);
}
};
loadScripts();
}
return () => {
// 清理函数不需要移除脚本,因为它们会一直被缓存和重用
};
}, [modulesLoaded, t]);
// 转换函数
const convertContent = () => {
if (!input.trim()) {
setError(t('tools.html_markdown_converter.error_empty'));
setOutput('');
return;
}
setIsConverting(true);
setError(null);
try {
if (mode === 'md2html') {
// Markdown 转 HTML
const html = window.marked.parse(input);
setOutput(html);
} else {
// HTML 转 Markdown
const turndownService = new window.TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced'
});
const markdown = turndownService.turndown(input);
setOutput(markdown);
}
setError(null);
} catch (err) {
console.error('转换错误:', err);
const errorMsg = t('tools.html_markdown_converter.error_convert').replace(
'{error}',
err instanceof Error ? err.message : t('tools.html_markdown_converter.error_unknown')
);
setError(errorMsg);
setOutput('');
} finally {
setIsConverting(false);
}
};
// 复制结果
const copyResult = () => {
if (!output) return;
navigator.clipboard.writeText(output)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => {
console.error(t('tools.html_markdown_converter.error_copy'), err);
setError(t('tools.html_markdown_converter.error_copy'));
});
};
// 切换转换模式
const toggleMode = () => {
// 切换模式时交换输入和输出
setMode(prevMode => prevMode === 'md2html' ? 'html2md' : 'md2html');
setInput(output);
setOutput(input);
setError(null);
};
// 清空所有内容
const clearAll = () => {
setInput('');
setOutput('');
setError(null);
};
// 加载示例
const loadExample = () => {
if (mode === 'md2html') {
setInput(`# 示例标题
这是一段**粗体**文字和*斜体*文字。
## 子标题
- 列表项1
- 列表项2
- 列表项3
[这是一个链接](https://example.com)
\`\`\`javascript
// 这是一段代码
function hello() {
console.log("Hello, world!");
}
\`\`\`
> 这是一段引用文字
---
| 表头1 | 表头2 |
|-------|-------|
| 单元格1 | 单元格2 |
| 单元格3 | 单元格4 |
`);
} else {
setInput(`<h1>示例标题</h1>
<p>这是一段<strong>粗体</strong>文字和<em>斜体</em>文字。</p>
<h2>子标题</h2>
<ul>
<li>列表项1</li>
<li>列表项2</li>
<li>列表项3</li>
</ul>
<p><a href="https://example.com">这是一个链接</a></p>
<pre><code class="language-javascript">// 这是一段代码
function hello() {
console.log("Hello, world!");
}
</code></pre>
<blockquote>
<p>这是一段引用文字</p>
</blockquote>
<hr />
<table>
<thead>
<tr>
<th>表头1</th>
<th>表头2</th>
</tr>
</thead>
<tbody>
<tr>
<td>单元格1</td>
<td>单元格2</td>
</tr>
<tr>
<td>单元格3</td>
<td>单元格4</td>
</tr>
</tbody>
</table>`);
}
setOutput('');
setError(null);
};
return (
<div className={styles.container}>
{/* 工具头部 */}
<ToolHeader
toolCode="html_markdown_converter"
icon={faFileCode}
title=""
description=""
/>
{/* 主内容区域 */}
<div className={styles.card}>
<div className="space-y-6">
{/* 转换模式切换 */}
<div className={styles.flexBetween}>
<div className={styles.toggleContainer}>
<button
className={`${styles.toggleBtn} ${mode === 'md2html' ? styles.toggleBtnActive : styles.toggleBtnInactive}`}
onClick={() => setMode('md2html')}
>
{t('tools.html_markdown_converter.md2html')}
</button>
<button
className={`${styles.toggleBtn} ${mode === 'html2md' ? styles.toggleBtnActive : styles.toggleBtnInactive}`}
onClick={() => setMode('html2md')}
>
{t('tools.html_markdown_converter.html2md')}
</button>
</div>
<div className="flex gap-2">
<button
onClick={loadExample}
className={styles.actionBtn}
disabled={loadingModules}
>
<FontAwesomeIcon icon={faRedo} />
{t('tools.html_markdown_converter.load_example')}
</button>
<button
onClick={clearAll}
className={styles.actionBtn}
disabled={!input && !output}
>
<FontAwesomeIcon icon={faEraser} />
{t('tools.html_markdown_converter.clear')}
</button>
</div>
</div>
{/* 加载状态提示 */}
{loadingModules && (
<div className={styles.moduleLoading}>
<FontAwesomeIcon icon={faInfoCircle} className="mr-2" />
{t('tools.html_markdown_converter.loading_modules')}
</div>
)}
{/* 输入输出区域 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 输入区域 */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<label className={styles.label}>
{mode === 'md2html'
? t('tools.html_markdown_converter.md_input')
: t('tools.html_markdown_converter.html_input')}
</label>
</div>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={mode === 'md2html'
? t('tools.html_markdown_converter.md_placeholder')
: t('tools.html_markdown_converter.html_placeholder')}
className={styles.textarea}
disabled={loadingModules}
/>
</div>
{/* 输出区域 */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<div className="flex items-center">
<label className={styles.label}>
{mode === 'md2html'
? t('tools.html_markdown_converter.html_output')
: t('tools.html_markdown_converter.md_output')}
</label>
<button
onClick={toggleMode}
className={styles.exchangeBtn}
title={t('tools.html_markdown_converter.exchange')}
disabled={loadingModules}
>
<FontAwesomeIcon icon={faExchangeAlt} />
</button>
</div>
<div>
<button
onClick={convertContent}
className={styles.actionBtnPrimary}
disabled={!input || loadingModules || isConverting}
>
{isConverting
? t('tools.html_markdown_converter.converting')
: t('tools.html_markdown_converter.convert')}
</button>
</div>
</div>
<textarea
value={output}
readOnly
placeholder={mode === 'md2html'
? t('tools.html_markdown_converter.html_result_placeholder')
: t('tools.html_markdown_converter.md_result_placeholder')}
className={styles.textarea}
/>
{/* 复制按钮 */}
{output && (
<div className="flex justify-end">
<button
onClick={copyResult}
className={styles.actionBtn}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
{copied
? t('tools.html_markdown_converter.copied')
: t('tools.html_markdown_converter.copy_result')}
</button>
</div>
)}
</div>
</div>
{/* 错误提示 */}
{error && (
<div className={styles.error}>
<FontAwesomeIcon icon={faInfoCircle} className="mr-2" />
{error}
</div>
)}
{/* 说明部分 */}
<div className="p-4 bg-block rounded-lg">
<h3 className="text-primary font-medium mb-2">{t('tools.html_markdown_converter.feature_title')}</h3>
<div className={styles.info}>
<p className="mb-2">
{t('tools.html_markdown_converter.feature_intro')}
</p>
<ul className="list-disc pl-5 space-y-1">
<li>{t('tools.html_markdown_converter.feature_1')}</li>
<li>{t('tools.html_markdown_converter.feature_2')}</li>
<li>{t('tools.html_markdown_converter.feature_3')}</li>
<li>{t('tools.html_markdown_converter.feature_4')}</li>
</ul>
<p className="mt-2">
{mode === 'md2html'
? t('tools.html_markdown_converter.md2html_description')
: t('tools.html_markdown_converter.html2md_description')}
</p>
</div>
</div>
</div>
</div>
{/* 回到顶部按钮 */}
<BackToTop />
</div>
);
}
@@ -0,0 +1,387 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faFileCode,
faCopy,
faCheck,
faRedo,
faExchangeAlt,
faInfoCircle
} from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
// 添加全局接口,使marked和turndown可以在window上使用
declare global {
interface Window {
marked: {
parse: (markdown: string, options?: Record<string, unknown>) => string;
[key: string]: unknown;
};
TurndownService: {
new (options?: Record<string, unknown>): {
turndown: (html: string) => string;
[key: string]: unknown;
};
};
}
}
export default function HtmlMarkdownConverter() {
// 输入与输出
const [input, setInput] = useState('');
const [output, setOutput] = useState('');
const [mode, setMode] = useState<'html2md' | 'md2html'>('md2html');
// 其他状态
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [isConverting, setIsConverting] = useState(false);
const [loadingModules, setLoadingModules] = useState(false);
const [modulesLoaded, setModulesLoaded] = useState(false);
// 动态加载marked和turndown库
useEffect(() => {
if (typeof window !== 'undefined' && !modulesLoaded) {
setLoadingModules(true);
const loadScripts = async () => {
try {
// 加载Marked库
const markedScript = document.createElement('script');
markedScript.src = '/lib/markdown/marked.min.js';
markedScript.async = true;
const markedPromise = new Promise<void>((resolve, reject) => {
markedScript.onload = () => {
console.log('Marked库加载成功');
resolve();
};
markedScript.onerror = (error) => {
console.error('加载本地Marked库失败,尝试从CDN加载:', error);
// 从CDN加载失败时的备用方案
const cdnMarkedScript = document.createElement('script');
cdnMarkedScript.src = 'https://cdn.jsdelivr.net/npm/marked/marked.min.js';
cdnMarkedScript.async = true;
cdnMarkedScript.onload = () => {
console.log('从CDN加载Marked库成功');
resolve();
};
cdnMarkedScript.onerror = (cdnError) => {
console.error('从CDN加载Marked库失败:', cdnError);
reject(new Error('加载Marked库失败'));
};
document.body.appendChild(cdnMarkedScript);
};
});
document.body.appendChild(markedScript);
// 加载Turndown库
const turndownScript = document.createElement('script');
turndownScript.src = '/lib/markdown/turndown.js';
turndownScript.async = true;
const turndownPromise = new Promise<void>((resolve, reject) => {
turndownScript.onload = () => {
console.log('Turndown库加载成功');
resolve();
};
turndownScript.onerror = (error) => {
console.error('加载本地Turndown库失败,尝试从CDN加载:', error);
// 从CDN加载失败时的备用方案
const cdnTurndownScript = document.createElement('script');
cdnTurndownScript.src = 'https://cdn.jsdelivr.net/npm/turndown/dist/turndown.js';
cdnTurndownScript.async = true;
cdnTurndownScript.onload = () => {
console.log('从CDN加载Turndown库成功');
resolve();
};
cdnTurndownScript.onerror = (cdnError) => {
console.error('从CDN加载Turndown库失败:', cdnError);
reject(new Error('加载Turndown库失败'));
};
document.body.appendChild(cdnTurndownScript);
};
});
document.body.appendChild(turndownScript);
// 等待两个脚本都加载完成
await Promise.all([markedPromise, turndownPromise]);
console.log('所有模块加载完成!');
setModulesLoaded(true);
setLoadingModules(false);
} catch (error) {
console.error('加载库失败:', error);
setError('加载转换库失败,请刷新页面重试');
setLoadingModules(false);
}
};
loadScripts();
}
return () => {
// 清理函数不需要移除脚本,因为它们会一直被缓存和重用
};
}, [modulesLoaded]);
// 转换函数
const convertContent = () => {
if (!input.trim()) {
setError('请输入需要转换的内容');
setOutput('');
return;
}
setIsConverting(true);
setError(null);
try {
if (mode === 'md2html') {
// Markdown 转 HTML
const html = window.marked.parse(input);
setOutput(html);
} else {
// HTML 转 Markdown
const turndownService = new window.TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced'
});
const markdown = turndownService.turndown(input);
setOutput(markdown);
}
setError(null);
} catch (err) {
console.error('转换错误:', err);
setError(`转换失败: ${err instanceof Error ? err.message : '未知错误'}`);
setOutput('');
} finally {
setIsConverting(false);
}
};
// 复制结果
const copyResult = () => {
if (!output) return;
navigator.clipboard.writeText(output)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => {
console.error('复制失败:', err);
setError('复制到剪贴板失败');
});
};
// 切换转换模式
const toggleMode = () => {
// 切换模式时交换输入和输出
setMode(prevMode => prevMode === 'md2html' ? 'html2md' : 'md2html');
setInput(output);
setOutput(input);
setError(null);
};
// 清空所有内容
const clearAll = () => {
setInput('');
setOutput('');
setError(null);
};
// 加载示例
const loadExample = () => {
if (mode === 'md2html') {
setInput(`# 示例标题
这是一段**粗体**文字和*斜体*文字。
## 子标题
- 列表项1
- 列表项2
- 列表项3
[这是一个链接](https://example.com)
\`\`\`javascript
// 这是一段代码
function hello() {
console.log("Hello, world!");
}
\`\`\`
> 这是一段引用文字
---
| 表头1 | 表头2 |
|-------|-------|
| 单元格1 | 单元格2 |
| 单元格3 | 单元格4 |
`);
} else {
setInput(`<h1>示例标题</h1>
<p>这是一段<strong>粗体</strong>文字和<em>斜体</em>文字。</p>
<h2>子标题</h2>
<ul>
<li>列表项1</li>
<li>列表项2</li>
<li>列表项3</li>
</ul>
<p><a href="https://example.com">这是一个链接</a></p>
<pre><code class="language-javascript">// 这是一段代码
function hello() {
console.log("Hello, world!");
}
</code></pre>
<blockquote>
<p>这是一段引用文字</p>
</blockquote>
<hr>
<table>
<thead>
<tr>
<th>表头1</th>
<th>表头2</th>
</tr>
</thead>
<tbody>
<tr>
<td>单元格1</td>
<td>单元格2</td>
</tr>
<tr>
<td>单元格3</td>
<td>单元格4</td>
</tr>
</tbody>
</table>
`);
}
};
return (
<div className="container mx-auto px-4 py-8 max-w-6xl">
<ToolHeader
title="HTML与Markdown互转"
description="HTML和Markdown文档格式转换"
icon={faFileCode}
/>
<div className="mb-6 flex flex-wrap gap-4">
<button
className={`btn-primary px-4 py-2 flex items-center gap-2 ${isConverting || loadingModules ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={convertContent}
disabled={isConverting || loadingModules || !input.trim()}
>
<FontAwesomeIcon icon={faFileCode} />
转换
</button>
<button
className="btn-secondary px-4 py-2 flex items-center gap-2"
onClick={toggleMode}
>
<FontAwesomeIcon icon={faExchangeAlt} />
切换: {mode === 'md2html' ? 'Markdown → HTML' : 'HTML → Markdown'}
</button>
<button
className={`btn-secondary px-4 py-2 flex items-center gap-2 ${!output ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={copyResult}
disabled={!output}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
{copied ? '已复制' : '复制'}
</button>
<button
className="btn-secondary px-4 py-2 flex items-center gap-2"
onClick={clearAll}
>
<FontAwesomeIcon icon={faRedo} />
清空
</button>
<button
className="btn-secondary px-4 py-2 flex items-center gap-2"
onClick={loadExample}
>
<FontAwesomeIcon icon={faInfoCircle} />
示例
</button>
</div>
{error && (
<div className="bg-red-900 bg-opacity-30 border border-red-700 text-red-100 px-4 py-2 rounded mb-4">
<FontAwesomeIcon icon={faInfoCircle} className="mr-2" />
{error}
</div>
)}
{loadingModules && (
<div className="bg-blue-900 bg-opacity-30 border border-blue-700 text-blue-100 px-4 py-2 rounded mb-4">
<FontAwesomeIcon icon={faInfoCircle} className="mr-2" />
正在加载转换库,请稍候...
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-2">
<h2 className="text-lg font-medium text-[#F1F5F9]">
{mode === 'md2html' ? 'Markdown' : 'HTML'}
</h2>
<textarea
className="w-full h-96 p-4 bg-[#1E293B] text-[#F1F5F9] rounded-lg border border-[rgba(99,102,241,0.15)] focus:border-[#6366F1] focus:ring-1 focus:ring-[#6366F1] outline-none font-mono"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={mode === 'md2html' ? '请输入Markdown内容...' : '请输入HTML内容...'}
spellCheck={false}
/>
</div>
<div className="space-y-2">
<h2 className="text-lg font-medium text-[#F1F5F9]">
{mode === 'md2html' ? 'HTML' : 'Markdown'}
</h2>
<textarea
className="w-full h-96 p-4 bg-[#1E293B] text-[#F1F5F9] rounded-lg border border-[rgba(99,102,241,0.15)] outline-none font-mono"
value={output}
readOnly
placeholder="转换结果将显示在这里..."
spellCheck={false}
/>
</div>
</div>
{mode === 'md2html' && output && (
<div className="mt-6">
<h2 className="text-lg font-medium text-[#F1F5F9] mb-2">预览</h2>
<div
className="p-4 bg-[#1E293B] rounded-lg border border-[rgba(99,102,241,0.15)] prose prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: output }}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,23 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import styles from '../styles';
interface ErrorDisplayProps {
error: string | null;
}
const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ error }) => {
if (!error) {
return null;
}
return (
<div className={styles.errorBox}>
<FontAwesomeIcon icon={faExclamationTriangle} className="mr-2 text-warning" />
{error}
</div>
);
};
export default ErrorDisplay;
@@ -0,0 +1,99 @@
import React from 'react';
interface JsonRendererProps {
data: unknown;
}
/**
* JSON格式化和语法高亮组件
*/
const JsonRenderer: React.FC<JsonRendererProps> = ({ data }) => {
// 递归渲染JSON对象
const renderJsonValue = (value: unknown, depth = 0, isLast = true): React.ReactNode => {
const indent = Array(depth * 2).fill(' ').join('');
// 处理不同类型的值
if (value === null) return <span className="text-error">null</span>;
if (value === undefined) return <span className="text-tertiary">undefined</span>;
if (typeof value === 'boolean') {
return <span className="text-warning">{value.toString()}</span>;
}
if (typeof value === 'number') {
return <span className="text-success">{value}</span>;
}
if (typeof value === 'string') {
return <span className="text-primary-light">&quot;{value}&quot;</span>;
}
// 处理数组
if (Array.isArray(value)) {
if (value.length === 0) return <span>[]</span>;
return (
<span>
<span>[</span>
<div style={{ paddingLeft: '20px' }}>
{value.map((item, index) => (
<div key={index}>
{renderJsonValue(item, depth + 1, index === value.length - 1)}
{index !== value.length - 1 && <span className="text-tertiary">,</span>}
</div>
))}
</div>
<span>{indent}]</span>
{!isLast && <span className="text-tertiary">,</span>}
</span>
);
}
// 处理对象
if (typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) return <span>{'{}'}</span>;
return (
<span>
<span>{'{'}</span>
<div style={{ paddingLeft: '20px' }}>
{entries.map(([key, val], index) => (
<div key={key}>
<span className="text-purple">&quot;{key}&quot;</span>
<span className="text-tertiary">: </span>
{renderJsonValue(val, depth + 1, index === entries.length - 1)}
{index !== entries.length - 1 && <span className="text-tertiary">,</span>}
</div>
))}
</div>
<span>{indent}{'}'}</span>
{!isLast && <span className="text-tertiary">,</span>}
</span>
);
}
return <span>{String(value)}</span>;
};
// 解析JSON字符串 (如果传入的是字符串)
const parseAndRender = () => {
try {
if (typeof data === 'string') {
const parsedData = JSON.parse(data);
return renderJsonValue(parsedData);
}
return renderJsonValue(data);
} catch {
return <span className="text-error">JSON: {String(data)}</span>;
}
};
return (
<div className="font-mono text-sm overflow-x-auto">
{parseAndRender()}
</div>
);
};
export default JsonRenderer;
@@ -0,0 +1,189 @@
import React, { useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCopy, faCheck, faDownload, faTimes, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons';
import { createPortal } from 'react-dom';
interface MarkdownPreviewProps {
markdown: string;
isOpen: boolean;
onClose: () => void;
}
const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({ markdown, isOpen, onClose }) => {
const [copied, setCopied] = React.useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// 处理关闭模态框
const handleClose = () => {
onClose();
};
// 处理点击模态框外部关闭
const handleOutsideClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (modalRef.current && e.target === e.currentTarget) {
handleClose();
}
};
// 处理复制文档内容
const handleCopy = () => {
navigator.clipboard.writeText(markdown)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => console.error('复制失败', err));
};
// 处理下载文档
const handleDownload = () => {
const blob = new Blob([markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// 使用固定的文件名,不再从文档标题提取
const fileName = 'api_document.md';
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// 打开ShowDoc官网
const openShowDoc = () => {
window.open('https://www.showdoc.com.cn/', '_blank');
};
// 监听ESC键关闭模态窗口
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
handleClose();
}
};
window.addEventListener('keydown', handleEsc);
return () => {
window.removeEventListener('keydown', handleEsc);
};
}, [isOpen]);
// 当打开模态框时,禁止背景滚动
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
// 自动聚焦文本框,但不选中内容,以便用户可以正常阅读
if (textareaRef.current) {
textareaRef.current.focus();
}
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
// 如果模态框未打开,不渲染任何内容
if (!isOpen) return null;
// 模态窗口的内容
const modalContent = (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center p-4 bg-block-strong/80 backdrop-blur-sm"
onClick={handleOutsideClick}
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0 }}
>
<div
ref={modalRef}
className="relative bg-block border border-purple-glow rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] flex flex-col"
>
{/* 模态框标题 */}
<div className="flex justify-between items-center px-6 py-4 border-b border-purple-glow/30">
<h3 className="text-lg font-medium text-primary"></h3>
<button
className="text-tertiary hover:text-primary"
onClick={handleClose}
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
{/* ShowDoc推荐信息 */}
<div className="px-6 py-3 bg-purple/10 border-b border-purple-glow/20">
<div className="flex items-center justify-between">
<div className="flex items-center">
<span className="text-sm text-secondary">
<span className="text-purple font-medium"></span>
使ShowDoc风格编写ShowDoc平台进行团队分享和管理
</span>
</div>
<button
onClick={openShowDoc}
className="text-xs text-purple hover:text-purple-hover flex items-center gap-1 transition-colors"
>
访ShowDoc官网
<FontAwesomeIcon icon={faExternalLinkAlt} className="text-[10px]" />
</button>
</div>
</div>
{/* 模态框内容 */}
<div className="flex-1 overflow-auto p-6">
<textarea
ref={textareaRef}
className="w-full h-full min-h-[500px] p-5 bg-block-strong border border-purple-glow/30 rounded-lg text-primary font-mono text-sm leading-relaxed resize-none focus:outline-none focus:border-purple-glow"
value={markdown}
readOnly
style={{
lineHeight: '1.7',
letterSpacing: '0.3px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
scrollBehavior: 'smooth',
whiteSpace: 'pre',
overflowWrap: 'normal',
tabSize: 2
}}
/>
</div>
{/* 模态框底部操作按钮 */}
<div className="flex justify-end gap-3 px-6 py-4 border-t border-purple-glow/30">
<span className="text-sm text-tertiary mr-auto">
使 <a href="https://www.showdoc.com.cn/" target="_blank" rel="noopener noreferrer" className="text-purple hover:underline">ShowDoc</a>
</span>
<button
className="btn-secondary flex items-center gap-2 px-4 py-2 text-sm"
onClick={handleDownload}
>
<FontAwesomeIcon icon={faDownload} />
</button>
<button
className="btn-primary flex items-center gap-2 px-4 py-2 text-sm"
onClick={handleCopy}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
{copied ? '已复制' : '复制文档'}
</button>
</div>
</div>
</div>
);
// 使用React Portal将模态窗口渲染到body元素下,保证它不受父元素影响
if (typeof document !== 'undefined') {
return createPortal(modalContent, document.body);
}
return null;
};
export default MarkdownPreview;
@@ -0,0 +1,124 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons';
import styles from '../styles';
import { FormField } from '../types';
import { useLanguage } from '@/context/LanguageContext';
interface RequestBodyProps {
bodyFormat: 'json' | 'text' | 'form';
body: string;
formFields: FormField[];
onBodyChange: (body: string) => void;
onBodyFormatChange: (format: 'json' | 'text' | 'form') => void;
onAddFormField: () => void;
onUpdateFormField: (id: string, key: string, value: string) => void;
onRemoveFormField: (id: string) => void;
}
const RequestBody: React.FC<RequestBodyProps> = ({
bodyFormat,
body,
formFields,
onBodyChange,
onBodyFormatChange,
onAddFormField,
onUpdateFormField,
onRemoveFormField,
}) => {
const { t } = useLanguage();
return (
<div>
{/* 请求体格式选择 */}
<div className="flex gap-2 mb-4">
<button
className={styles.methodButton(bodyFormat === 'json')}
onClick={() => onBodyFormatChange('json')}
>
{t('tools.http_tester.json_format')}
</button>
<button
className={styles.methodButton(bodyFormat === 'text')}
onClick={() => onBodyFormatChange('text')}
>
{t('tools.http_tester.text_format')}
</button>
<button
className={styles.methodButton(bodyFormat === 'form')}
onClick={() => onBodyFormatChange('form')}
>
{t('tools.http_tester.form_format')}
</button>
</div>
{bodyFormat === 'json' && (
<>
<textarea
value={body}
onChange={(e) => onBodyChange(e.target.value)}
placeholder='{\n "key": "value"\n}'
className={styles.textArea}
/>
<div className="mt-2 text-xs text-tertiary">
{t('tools.http_tester.enter_request_body')}
</div>
</>
)}
{bodyFormat === 'text' && (
<textarea
value={body}
onChange={(e) => onBodyChange(e.target.value)}
placeholder={t('tools.http_tester.enter_request_body')}
className={styles.textArea}
/>
)}
{bodyFormat === 'form' && (
<div>
<div className="mb-4">
<button
className="btn-secondary text-xs px-3 py-1"
onClick={onAddFormField}
>
<FontAwesomeIcon icon={faPlus} className="mr-1" />
{t('tools.http_tester.add_form_field')}
</button>
</div>
{formFields.map(field => (
<div key={field.id} className={styles.headerRow}>
<input
type="text"
placeholder={t('tools.http_tester.form_field_key')}
value={field.key}
onChange={(e) => onUpdateFormField(field.id, e.target.value, field.value)}
className="flex-1 bg-block text-primary px-3 py-1 text-sm rounded-md border border-purple-glow/20 focus:border-purple-glow"
/>
<input
type="text"
placeholder={t('tools.http_tester.form_field_value')}
value={field.value}
onChange={(e) => onUpdateFormField(field.id, field.key, e.target.value)}
className="flex-1 bg-block text-primary px-3 py-1 text-sm rounded-md border border-purple-glow/20 focus:border-purple-glow"
/>
<button
onClick={() => onRemoveFormField(field.id)}
className={styles.iconButton}
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
))}
<div className="mt-2 text-xs text-tertiary">
{t('tools.http_tester.enter_request_body')}
</div>
</div>
)}
</div>
);
};
export default RequestBody;
@@ -0,0 +1,629 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPaperPlane, faInfoCircle, faTimes, faCode, faServer, faNetworkWired } from '@fortawesome/free-solid-svg-icons';
import styles from '../styles';
import { HttpMethod, NetworkType } from '../types';
import { useLanguage } from '@/context/LanguageContext';
// 定义跨域配置弹窗组件
interface CorsModalProps {
isOpen: boolean;
onClose: () => void;
}
const CorsModal: React.FC<CorsModalProps> = ({ isOpen, onClose }) => {
const [activeTab, setActiveTab] = React.useState<'nginx' | 'php' | 'node' | 'java' | 'python' | 'go'>('nginx');
const { t } = useLanguage();
if (!isOpen) return null;
const tabClasses = (isActive: boolean) =>
`px-3 py-2 text-xs font-medium rounded-t-md ${
isActive
? 'bg-background text-purple border-t border-l border-r border-purple-glow/30'
: 'bg-card hover:bg-background/60 text-tertiary hover:text-secondary transition-colors'
}`;
return (
<>
{/* 背景蒙层 - 使用透明度动画 */}
<div
className="fixed inset-0 bg-black/70 z-50 backdrop-blur-sm animate-fadeIn"
onClick={onClose}
role="button"
aria-label={t('tools.http_tester.close')}
tabIndex={0}
></div>
{/* 弹窗本身 - 使用弹性盒使其居中 */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 animate-scaleIn">
<div
className="bg-card w-full max-w-3xl rounded-lg shadow-xl border border-purple-glow/30 overflow-hidden flex flex-col"
style={{
maxHeight: 'calc(100vh - 40px)',
transform: 'translate3d(0,0,0)' // 强制硬件加速,避免某些浏览器渲染问题
}}
onClick={(e) => e.stopPropagation()}
>
{/* 标题栏 */}
<div className="bg-background p-4 flex items-center justify-between border-b border-purple-glow/30 shrink-0">
<div className="flex items-center">
<FontAwesomeIcon icon={faNetworkWired} className="text-purple mr-2" />
<h3 className="text-primary font-medium">{t('tools.http_tester.cors_settings')}</h3>
</div>
<button
onClick={onClose}
className="text-tertiary hover:text-purple transition-colors p-1 rounded-full hover:bg-background/60"
aria-label={t('tools.http_tester.close')}
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
{/* 内容区 */}
<div className="p-5 overflow-auto flex-grow">
<p className="text-secondary mb-4">
{t('tools.http_tester.cors_description')}
</p>
<div className="bg-amber-900/20 border border-amber-500/30 p-3 rounded-md mb-5">
<p className="text-amber-400 text-sm flex items-start">
<FontAwesomeIcon icon={faInfoCircle} className="mr-2 mt-0.5" />
<span>{t('tools.http_tester.cors_warning')}</span>
</p>
</div>
{/* HTTPS到HTTP问题说明 */}
<div className="bg-purple-900/20 border border-purple-500/30 p-3 rounded-md mb-5">
<h4 className="font-medium text-purple-400 mb-1 flex items-center text-sm">
<FontAwesomeIcon icon={faInfoCircle} className="mr-2" />
{t('tools.http_tester.https_to_http_title')}
</h4>
<p className="text-secondary text-sm mb-2">
{t('tools.http_tester.https_to_http_description')}
</p>
<ul className="text-secondary text-sm list-disc pl-5 space-y-1">
<li className="font-medium">{t('tools.http_tester.solution_one')}
<ul className="list-disc ml-5 mt-1 text-xs font-normal">
<li>{t('tools.http_tester.solution_one_1')}</li>
<li>{t('tools.http_tester.solution_one_2')}</li>
<li>{t('tools.http_tester.solution_one_3')}</li>
</ul>
</li>
<li className="font-medium mt-2">{t('tools.http_tester.solution_two')}
<ul className="list-disc ml-5 mt-1 text-xs font-normal">
<li>{t('tools.http_tester.solution_two_1')}</li>
<li>{t('tools.http_tester.solution_two_2')}</li>
<li>{t('tools.http_tester.solution_two_3')}</li>
</ul>
</li>
</ul>
<div className="bg-red-900/20 border border-red-500/30 p-2 rounded mt-3 text-xs text-red-300">
<span className="font-medium">{t('tools.http_tester.security_note')}</span>
</div>
</div>
{/* 选项卡 - 使用sticky定位保持在顶部 */}
<div className="flex mb-0 gap-1 flex-wrap sticky top-0 bg-card pt-1 -mt-1 -mx-1 px-1 pb-1 z-10">
<button className={tabClasses(activeTab === 'nginx')} onClick={() => setActiveTab('nginx')}>
<FontAwesomeIcon icon={faServer} className="mr-1" /> Nginx
</button>
<button className={tabClasses(activeTab === 'php')} onClick={() => setActiveTab('php')}>
<FontAwesomeIcon icon={faCode} className="mr-1" /> PHP
</button>
<button className={tabClasses(activeTab === 'node')} onClick={() => setActiveTab('node')}>
<FontAwesomeIcon icon={faCode} className="mr-1" /> Node.js
</button>
<button className={tabClasses(activeTab === 'python')} onClick={() => setActiveTab('python')}>
<FontAwesomeIcon icon={faCode} className="mr-1" /> Python
</button>
<button className={tabClasses(activeTab === 'java')} onClick={() => setActiveTab('java')}>
<FontAwesomeIcon icon={faCode} className="mr-1" /> Java
</button>
<button className={tabClasses(activeTab === 'go')} onClick={() => setActiveTab('go')}>
<FontAwesomeIcon icon={faCode} className="mr-1" /> Go
</button>
</div>
{/* 代码区 */}
<div className="bg-background border border-purple-glow/30 rounded-md p-4 overflow-auto mt-2" style={{ maxHeight: '350px' }}>
{activeTab === 'nginx' && (
<pre className="text-xs text-secondary font-mono whitespace-pre">
<code>{`# 在 Nginx 的 server 或 location 块中添加:
location /api/ {
# 允许所有来源访问(开发环境使用)
add_header 'Access-Control-Allow-Origin' '*' always;
# 允许的请求方法
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS, PATCH' always;
# 允许的请求头
add_header 'Access-Control-Allow-Headers' 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Connection, User-Agent, Cookie' always;
# 允许浏览器缓存预检请求结果,单位秒
add_header 'Access-Control-Max-Age' '3600' always;
# 处理 OPTIONS 预检请求
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS, PATCH';
add_header 'Access-Control-Allow-Headers' 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Connection, User-Agent, Cookie';
add_header 'Access-Control-Max-Age' '3600';
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' '0';
return 204;
}
# 你的其他配置...
}`}</code>
</pre>
)}
{activeTab === 'php' && (
<pre className="text-xs text-secondary font-mono whitespace-pre">
<code>{`<?php
// 在 PHP 脚本开头添加以下代码:
// 允许所有来源访问(开发环境使用)
header("Access-Control-Allow-Origin: *");
// 如果需要发送 Cookie
// header("Access-Control-Allow-Origin: http://localhost:3000"); // 指定来源
// header("Access-Control-Allow-Credentials: true");
// 允许的请求方法
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS, PATCH");
// 允许的请求头
header("Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Authorization, Connection, User-Agent, Cookie");
// 处理 OPTIONS 预检请求
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
header("HTTP/1.1 204 No Content");
exit;
}
// 你的 PHP 代码...
`}</code>
</pre>
)}
{activeTab === 'node' && (
<pre className="text-xs text-secondary font-mono whitespace-pre">
<code>{`// 方法 1: 使用 Express 框架和 cors 中间件
const express = require('express');
const cors = require('cors');
const app = express();
// 基本配置: 允许所有来源
app.use(cors());
// 高级配置
app.use(cors({
origin: '*', // 或特定域名 'http://localhost:3000'
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: false // 如果需要发送 Cookie,设为 true
}));
// 方法 2: 不使用中间件,手动设置响应头
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
// 处理 OPTIONS 请求
if (req.method === 'OPTIONS') {
return res.status(204).send();
}
next();
});
// 你的路由代码...
`}</code>
</pre>
)}
{activeTab === 'python' && (
<pre className="text-xs text-secondary font-mono whitespace-pre">
<code>{`# 方法 1: 使用 Flask
from flask import Flask
from flask_cors import CORS
app = Flask(__name__)
# 允许所有路由的 CORS
CORS(app)
# 或者,更具体的配置
CORS(app, resources={
r"/api/*": {
"origins": "*", # 或特定域名 ["http://localhost:3000"]
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
"allow_headers": ["Content-Type", "Authorization"]
}
})
# 方法 2: 使用 FastAPI
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 允许所有来源
allow_credentials=False, # 是否支持 cookies
allow_methods=["*"], # 允许所有方法
allow_headers=["*"], # 允许所有头
)
# 方法 3: 使用 Django
# 在 settings.py 中添加:
INSTALLED_APPS = [
# ...其他应用
'corsheaders',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
# ...其他中间件
]
CORS_ALLOW_ALL_ORIGINS = True # 允许所有来源
# 或者指定来源
# CORS_ALLOWED_ORIGINS = [
# "http://localhost:3000",
# ]
`}</code>
</pre>
)}
{activeTab === 'java' && (
<pre className="text-xs text-secondary font-mono whitespace-pre">
<code>{`// 方法 1: 使用 Spring Boot (添加过滤器)
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
// 允许所有来源访问
config.addAllowedOrigin("*");
// 或者允许特定来源
// config.addAllowedOrigin("http://localhost:3000");
// 允许发送 Cookie
// config.setAllowCredentials(true);
// 允许的请求方法
config.addAllowedMethod("GET");
config.addAllowedMethod("POST");
config.addAllowedMethod("PUT");
config.addAllowedMethod("DELETE");
config.addAllowedMethod("OPTIONS");
// 允许的请求头
config.addAllowedHeader("*");
// 预检请求的缓存时间
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
// 方法 2: 使用 Spring Boot (使用 @CrossOrigin 注解)
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@CrossOrigin(origins = "*", allowedHeaders = "*")
public class MyController {
@GetMapping("/api/data")
public String getData() {
return "数据响应";
}
}
// 方法 3: 在 Servlet 中手动设置响应头
@WebServlet("/api/*")
public class ApiServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 设置 CORS 响应头
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
// 正常处理请求...
}
@Override
protected void doOptions(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 处理预检请求
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
response.setHeader("Access-Control-Max-Age", "3600");
response.setStatus(HttpServletResponse.SC_NO_CONTENT);
}
}
`}</code>
</pre>
)}
{activeTab === 'go' && (
<pre className="text-xs text-secondary font-mono whitespace-pre">
<code>{`// 方法 1: 使用 net/http 标准库
package main
import (
"net/http"
)
func setCorsHeaders(w http.ResponseWriter) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
}
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
setCorsHeaders(w)
// 处理预检请求
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// API 处理逻辑...
})
// 应用中间件
http.Handle("/api/", corsMiddleware(apiHandler))
http.ListenAndServe(":8080", nil)
}
// 方法 2: 使用 Gin 框架
package main
import (
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"time"
)
func main() {
r := gin.Default()
// CORS 中间件配置
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: false,
MaxAge: 12 * time.Hour,
}))
// 路由处理
r.GET("/api/data", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "数据响应",
})
})
r.Run(":8080")
}
`}</code>
</pre>
)}
</div>
</div>
{/* 底部按钮 */}
<div className="bg-background p-4 border-t border-purple-glow/30 flex justify-end shrink-0">
<button
className="btn-primary"
onClick={onClose}
>
</button>
</div>
</div>
</div>
</>
);
};
interface RequestFormProps {
url: string;
method: HttpMethod;
loading: boolean;
networkType: NetworkType;
onUrlChange: (url: string) => void;
onMethodChange: (method: HttpMethod) => void;
onNetworkTypeChange: (type: NetworkType) => void;
onSendRequest: () => void;
}
const RequestForm: React.FC<RequestFormProps> = ({
url,
method,
loading,
networkType,
onUrlChange,
onMethodChange,
onNetworkTypeChange,
onSendRequest,
}) => {
const { t } = useLanguage();
// HTTP方法列表
const methods: HttpMethod[] = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
// 显示或隐藏CORS设置弹窗
const [showCorsModal, setShowCorsModal] = React.useState(false);
// 检测是否为HTTP URL
const isHttpUrl = React.useMemo(() => {
try {
return url.trim().toLowerCase().startsWith('http://');
} catch {
return false;
}
}, [url]);
// 检测当前页面是否为HTTPS
const [isCurrentPageHttps, setIsCurrentPageHttps] = React.useState(false);
React.useEffect(() => {
// 仅在客户端执行
if (typeof window !== 'undefined') {
setIsCurrentPageHttps(window.location.protocol === 'https:');
}
}, []);
// 显示混合内容警告的条件
const showMixedContentWarning = networkType === 'local' && isHttpUrl && isCurrentPageHttps;
return (
<div className="mb-6">
<div className="flex gap-2 mb-2">
{methods.map(m => (
<button
key={m}
className={styles.methodButton(method === m)}
onClick={() => onMethodChange(m)}
>
{m}
</button>
))}
</div>
<div className="flex gap-2 mb-2">
<input
type="text"
value={url}
onChange={(e) => onUrlChange(e.target.value)}
placeholder={t('tools.http_tester.enter_url')}
className={`${styles.input} ${showMixedContentWarning ? 'border-amber-500 focus:border-amber-500' : ''}`}
/>
<button
className="btn-primary whitespace-nowrap"
onClick={onSendRequest}
disabled={loading}
>
{loading ? (
<span className="flex items-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{t('tools.http_tester.loading')}
</span>
) : (
<span className="flex items-center">
<FontAwesomeIcon icon={faPaperPlane} className="mr-2" />
{t('tools.http_tester.send_request')}
</span>
)}
</button>
</div>
{/* 混合内容特别警告 - 当检测到HTTPS页面请求HTTP URL时 */}
{showMixedContentWarning && (
<div className="mt-2 text-xs text-red-400 flex items-center mb-2">
<FontAwesomeIcon icon={faInfoCircle} className="mr-1" />
<span>{t('tools.http_tester.https_to_http_title')}</span>
<button
className="ml-2 underline text-purple text-xs hover:text-purple-light transition-colors"
onClick={() => setShowCorsModal(true)}
>
{t('tools.http_tester.cors_settings')}
</button>
</div>
)}
{/* 本地/局域网选项 */}
<div className="flex items-center gap-2 text-sm text-tertiary">
<div className="flex items-center">
<input
type="checkbox"
id="localNetwork"
checked={networkType === 'local'}
onChange={() => onNetworkTypeChange(networkType === 'local' ? 'public' : 'local')}
className="mr-1 accent-purple cursor-pointer"
/>
<label htmlFor="localNetwork" className="cursor-pointer">
{t('tools.http_tester.local_network')}
</label>
</div>
{networkType === 'local' && (
<div className="flex items-center gap-1">
<span className="text-amber-400 text-xs">
<FontAwesomeIcon icon={faInfoCircle} className="mr-1" />
{t('tools.http_tester.cors_settings')}
</span>
<button
className="text-purple text-xs border border-purple-glow/30 px-2 py-0.5 rounded hover:bg-background transition-colors"
onClick={() => setShowCorsModal(true)}
>
{t('tools.http_tester.cors_settings')}
</button>
</div>
)}
</div>
{/* 替换原来的大型警告框为一个小链接 */}
{networkType === 'local' && !showMixedContentWarning && (
<div className="mt-2 text-xs flex items-center">
<button
className="text-purple hover:text-purple-light transition-colors underline flex items-center"
onClick={() => setShowCorsModal(true)}
>
<FontAwesomeIcon icon={faInfoCircle} className="mr-1" />
{t('tools.http_tester.https_to_http_title')}
</button>
</div>
)}
{/* CORS设置弹窗 */}
<CorsModal
isOpen={showCorsModal}
onClose={() => setShowCorsModal(false)}
/>
</div>
);
};
export default RequestForm;
@@ -0,0 +1,63 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons';
import styles from '../styles';
import { RequestHeader } from '../types';
import { useLanguage } from '@/context/LanguageContext';
interface RequestHeadersProps {
headers: RequestHeader[];
onAddHeader: () => void;
onUpdateHeader: (id: string, key: string, value: string) => void;
onRemoveHeader: (id: string) => void;
}
const RequestHeaders: React.FC<RequestHeadersProps> = ({
headers,
onAddHeader,
onUpdateHeader,
onRemoveHeader,
}) => {
const { t } = useLanguage();
return (
<div>
<div className="mb-4">
<button
className="btn-secondary text-xs px-3 py-1"
onClick={onAddHeader}
>
<FontAwesomeIcon icon={faPlus} className="mr-1" />
{t('tools.http_tester.add_header')}
</button>
</div>
{headers.map(header => (
<div key={header.id} className={styles.headerRow}>
<input
type="text"
placeholder={t('tools.http_tester.header_key')}
value={header.key}
onChange={(e) => onUpdateHeader(header.id, e.target.value, header.value)}
className="flex-1 bg-block text-primary px-3 py-1 text-sm rounded-md border border-purple-glow/20 focus:border-purple-glow"
/>
<input
type="text"
placeholder={t('tools.http_tester.header_value')}
value={header.value}
onChange={(e) => onUpdateHeader(header.id, header.key, e.target.value)}
className="flex-1 bg-block text-primary px-3 py-1 text-sm rounded-md border border-purple-glow/20 focus:border-purple-glow"
/>
<button
onClick={() => onRemoveHeader(header.id)}
className={styles.iconButton}
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
))}
</div>
);
};
export default RequestHeaders;
@@ -0,0 +1,88 @@
import React from 'react';
import styles from '../styles';
import { HistoryItem, HttpMethod } from '../types';
import { useLanguage } from '@/context/LanguageContext';
interface RequestHistoryProps {
history: HistoryItem[];
showHistory: boolean;
onToggleHistory: () => void;
onClearHistory: () => void;
onLoadFromHistory: (item: {url: string, method: HttpMethod}) => void;
}
const RequestHistory: React.FC<RequestHistoryProps> = ({
history,
showHistory,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onToggleHistory,
onClearHistory,
onLoadFromHistory,
}) => {
const { t } = useLanguage();
// 格式化日期
const formatDate = (date: Date) => {
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
};
return (
<div className={styles.card}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg text-primary font-medium">{t('tools.http_tester.history')}</h2>
<div className="flex gap-2">
{/* <button
className="text-sm text-tertiary hover:text-secondary"
onClick={onToggleHistory}
>
{showHistory ? '隐藏' : '显示'}
</button> */}
{history.length > 0 && (
<button
className="text-sm text-tertiary hover:text-error"
onClick={onClearHistory}
>
{t('tools.http_tester.clear_history')}
</button>
)}
</div>
</div>
{history.length > 0 ? (
<div className="space-y-1">
{history.map((item, index) => (
<div
key={index}
className={styles.historyItem}
onClick={() => onLoadFromHistory(item)}
>
<div className="flex items-center gap-2">
<span className={styles.historyMethod(item.method)}>
{item.method}
</span>
<span className="text-sm text-primary truncate max-w-[200px]">
{item.url}
</span>
</div>
<span className="text-xs text-tertiary">
{formatDate(item.timestamp)}
</span>
</div>
))}
</div>
) : (
<div className="text-center text-tertiary py-4">
{t('tools.http_tester.history_empty')}
</div>
)}
{!showHistory && history.length > 0 && (
<div className="text-sm text-tertiary mt-4">
<p>{t('tools.http_tester.history')}</p>
</div>
)}
</div>
);
};
export default RequestHistory;
@@ -0,0 +1,321 @@
import React, { useState, useEffect, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGlobe, faCopy, faCheck, faFileAlt, faInfoCircle } from '@fortawesome/free-solid-svg-icons';
import styles from '../styles';
import { HttpResponse, HttpMethod, NetworkType } from '../types';
import JsonRenderer from './JsonRenderer';
import { generateMarkdownDoc } from '../utils';
import MarkdownPreview from './MarkdownPreview';
import { useLanguage } from '@/context/LanguageContext';
interface ResponseDisplayProps {
response: HttpResponse | null;
url: string;
method: HttpMethod;
headers: {key: string; value: string; id: string}[];
body: string;
bodyFormat: 'json' | 'text' | 'form';
formFields: {key: string; value: string; id: string}[];
networkType: NetworkType;
}
const ResponseDisplay: React.FC<ResponseDisplayProps> = ({
response,
url,
method,
headers,
body,
bodyFormat,
formFields,
networkType
}) => {
const { t } = useLanguage();
const [responseTab, setResponseTab] = useState<'body' | 'headers' | 'info'>('body');
const [copiedJson, setCopiedJson] = useState(false);
const [markdownContent, setMarkdownContent] = useState<string>('');
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
// 美化JSON
const formatJson = (json: string): string => {
try {
return JSON.stringify(JSON.parse(json), null, 2);
} catch {
return json;
}
};
// 检测内容是否为JSON
const isJsonContent = (data: unknown): boolean => {
// 检查响应头中的Content-Type
if (response?.headers && response.headers['content-type']?.includes('application/json')) {
return true;
}
// 对于对象类型的数据,直接判定为JSON
if (typeof data === 'object' && data !== null) {
return true;
}
// 尝试解析字符串
if (typeof data === 'string') {
try {
JSON.parse(data);
return true;
} catch {
return false;
}
}
return false;
};
// 复制响应内容
const copyResponse = () => {
if (!response) return;
const textToCopy = responseTab === 'body'
? typeof response.data === 'string'
? response.data
: JSON.stringify(response.data, null, 2)
: Object.entries(response.headers)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
navigator.clipboard.writeText(textToCopy)
.then(() => {
setCopiedJson(true);
setTimeout(() => setCopiedJson(false), 2000);
})
.catch(err => console.error(t('tools.http_tester.copy_failed'), err));
};
// 生成并显示Markdown接口文档
const handleGenerateMarkdown = () => {
if (!response) return;
const markdownDoc = generateMarkdownDoc(
url,
method,
headers,
body,
bodyFormat,
formFields,
response,
networkType
);
setMarkdownContent(markdownDoc);
setShowMarkdownPreview(true);
};
// 关闭Markdown预览窗口
const handleCloseMarkdownPreview = () => {
setShowMarkdownPreview(false);
};
// 调整响应区域高度以适应可用空间并跟随内容变化
useEffect(() => {
if (!response) return;
const adjustHeight = () => {
if (!containerRef.current || !contentRef.current) return;
// 计算可用的视窗高度
const viewportHeight = window.innerHeight;
// 获取容器到视窗顶部的距离
const containerTop = containerRef.current.getBoundingClientRect().top;
// 设置底部边距
const bottomMargin = 40;
// 计算容器可用的最大高度(视口高度限制)
const maxViewportHeight = viewportHeight - containerTop - bottomMargin;
// 获取内容实际高度
const contentHeight = contentRef.current.scrollHeight;
// 设置容器初始高度为视口可用高度
let targetHeight = Math.max(600, maxViewportHeight);
// 如果内容高度超过初始高度,则让容器跟随内容增高
// 最小高度600px,最大不超过内容高度+100px(为头部和边距预留空间)
if (contentHeight > targetHeight - 100) {
targetHeight = Math.min(contentHeight + 100, 2000); // 设置一个最大值2000px,防止过长
}
// 应用高度
containerRef.current.style.minHeight = `${targetHeight}px`;
};
// 初始调整
adjustHeight();
// 设置一个延时调整,确保内容渲染完成后再次计算高度
const timeoutId = setTimeout(adjustHeight, 100);
// 监听窗口大小变化
window.addEventListener('resize', adjustHeight);
return () => {
window.removeEventListener('resize', adjustHeight);
clearTimeout(timeoutId);
};
}, [response, responseTab]);
return (
<div className={`${styles.card}`} ref={containerRef}>
<h2 className="text-lg text-primary font-medium mb-4">{t('tools.http_tester.response_result')}</h2>
{response ? (
<div className="flex flex-col flex-grow w-full">
{/* 响应头部 */}
<div className={styles.responseHeader}>
<div className="flex items-center gap-2">
<span className={styles.statusBadge(response.status)}>
{response.status}
</span>
<span className="text-sm text-secondary">
{response.statusText}
</span>
</div>
<div className="flex items-center gap-3">
<div className={styles.statsText}>
{response.size} bytes | {response.time}ms
</div>
<button
className={styles.copyButton}
onClick={copyResponse}
title={t('tools.http_tester.copy')}
>
<FontAwesomeIcon icon={copiedJson ? faCheck : faCopy} />
{copiedJson ? t('tools.http_tester.copied') : t('tools.http_tester.copy')}
</button>
<button
className={styles.copyButton}
onClick={handleGenerateMarkdown}
title={t('tools.http_tester.generate_doc')}
>
<FontAwesomeIcon icon={faFileAlt} className="mr-1" />
{t('tools.http_tester.generate_doc')}
</button>
</div>
</div>
{/* 响应标签页 */}
<div className="border-b border-purple-glow/30 mb-4">
<div className="flex">
<button
className={styles.tabButton(responseTab === 'body')}
onClick={() => setResponseTab('body')}
>
{t('tools.http_tester.response_body')}
</button>
<button
className={styles.tabButton(responseTab === 'headers')}
onClick={() => setResponseTab('headers')}
>
{t('tools.http_tester.response_headers')}
</button>
<button
className={styles.tabButton(responseTab === 'info')}
onClick={() => setResponseTab('info')}
>
{t('tools.http_tester.request_info')}
</button>
</div>
</div>
{/* 响应内容 */}
<div className={`${styles.responseBox} flex-grow`} ref={contentRef}>
{responseTab === 'body' && (
<>
{isJsonContent(response.data)
? <JsonRenderer data={response.data} />
: (typeof response.data === 'string'
? response.data
: formatJson(JSON.stringify(response.data)))}
</>
)}
{responseTab === 'headers' && (
Object.entries(response.headers).map(([key, value]) => (
<div key={key}>
<span className="text-purple">{key}</span>: {value}
</div>
))
)}
{responseTab === 'info' && (
<div className="space-y-2">
<div className="text-sm">
<span className="text-purple font-medium">{t('tools.http_tester.network_mode')}:</span> {networkType === 'local' ? t('tools.http_tester.network_mode_local') : t('tools.http_tester.network_mode_public')}
{networkType === 'local' && (
<span className="ml-2 text-amber-400 text-xs">
<FontAwesomeIcon icon={faInfoCircle} className="mr-1" />
{t('tools.http_tester.cors_description')}
</span>
)}
</div>
<div className="text-sm">
<span className="text-purple font-medium">{t('tools.http_tester.request_url')}:</span> {url}
</div>
<div className="text-sm">
<span className="text-purple font-medium">{t('tools.http_tester.request_method')}:</span> {method}
</div>
<div className="text-sm">
<span className="text-purple font-medium">{t('tools.http_tester.response_result')}:</span> {response.time}ms
</div>
<div className="text-sm">
<span className="text-purple font-medium">{t('tools.http_tester.response_result')}:</span> {response.size} bytes
</div>
{Object.entries(headers).length > 0 && (
<div>
<div className="text-purple font-medium text-sm mt-4 mb-2">{t('tools.http_tester.request_headers')}</div>
<div className="bg-background/40 p-3 rounded text-xs">
{headers.filter(h => h.key && h.value).map((header, index) => (
<div key={header.id || index}>
{header.key}: {header.value}
</div>
))}
</div>
</div>
)}
{['POST', 'PUT', 'PATCH'].includes(method) && (
<div>
<div className="text-purple font-medium text-sm mt-4 mb-2">{t('tools.http_tester.request_body')}</div>
<div className="bg-background/40 p-3 rounded text-xs break-all font-mono">
{bodyFormat === 'json' ? formatJson(body) :
bodyFormat === 'form' ?
formFields.filter(f => f.key && f.value)
.map((field, _index) => `${field.key}=${field.value}`)
.join('&') :
body}
</div>
</div>
)}
</div>
)}
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-20 text-tertiary">
<FontAwesomeIcon icon={faGlobe} className="text-4xl mb-4 text-purple-glow" />
<p>{t('tools.http_tester.enter_url')}</p>
</div>
)}
{/* Markdown预览模态窗口 */}
<MarkdownPreview
markdown={markdownContent}
isOpen={showMarkdownPreview}
onClose={handleCloseMarkdownPreview}
/>
</div>
);
};
export default ResponseDisplay;
+384
View File
@@ -0,0 +1,384 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGlobe, faTrash } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import tools from '@/config/tools';
import styles from './styles';
import { useLanguage } from '@/context/LanguageContext';
// 导入类型
import { HttpMethod, RequestHeader, HttpResponse, HistoryItem, NetworkType } from './types';
// 导入组件
import RequestForm from './components/RequestForm';
import RequestHeaders from './components/RequestHeaders';
import RequestBody from './components/RequestBody';
import ResponseDisplay from './components/ResponseDisplay';
import RequestHistory from './components/RequestHistory';
import ErrorDisplay from './components/ErrorDisplay';
// 导入工具函数
import { sendHttpRequest } from './utils';
// 本地存储的key
const HISTORY_STORAGE_KEY = 'http_tester_history';
export default function HttpTester() {
const { t } = useLanguage();
// 从工具配置中获取当前工具信息
const toolConfig = tools.find(tool => tool.code === 'http_tester');
// 请求配置
const [url, setUrl] = useState('https://jsonplaceholder.typicode.com/posts/1');
const [method, setMethod] = useState<HttpMethod>('GET');
const [headers, setHeaders] = useState<RequestHeader[]>([
{ key: 'Content-Type', value: 'application/json', id: Date.now().toString() }
]);
const [body, setBody] = useState('');
const [bodyFormat, setBodyFormat] = useState<'json' | 'text' | 'form'>('json');
// 添加表单字段状态
const [formFields, setFormFields] = useState<RequestHeader[]>([
{ key: '', value: '', id: Date.now().toString() }
]);
// 网络类型(新增)
const [networkType, setNetworkType] = useState<NetworkType>('public');
// 响应状态
const [response, setResponse] = useState<HttpResponse | null>(null);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState<'body' | 'headers'>('body');
const [error, setError] = useState<string | null>(null);
const [showHistory, setShowHistory] = useState(true); // 默认显示历史记录
// 历史记录
const [history, setHistory] = useState<HistoryItem[]>([]);
// 上一次bodyFormat的引用
const prevBodyFormatRef = useRef(bodyFormat);
// 从本地存储加载历史记录 - 放在顶部优先执行
useEffect(() => {
// 确保在客户端运行
if (typeof window === 'undefined') return;
try {
// 直接从localStorage获取数据
const savedHistory = localStorage.getItem(HISTORY_STORAGE_KEY);
if (savedHistory) {
const parsedHistory = JSON.parse(savedHistory);
// 转换时间戳为Date对象
const processedHistory = parsedHistory.map((item: {url: string, method: HttpMethod, timestamp: string}) => ({
...item,
timestamp: new Date(item.timestamp)
}));
// 设置历史记录
setHistory(processedHistory);
}
} catch (e) {
console.error(t('tools.http_tester.copy_failed'), e);
}
}, [t]);
// 监听bodyFormat变化,自动更新Content-Type
useEffect(() => {
// 如果bodyFormat没有变化,直接返回
if (prevBodyFormatRef.current === bodyFormat) return;
// 更新上一次的bodyFormat
prevBodyFormatRef.current = bodyFormat;
// 获取对应的Content-Type值
let newContentType = '';
if (bodyFormat === 'json') {
newContentType = 'application/json';
} else if (bodyFormat === 'form') {
newContentType = 'application/x-www-form-urlencoded';
} else if (bodyFormat === 'text') {
newContentType = 'text/plain';
}
// 使用函数式更新,避免依赖headers
setHeaders(prevHeaders => {
// 查找现有的Content-Type请求头
const contentTypeIndex = prevHeaders.findIndex(
h => h.key.toLowerCase() === 'content-type'
);
// 如果找到了Content-Type并且需要更新
if (contentTypeIndex !== -1) {
const updatedHeaders = [...prevHeaders];
updatedHeaders[contentTypeIndex] = {
...updatedHeaders[contentTypeIndex],
value: newContentType
};
return updatedHeaders;
} else if (newContentType) {
// 如果没有找到Content-Type但需要添加
return [
...prevHeaders,
{ key: 'Content-Type', value: newContentType, id: Date.now().toString() }
];
}
// 没有变化时返回原来的headers
return prevHeaders;
});
}, [bodyFormat]); // 只依赖bodyFormat
// 在组件挂载时检查是否是从首页导航过来
useEffect(() => {
// 确保在客户端运行
if (typeof window !== 'undefined') {
// 检查是否是从首页导航过来的标记
const fromHomepage = sessionStorage.getItem('from_homepage');
if (fromHomepage) {
// 确保历史记录显示
setShowHistory(true);
// 清除标记
sessionStorage.removeItem('from_homepage');
}
}
}, []);
// 当历史记录更新时保存到本地存储
useEffect(() => {
if (typeof window === 'undefined' || history.length === 0) return;
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(history));
}, [history]);
// 添加请求头
const addHeader = () => {
setHeaders(prevHeaders => [...prevHeaders, { key: '', value: '', id: Date.now().toString() }]);
};
// 更新请求头
const updateHeader = (id: string, key: string, value: string) => {
setHeaders(prevHeaders =>
prevHeaders.map(header =>
header.id === id ? { ...header, key, value } : header
)
);
};
// 删除请求头
const removeHeader = (id: string) => {
setHeaders(prevHeaders => prevHeaders.filter(header => header.id !== id));
};
// 添加表单字段
const addFormField = () => {
setFormFields(prevFields => [...prevFields, { key: '', value: '', id: Date.now().toString() }]);
};
// 更新表单字段
const updateFormField = (id: string, key: string, value: string) => {
setFormFields(prevFields =>
prevFields.map(field =>
field.id === id ? { ...field, key, value } : field
)
);
};
// 删除表单字段
const removeFormField = (id: string) => {
setFormFields(prevFields => prevFields.filter(field => field.id !== id));
};
// 清空所有内容
const clearAll = () => {
// 一次性重置所有状态
setUrl('https://jsonplaceholder.typicode.com/posts/1');
setMethod('GET');
setHeaders([{ key: 'Content-Type', value: 'application/json', id: Date.now().toString() }]);
setBody('');
setFormFields([{ key: '', value: '', id: Date.now().toString() }]);
setResponse(null);
setError(null);
// 确保bodyFormat匹配Content-Type
setBodyFormat('json');
};
// 发送请求
const handleSendRequest = async () => {
setLoading(true);
setResponse(null);
setError(null);
const result = await sendHttpRequest(
url,
method,
headers,
body,
bodyFormat,
formFields,
networkType
);
if (result.error) {
setError(result.error);
} else if (result.response) {
setResponse(result.response);
// 更新历史记录
setHistory(prev => [
{ url, method, timestamp: new Date() },
...prev.slice(0, 9) // 保留最近10条
]);
}
setLoading(false);
};
// 从历史记录加载请求
const loadFromHistory = (historyItem: {url: string, method: HttpMethod}) => {
setUrl(historyItem.url);
setMethod(historyItem.method);
// 不重置其他状态,保留当前的请求头和请求体
};
// 清空历史记录
const clearHistory = () => {
if (confirm(t('tools.http_tester.clear_history_confirm'))) {
setHistory([]);
localStorage.removeItem(HISTORY_STORAGE_KEY);
}
};
// 切换历史记录显示状态
const toggleHistory = () => {
setShowHistory(!showHistory);
};
return (
<div className={styles.container}>
{/* 工具头部 */}
<ToolHeader
icon={toolConfig?.icon || faGlobe}
toolCode="http_tester"
title=""
description=""
/>
{/* 轻量提示信息 */}
<div className="mb-4 text-xs text-tertiary italic text-right">
<a
href="https://www.showdoc.com.cn/runapi"
target="_blank"
rel="noopener noreferrer"
className="text-purple hover:text-purple-hover transition-colors"
>
{t('tools.http_tester.need_advanced')}
</a>
</div>
{/* 错误提示 */}
<ErrorDisplay error={error} />
{/* 主要内容区 */}
<div className={styles.grid}>
{/* 左侧 - 请求配置 */}
<div className="space-y-6 flex flex-col">
{/* 请求表单 */}
<div className={styles.card}>
<div className={styles.formHeader}>
<h2 className="text-lg text-primary font-medium">{t('tools.http_tester.http_request')}</h2>
<button
className="btn-secondary text-xs px-3 py-1"
onClick={clearAll}
>
<FontAwesomeIcon icon={faTrash} className="mr-1" />
{t('tools.http_tester.clear')}
</button>
</div>
{/* 请求URL和方法 */}
<RequestForm
url={url}
method={method}
loading={loading}
networkType={networkType}
onUrlChange={setUrl}
onMethodChange={setMethod}
onNetworkTypeChange={setNetworkType}
onSendRequest={handleSendRequest}
/>
{/* 请求参数标签页 */}
<div className="border-b border-purple-glow/30 mb-4">
<div className="flex">
<button
className={styles.tabButton(activeTab === 'headers')}
onClick={() => setActiveTab('headers')}
>
{t('tools.http_tester.request_headers')}
</button>
<button
className={styles.tabButton(activeTab === 'body')}
onClick={() => setActiveTab('body')}
>
{t('tools.http_tester.request_body')}
</button>
</div>
</div>
{/* 请求头 */}
{activeTab === 'headers' && (
<RequestHeaders
headers={headers}
onAddHeader={addHeader}
onUpdateHeader={updateHeader}
onRemoveHeader={removeHeader}
/>
)}
{/* 请求体 */}
{activeTab === 'body' && (
<RequestBody
bodyFormat={bodyFormat}
body={body}
formFields={formFields}
onBodyChange={setBody}
onBodyFormatChange={setBodyFormat}
onAddFormField={addFormField}
onUpdateFormField={updateFormField}
onRemoveFormField={removeFormField}
/>
)}
</div>
{/* 历史记录 */}
<RequestHistory
history={history}
showHistory={showHistory}
onToggleHistory={toggleHistory}
onClearHistory={clearHistory}
onLoadFromHistory={loadFromHistory}
/>
</div>
{/* 右侧 - 响应结果 */}
<div className="flex w-full">
<ResponseDisplay
response={response}
url={url}
method={method}
headers={headers}
body={body}
bodyFormat={bodyFormat}
formFields={formFields}
networkType={networkType}
/>
</div>
</div>
{/* 回到顶部按钮 */}
<BackToTop position="bottom-right" offset={30} size="medium" />
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
// 定义样式对象
const styles = {
card: "card p-6 h-full flex flex-col transition-all duration-300 w-full",
input: "search-input w-full",
textArea: "w-full h-48 p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple resize-y",
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6 pb-16",
grid: "grid grid-cols-1 lg:grid-cols-2 gap-6 flex-grow",
tabButton: (active: boolean) => `px-4 py-2 text-sm font-medium ${active ? 'text-purple border-b-2 border-purple' : 'text-tertiary hover:text-secondary'}`,
methodButton: (active: boolean) => `px-3 py-1 text-xs rounded-md ${active ? 'bg-purple-glow/20 text-purple' : 'bg-block-strong text-secondary'}`,
responseHeader: "flex items-center justify-between bg-block-strong p-3 rounded-t-lg",
statusBadge: (status: number) => {
if (status >= 200 && status < 300) return "px-2 py-1 bg-green-900/20 text-success text-xs rounded-md";
if (status >= 300 && status < 400) return "px-2 py-1 bg-blue-900/20 text-blue-500 text-xs rounded-md";
if (status >= 400 && status < 500) return "px-2 py-1 bg-yellow-900/20 text-warning text-xs rounded-md";
return "px-2 py-1 bg-red-900/20 text-error text-xs rounded-md";
},
responseBox: "bg-block p-3 border border-purple-glow rounded-lg font-mono text-sm text-primary overflow-auto min-h-[400px] flex-grow whitespace-pre-wrap w-full",
historyItem: "flex items-center justify-between p-2 hover:bg-block-hover rounded-md cursor-pointer",
historyMethod: (method: string) => {
const colors: Record<string, string> = {
GET: "bg-green-900/20 text-success",
POST: "bg-blue-900/20 text-blue-500",
PUT: "bg-yellow-900/20 text-warning",
DELETE: "bg-red-900/20 text-error",
PATCH: "bg-purple-900/20 text-purple",
default: "bg-block-strong text-secondary"
};
return `px-2 py-1 text-xs rounded-md ${colors[method] || colors.default}`;
},
formHeader: "mb-4 flex items-center justify-between",
headerRow: "flex items-center gap-2 mb-2",
errorBox: "p-3 bg-red-900/20 border border-red-700/30 text-error rounded-lg mb-4",
iconButton: "p-1 text-secondary hover:text-primary",
copyButton: "flex items-center gap-1 text-xs px-2 py-1 rounded bg-block-strong hover:bg-block-hover text-secondary transition-colors",
statsText: "text-xs text-tertiary",
};
export default styles;
+36
View File
@@ -0,0 +1,36 @@
// 定义HTTP方法类型
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
// 定义请求头类型
export type RequestHeader = {
key: string;
value: string;
id: string;
};
// 定义历史记录项类型
export type HistoryItem = {
url: string;
method: HttpMethod;
timestamp: Date;
};
// 定义响应数据类型
export type ResponseData = string | Record<string, unknown>;
// 定义响应类型
export type HttpResponse = {
status: number;
statusText: string;
headers: Record<string, string>;
data: ResponseData;
time: number;
size: number;
error?: string;
};
// 定义表单字段类型 (与请求头结构相同)
export type FormField = RequestHeader;
// 定义网络类型(本地/公网)
export type NetworkType = 'public' | 'local';
+348
View File
@@ -0,0 +1,348 @@
import { HttpMethod, HttpResponse, RequestHeader, NetworkType, ResponseData } from './types';
import { apiClient } from '@/lib/api-client';
// 代理响应接口
interface ProxyResponse {
status: number;
statusText: string;
headers: Record<string, string>;
data: ResponseData;
time?: number;
size?: number;
}
/**
* 发送HTTP请求
* @param url 请求URL
* @param method 请求方法
* @param headers 请求头
* @param body 请求体
* @param bodyFormat 请求体格式
* @param formFields 表单字段
* @param networkType 网络类型(公网/本地)
*/
export const sendHttpRequest = async (
url: string,
method: HttpMethod,
headers: RequestHeader[],
body: string,
bodyFormat: 'json' | 'text' | 'form',
formFields: RequestHeader[],
networkType: NetworkType = 'public'
): Promise<{ response: HttpResponse | null; error: string | null }> => {
try {
if (!url.trim()) {
return { response: null, error: 'URL不能为空' };
}
// 准备请求头
const headerObj: Record<string, string> = {};
headers.forEach(h => {
if (h.key.trim() && h.value.trim()) {
headerObj[h.key] = h.value;
}
});
// 准备请求体
let requestBody: string | FormData | undefined;
if (['POST', 'PUT', 'PATCH'].includes(method)) {
if (bodyFormat === 'json') {
try {
// 尝试验证JSON格式
if (body.trim()) {
JSON.parse(body);
}
requestBody = body;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
// 忽略具体错误,只返回格式无效的消息
return { response: null, error: '请求体不是有效的JSON格式' };
}
} else if (bodyFormat === 'form') {
try {
// 使用表单字段构建请求体
const formData = new URLSearchParams();
formFields.forEach(field => {
if (field.key.trim() && field.value.trim()) {
formData.append(field.key, field.value);
}
});
if ([...formData.keys()].length === 0) {
return { response: null, error: '表单至少需要一个有效的字段' };
}
requestBody = formData.toString();
// 设置适当的Content-Type
headerObj['Content-Type'] = 'application/x-www-form-urlencoded';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
// 忽略具体错误,只返回处理失败的消息
return { response: null, error: '表单数据处理失败,请检查输入的字段值' };
}
} else {
requestBody = body;
}
}
const startTime = performance.now();
// 根据网络类型决定发送请求的方式
let responseData: ProxyResponse;
let clientTime: number;
// 如果是本地/局域网请求,直接发送请求(不通过代理)
if (networkType === 'local') {
try {
// 直接使用fetch API发送请求到目标地址
const response = await fetch(url, {
method,
headers: headerObj,
body: ['POST', 'PUT', 'PATCH'].includes(method) ? requestBody : undefined,
});
const endTime = performance.now();
clientTime = Math.round(endTime - startTime);
// 获取响应头
const headers: Record<string, string> = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
// 尝试解析响应体
let data: ResponseData;
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
data = await response.json();
} else {
data = await response.text();
}
// 计算响应大小
const bodyText = typeof data === 'string' ? data : JSON.stringify(data);
const size = new Blob([bodyText]).size;
responseData = {
status: response.status,
statusText: response.statusText,
headers,
data,
time: clientTime,
size,
};
} catch (error) {
return {
response: null,
error: `本地请求失败: ${(error as Error).message}。请确保目标服务器已配置CORS,允许跨域请求。`
};
}
} else {
// 通过后端代理发送请求,避免跨域问题
const proxyResponse = await apiClient.post<ProxyResponse>('/api/proxy', {
url,
method,
headers: headerObj,
body: requestBody,
});
const endTime = performance.now();
clientTime = Math.round(endTime - startTime);
responseData = proxyResponse;
}
return {
response: {
status: responseData.status,
statusText: responseData.statusText,
headers: responseData.headers,
data: responseData.data,
// 使用服务器计算的响应时间或客户端时间
time: responseData.time || clientTime,
size: responseData.size || 0,
},
error: null
};
} catch (error) {
console.error('请求错误', error);
return {
response: null,
error: (error as Error).message || '请求失败'
};
}
};
/**
* 生成Markdown格式的接口文档
* @param url 请求URL
* @param method 请求方法
* @param headers 请求头
* @param requestBody 请求体
* @param bodyFormat 请求体格式
* @param formFields 表单字段
* @param response 响应数据
* @param networkType 网络类型(公网/本地)
* @returns Markdown格式的接口文档
*/
export const generateMarkdownDoc = (
url: string,
method: HttpMethod,
headers: RequestHeader[],
requestBody: string,
bodyFormat: 'json' | 'text' | 'form',
formFields: RequestHeader[],
response: HttpResponse | null,
networkType: NetworkType = 'public'
): string => {
// 准备请求体展示
let requestBodyContent = '';
if (['POST', 'PUT', 'PATCH'].includes(method)) {
if (bodyFormat === 'json' && requestBody.trim()) {
try {
// 美化JSON
const parsedJson = JSON.parse(requestBody);
requestBodyContent = JSON.stringify(parsedJson, null, 2);
} catch {
requestBodyContent = requestBody;
}
} else if (bodyFormat === 'form') {
// 构建表单内容,但不需要在这里生成请求体内容
// 因为我们将在下面的请求参数部分直接使用表格展示
const formData = new URLSearchParams();
formFields.forEach(field => {
if (field.key.trim() && field.value.trim()) {
formData.append(field.key, field.value);
}
});
// 在表单模式下,请求体内容为空,因为我们会直接使用表格展示
requestBodyContent = '';
} else {
requestBodyContent = requestBody;
}
}
// 过滤有效的请求头
const validHeaders = headers.filter(h => h.key.trim() && h.value.trim());
// 构建Markdown文档 - 使用ShowDoc风格
let markdown = ``;
// 简要描述部分
markdown += `**简要描述:** \n\n`;
markdown += `- 自动生成的API接口文档\n\n`;
// 请求模式部分(新增)
markdown += `**请求模式:** \n\n`;
markdown += `- ${networkType === 'local' ? '本地/局域网' : '公网代理'} \n\n`;
// 请求URL部分
markdown += `**请求URL** \n\n`;
markdown += `- \`${url}\`\n\n`;
// 请求方式部分
markdown += `**请求方式:**\n\n`;
markdown += `- ${method} \n\n`;
// 请求头部分(如果有)
if (validHeaders.length > 0) {
markdown += `**请求头:** \n\n`;
markdown += `| 参数名 | 必选 | 参数值 | 说明 |\n`;
markdown += `| ------ | ---- | ------ | ---- |\n`;
validHeaders.forEach(header => {
const isContent = header.key.toLowerCase() === 'content-type';
markdown += `| ${header.key} | ${isContent ? '是' : '否'} | ${header.value} | ${isContent ? '请求数据类型' : '-'} |\n`;
});
markdown += `\n`;
}
// 请求参数部分(针对POST、PUT等方法)
if (['POST', 'PUT', 'PATCH'].includes(method) && (requestBodyContent || bodyFormat === 'form')) {
markdown += `**请求参数:** \n\n`;
// 针对表单格式单独处理
if (bodyFormat === 'form') {
// 直接生成表单参数表格
markdown += `| 参数名 | 必选 | 类型 | 说明 |\n`;
markdown += `| ------ | ---- | ---- | ---- |\n`;
const validFormFields = formFields.filter(field => field.key.trim() && field.value.trim());
if (validFormFields.length > 0) {
validFormFields.forEach(field => {
markdown += `| ${field.key} | 是 | string | - |\n`;
});
markdown += `\n`;
// 添加URL编码格式的说明
const formData = new URLSearchParams();
validFormFields.forEach(field => {
formData.append(field.key, field.value);
});
markdown += `**表单URL编码格式:** \n\n`;
markdown += `\`${formData.toString()}\`\n\n`;
} else {
markdown += `| - | - | - | 无参数 |\n\n`;
}
}
// JSON或文本格式的处理
else {
// 尝试解析参数并构建表格
try {
if (bodyFormat === 'json') {
const parsedBody = JSON.parse(requestBodyContent);
if (typeof parsedBody === 'object' && parsedBody !== null) {
markdown += `| 参数名 | 必选 | 类型 | 说明 |\n`;
markdown += `| ------ | ---- | ---- | ---- |\n`;
Object.entries(parsedBody).forEach(([key, value]) => {
const type = Array.isArray(value) ? 'array' : typeof value;
markdown += `| ${key} | 是 | ${type} | - |\n`;
});
markdown += `\n`;
} else {
markdown += `\`\`\`json\n${requestBodyContent}\n\`\`\`\n\n`;
}
} else if (requestBodyContent.trim()) {
markdown += `\`\`\`\n${requestBodyContent}\n\`\`\`\n\n`;
}
} catch {
if (requestBodyContent.trim()) {
markdown += `\`\`\`\n${requestBodyContent}\n\`\`\`\n\n`;
}
}
}
}
// 响应结果部分
if (response) {
// 返回示例
markdown += `**返回示例**\n\n`;
if (typeof response.data === 'object') {
// 直接使用JSON.stringify的缩进参数格式化JSON
markdown += `\`\`\`json\n${JSON.stringify(response.data, null, 2)}\n\`\`\`\n\n`;
} else if (typeof response.data === 'string') {
// 尝试检测是否为JSON字符串
try {
const parsedJson = JSON.parse(response.data);
markdown += `\`\`\`json\n${JSON.stringify(parsedJson, null, 2)}\n\`\`\`\n\n`;
} catch {
// 不是JSON字符串,直接显示
markdown += `\`\`\`\n${response.data}\n\`\`\`\n\n`;
}
} else {
markdown += `\`\`\`\n${response.data}\n\`\`\`\n\n`;
}
}
// 备注
markdown += `**备注** \n\n`;
markdown += `- 此文档由HTTP测试工具自动生成\n`;
markdown += `- 响应时间: ${response?.time || '-'}ms\n`;
markdown += `- 响应大小: ${response?.size || '-'} bytes\n`;
return markdown;
};
+345
View File
@@ -0,0 +1,345 @@
'use client';
import React, { useState, useRef, ChangeEvent } from 'react';
import Compressor from 'compressorjs';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUpload, faDownload, faCog, faTrash, faImage } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
card: "card p-6 mb-6",
inputLabel: "block text-secondary text-sm font-bold mb-2",
fileUploadBtn: "btn-primary flex items-center justify-center cursor-pointer",
fileName: "ml-3 text-secondary text-sm",
heading: "text-lg font-semibold mb-2 text-primary",
settingsContainer: "space-y-4",
rangeInput: "w-full accent-[rgb(var(--color-primary))]",
textInput: "search-input w-full",
infoPanel: "bg-block p-4 rounded-lg border border-purple-glow/15",
infoPanelRow: "flex justify-between mb-2",
infoLabel: "text-secondary",
infoValue: "font-medium text-primary",
successValue: "font-medium text-success",
actionBtn: "w-full",
actionBtnDisabled: "btn-secondary opacity-50 cursor-not-allowed w-full",
imageContainer: "bg-block rounded-lg p-4 min-h-64 flex items-center justify-center border border-purple-glow/15",
image: "max-w-full max-h-96 object-contain",
placeholder: "text-tertiary",
};
export default function ImageCompressor() {
const { t } = useLanguage();
const [originalImage, setOriginalImage] = useState<string | null>(null);
const [compressedImage, setCompressedImage] = useState<string | null>(null);
const [fileName, setFileName] = useState<string>('');
const [originalSize, setOriginalSize] = useState<number>(0);
const [compressedSize, setCompressedSize] = useState<number>(0);
const [isCompressing, setIsCompressing] = useState<boolean>(false);
const [quality, setQuality] = useState<number>(80);
const [maxWidth, setMaxWidth] = useState<number>(1920);
const [maxHeight, setMaxHeight] = useState<number>(1080);
const [maintainRatio, setMaintainRatio] = useState<boolean>(true);
const [compressFormat, setCompressFormat] = useState<string>('auto');
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setFileName(file.name);
setOriginalSize(file.size);
setCompressedImage(null);
setCompressedSize(0);
const reader = new FileReader();
reader.onload = (e) => {
setOriginalImage(e.target?.result as string);
};
reader.readAsDataURL(file);
};
const compressImage = () => {
if (!fileInputRef.current?.files?.[0]) return;
const file = fileInputRef.current.files[0];
setIsCompressing(true);
new Compressor(file, {
quality: quality / 100,
maxWidth,
maxHeight,
checkOrientation: true,
convertSize: 5000000, // 如果图片大于5MB,则转换为JPEG
mimeType: compressFormat === 'auto' ? undefined : `image/${compressFormat}`,
success(result) {
const reader = new FileReader();
reader.onload = (e) => {
setCompressedImage(e.target?.result as string);
setCompressedSize(result.size);
setIsCompressing(false);
};
reader.readAsDataURL(result);
},
error(err) {
console.error(t('tools.image_compressor.compression_failed'), err);
setIsCompressing(false);
},
});
};
const downloadCompressedImage = () => {
if (!compressedImage) return;
const link = document.createElement('a');
const ext = compressFormat === 'auto'
? fileName.split('.').pop()
: compressFormat;
const newFileName = fileName.replace(
/\.[^/.]+$/,
`.compressed.${ext === 'auto' ? 'jpg' : ext}`
);
link.href = compressedImage;
link.download = newFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const resetAll = () => {
setOriginalImage(null);
setCompressedImage(null);
setFileName('');
setOriginalSize(0);
setCompressedSize(0);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const formatSize = (bytes: number): string => {
if (bytes === 0) return '0 ' + t('tools.image_compressor.bytes');
const k = 1024;
const sizes = [
t('tools.image_compressor.bytes'),
t('tools.image_compressor.kb'),
t('tools.image_compressor.mb'),
t('tools.image_compressor.gb')
];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const calculateReduction = (): string => {
if (!originalSize || !compressedSize) return '0%';
const reduction = ((originalSize - compressedSize) / originalSize) * 100;
return `${reduction.toFixed(2)}%`;
};
return (
<div className={styles.container}>
{/* 使用 ToolHeader 组件 */}
<ToolHeader
icon={faImage}
toolCode="image_compressor"
title=""
description=""
/>
{/* 主要内容区 */}
<div className={styles.card}>
<div className="mb-6">
<label className={styles.inputLabel}>
{t('tools.image_compressor.select_image')}
</label>
<div className="flex items-center">
<label className={styles.fileUploadBtn}>
<FontAwesomeIcon icon={faUpload} className="mr-2 icon" />
{t('tools.image_compressor.choose_file')}
<input
type="file"
className="hidden"
accept="image/*"
onChange={handleFileChange}
ref={fileInputRef}
/>
</label>
{fileName && (
<span className={styles.fileName}>{fileName}</span>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div>
<h3 className={styles.heading}>{t('tools.image_compressor.compression_settings')}</h3>
<div className={styles.settingsContainer}>
<div>
<label className={styles.inputLabel}>
{t('tools.image_compressor.quality')} ({quality}%)
</label>
<input
type="range"
min="1"
max="100"
value={quality}
onChange={(e) => setQuality(parseInt(e.target.value))}
className={styles.rangeInput}
/>
</div>
<div>
<label className={styles.inputLabel}>
{t('tools.image_compressor.max_width')}
</label>
<input
type="number"
min="1"
value={maxWidth}
onChange={(e) => setMaxWidth(parseInt(e.target.value))}
className={styles.textInput}
/>
</div>
<div>
<label className={styles.inputLabel}>
{t('tools.image_compressor.max_height')}
</label>
<input
type="number"
min="1"
value={maxHeight}
onChange={(e) => setMaxHeight(parseInt(e.target.value))}
className={styles.textInput}
/>
</div>
<div>
<label className={styles.inputLabel}>
{t('tools.image_compressor.output_format')}
</label>
<select
value={compressFormat}
onChange={(e) => setCompressFormat(e.target.value)}
className={styles.textInput}
>
<option value="auto">{t('tools.image_compressor.auto_format')}</option>
<option value="jpeg">JPEG</option>
<option value="png">PNG</option>
<option value="webp">WebP</option>
</select>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="maintainRatio"
checked={maintainRatio}
onChange={(e) => setMaintainRatio(e.target.checked)}
className="mr-2 accent-[rgb(var(--color-primary))] w-4 h-4"
/>
<label htmlFor="maintainRatio" className={styles.inputLabel}>
{t('tools.image_compressor.maintain_ratio')}
</label>
</div>
</div>
</div>
<div>
{originalImage && (
<div className="mb-4">
<h3 className={styles.heading}>{t('tools.image_compressor.compression_info')}</h3>
<div className={styles.infoPanel}>
<div className={styles.infoPanelRow}>
<span className={styles.infoLabel}>{t('tools.image_compressor.original_size')}:</span>
<span className={styles.infoValue}>{formatSize(originalSize)}</span>
</div>
{compressedSize > 0 && (
<>
<div className={styles.infoPanelRow}>
<span className={styles.infoLabel}>{t('tools.image_compressor.compressed_size')}:</span>
<span className={styles.infoValue}>{formatSize(compressedSize)}</span>
</div>
<div className={styles.infoPanelRow}>
<span className={styles.infoLabel}>{t('tools.image_compressor.compression_ratio')}:</span>
<span className={styles.successValue}>{calculateReduction()}</span>
</div>
</>
)}
</div>
</div>
)}
<div className="space-y-3">
<button
onClick={compressImage}
disabled={!originalImage || isCompressing}
className={!originalImage || isCompressing ? styles.actionBtnDisabled : "btn-primary " + styles.actionBtn}
>
<FontAwesomeIcon icon={faCog} className="mr-2 icon" />
{isCompressing ? t('tools.image_compressor.compressing') : t('tools.image_compressor.compress_image')}
</button>
<button
onClick={downloadCompressedImage}
disabled={!compressedImage}
className={!compressedImage ? styles.actionBtnDisabled : "btn-primary " + styles.actionBtn}
style={{ background: compressedImage ? 'linear-gradient(to right, var(--color-success), var(--color-success-hover))' : '' }}
>
<FontAwesomeIcon icon={faDownload} className="mr-2 icon" />
{t('tools.image_compressor.download_compressed')}
</button>
<button
onClick={resetAll}
disabled={!originalImage}
className={!originalImage ? styles.actionBtnDisabled : "btn-secondary " + styles.actionBtn}
>
<FontAwesomeIcon icon={faTrash} className="mr-2 icon" />
{t('tools.image_compressor.reset')}
</button>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="card p-4">
<h3 className={styles.heading}>{t('tools.image_compressor.original_image')}</h3>
<div className={styles.imageContainer}>
{originalImage ? (
<img
src={originalImage}
alt={t('tools.image_compressor.original_image')}
className={styles.image}
/>
) : (
<div className={styles.placeholder}>{t('tools.image_compressor.no_image_selected')}</div>
)}
</div>
</div>
<div className="card p-4">
<h3 className={styles.heading}>{t('tools.image_compressor.compressed_image')}</h3>
<div className={styles.imageContainer}>
{compressedImage ? (
<img
src={compressedImage}
alt={t('tools.image_compressor.compressed_image')}
className={styles.image}
/>
) : (
<div className={styles.placeholder}>
{isCompressing ? t('tools.image_compressor.compressing') : t('tools.image_compressor.no_compressed_image')}
</div>
)}
</div>
</div>
</div>
</div>
);
}
+578
View File
@@ -0,0 +1,578 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faNetworkWired, faCopy, faCheck, faSearch } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import { apiClient } from '@/lib/api-client';
import { useLanguage } from '@/context/LanguageContext';
// IP信息接口
interface IPInfo {
ip: string;
country?: string;
region?: string;
city?: string;
isp?: string;
lat?: number;
lon?: number;
timezone?: string;
source?: string;
[key: string]: unknown; // 其他可能的字段
}
// API接口定义
interface ApiSource {
name: string;
url: string;
responseParser: (data: unknown) => IPInfo;
supportsQuery: boolean;
}
// API响应接口
interface ApiResponse {
data: unknown;
source: string;
requestType?: 'self' | 'query';
cached?: boolean;
}
// 添加CSS变量样式
const styles = {
card: "card p-6",
input: "search-input w-full",
textarea: "w-full p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple transition-all",
label: "text-secondary font-medium",
secondaryText: "text-sm text-tertiary",
resultItem: "flex justify-between items-center py-2 border-b border-purple-glow/10",
resultLabel: "text-sm text-secondary",
resultValue: "text-sm text-primary font-semibold",
iconButton: "text-tertiary hover:text-purple transition-colors",
primaryBtn: "btn-primary flex items-center justify-center gap-2",
resultBox: "p-3 bg-block rounded-md border border-purple-glow/30",
}
export default function IpLookup() {
const { t, language } = useLanguage();
// 状态管理
const [ipAddress, setIpAddress] = useState<string>('');
const [ipInfo, setIpInfo] = useState<IPInfo | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>('');
const [copied, setCopied] = useState(false);
// API 源列表
const apiSources: ApiSource[] = [
{
name: t('tools.ip_lookup.api_sources.pconline'),
url: '/api/ip?ip={ip}&source=太平洋电脑网',
responseParser: (data: unknown) => {
const response = data as ApiResponse;
try {
const typedData = response.data as { ip?: string; addr?: string; pro?: string; city?: string };
// 确保所有字段都存在并有默认值
const addr = typedData.addr || '';
const pro = typedData.pro || '';
const city = typedData.city || '';
return {
ip: typedData.ip || t('tools.ip_lookup.unknown'),
country: addr.split(' ')[0] || t('tools.ip_lookup.unknown'),
region: pro || t('tools.ip_lookup.unknown'),
city: city || t('tools.ip_lookup.unknown'),
isp: addr.includes('电信') ? '电信' :
addr.includes('联通') ? '联通' :
addr.includes('移动') ? '移动' :
addr.includes('铁通') ? '铁通' :
addr.includes('网通') ? '网通' : t('tools.ip_lookup.unknown'),
source: response.source
};
} catch (error) {
console.error(t('tools.ip_lookup.console_errors.pconline'), error);
return {
ip: t('tools.ip_lookup.unknown'),
country: t('tools.ip_lookup.unknown'),
region: t('tools.ip_lookup.unknown'),
city: t('tools.ip_lookup.unknown'),
isp: t('tools.ip_lookup.unknown'),
source: response.source
};
}
},
supportsQuery: true
},
{
name: t('tools.ip_lookup.api_sources.ipcn'),
url: '/api/ip?ip={ip}&source=IP.CN',
responseParser: (data: unknown) => {
const response = data as ApiResponse;
try {
const typedData = response.data as { ip?: string; address?: string };
if (!typedData?.ip) {
return {
ip: t('tools.ip_lookup.unknown'),
country: t('tools.ip_lookup.unknown'),
region: t('tools.ip_lookup.unknown'),
city: t('tools.ip_lookup.unknown'),
isp: t('tools.ip_lookup.unknown'),
source: response.source
};
}
return {
ip: typedData.ip,
country: typedData.address?.split(' ')?.[0] || t('tools.ip_lookup.unknown'),
region: typedData.address?.split(' ')?.[1] || t('tools.ip_lookup.unknown'),
city: typedData.address?.split(' ')?.[2] || t('tools.ip_lookup.unknown'),
isp: typedData.address?.split(' ')?.[3] || t('tools.ip_lookup.unknown'),
source: response.source
};
} catch (error) {
console.error(t('tools.ip_lookup.console_errors.ipcn'), error);
return {
ip: t('tools.ip_lookup.unknown'),
country: t('tools.ip_lookup.unknown'),
region: t('tools.ip_lookup.unknown'),
city: t('tools.ip_lookup.unknown'),
isp: t('tools.ip_lookup.unknown'),
source: response.source
};
}
},
supportsQuery: true
},
{
name: t('tools.ip_lookup.api_sources.ipapi'),
url: '/api/ip?ip={ip}&source=ip-api.com',
responseParser: (data: unknown) => {
const response = data as ApiResponse;
try {
const typedData = response.data as {
query?: string;
country?: string;
regionName?: string;
city?: string;
isp?: string;
lat?: number;
lon?: number;
timezone?: string;
};
if (!typedData?.query) {
return {
ip: t('tools.ip_lookup.unknown'),
country: t('tools.ip_lookup.unknown'),
region: t('tools.ip_lookup.unknown'),
city: t('tools.ip_lookup.unknown'),
isp: t('tools.ip_lookup.unknown'),
source: response.source
};
}
return {
ip: typedData.query,
country: typedData.country || t('tools.ip_lookup.unknown'),
region: typedData.regionName || t('tools.ip_lookup.unknown'),
city: typedData.city || t('tools.ip_lookup.unknown'),
isp: typedData.isp || t('tools.ip_lookup.unknown'),
lat: typedData.lat,
lon: typedData.lon,
timezone: typedData.timezone,
source: response.source
};
} catch (error) {
console.error(t('tools.ip_lookup.console_errors.ipapi'), error);
return {
ip: t('tools.ip_lookup.unknown'),
country: t('tools.ip_lookup.unknown'),
region: t('tools.ip_lookup.unknown'),
city: t('tools.ip_lookup.unknown'),
isp: t('tools.ip_lookup.unknown'),
source: response.source
};
}
},
supportsQuery: true
},
{
name: t('tools.ip_lookup.api_sources.baidu'),
url: '/api/ip?ip={ip}&source=百度IP',
responseParser: (data: unknown) => {
const response = data as ApiResponse;
const typedData = response.data as {
data: Array<{
location: string;
origip: string;
}>
};
// 安全处理
if (!typedData.data || !typedData.data[0]) {
return {
ip: t('tools.ip_lookup.unknown'),
source: response.source
};
}
const locationInfo = typedData.data[0].location?.split(' ') || [];
return {
ip: typedData.data[0].origip || t('tools.ip_lookup.unknown'),
country: locationInfo[0] || t('tools.ip_lookup.unknown'),
region: locationInfo[1] || t('tools.ip_lookup.unknown'),
city: locationInfo[2] || t('tools.ip_lookup.unknown'),
isp: locationInfo[3] || t('tools.ip_lookup.unknown'),
source: response.source
};
},
supportsQuery: true
},
{
name: t('tools.ip_lookup.api_sources.meitu'),
url: '/api/ip?ip={ip}&source=美图IP',
responseParser: (data: unknown) => {
const response = data as ApiResponse;
try {
const typedData = response.data as {
data: {
[key: string]: {
nation: string;
province: string;
city: string;
isp: string;
latitude: number;
longitude: number;
}
}
};
// 找到IP键
const ipKey = Object.keys(typedData.data)[0];
const ipData = typedData.data[ipKey];
return {
ip: ipKey || t('tools.ip_lookup.unknown'),
country: ipData.nation || t('tools.ip_lookup.unknown'),
region: ipData.province || t('tools.ip_lookup.unknown'),
city: ipData.city || t('tools.ip_lookup.unknown'),
isp: ipData.isp || t('tools.ip_lookup.unknown'),
lat: ipData.latitude,
lon: ipData.longitude,
source: response.source
};
} catch (error) {
console.error(t('tools.ip_lookup.console_errors.meitu'), error);
return {
ip: t('tools.ip_lookup.unknown'),
country: t('tools.ip_lookup.unknown'),
region: t('tools.ip_lookup.unknown'),
city: t('tools.ip_lookup.unknown'),
isp: t('tools.ip_lookup.unknown'),
source: response.source
};
}
},
supportsQuery: true
}
];
// API源重构,确保语言变化时更新
useEffect(() => {
// 此处空实现,确保语言切换时组件重新渲染
}, [language, t]);
// 处理我的IP点击
const handleMyIpClick = () => {
setIpAddress('');
searchIP('');
};
// 获取IP信息
const fetchIPInfo = async (ip: string) => {
setLoading(true);
setError('');
try {
const results = await Promise.all(
apiSources.map(async (source) => {
try {
const url = source.url.replace('{ip}', ip);
const response = await apiClient.get(url);
return source.responseParser(response);
} catch (error) {
console.error(`${source.name}${t('tools.ip_lookup.errors.query_failed')}:`, error);
return null;
}
})
);
// 过滤掉失败的结果
const validResults = results.filter((result): result is IPInfo => result !== null);
if (validResults.length === 0) {
throw new Error(t('tools.ip_lookup.errors.query_failed'));
}
// 使用第一个有效结果
setIpInfo(validResults[0]);
} catch (error) {
setError(error instanceof Error ? error.message : t('tools.ip_lookup.errors.query_failed'));
} finally {
setLoading(false);
}
};
// 搜索IP
const searchIP = async (ip: string = ipAddress) => {
if (!ip) {
await fetchIPInfo('');
return;
}
// 验证IP地址格式
const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (!ipRegex.test(ip)) {
setError(t('tools.ip_lookup.errors.invalid_ip'));
return;
}
await fetchIPInfo(ip);
};
// 复制到剪贴板
const copyToClipboard = () => {
if (!ipInfo) return;
const text = Object.entries(ipInfo)
.filter(([key]) => !['source'].includes(key))
.map(([key, value]) => `${t(`tools.ip_lookup.ip_info.${key}`)}: ${value}`)
.join('\n');
navigator.clipboard.writeText(text)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(() => {
console.error(t('tools.ip_lookup.errors.copy_failed'));
});
};
// 获取IP类型
const getIPType = (ip: string) => {
if (!ip || ip === t('tools.ip_lookup.unknown')) return t('tools.ip_lookup.unknown');
const firstOctet = parseInt(ip.split('.')[0]);
if (firstOctet >= 1 && firstOctet <= 126) return 'A';
if (firstOctet >= 128 && firstOctet <= 191) return 'B';
if (firstOctet >= 192 && firstOctet <= 223) return 'C';
if (firstOctet >= 224 && firstOctet <= 239) return 'D';
if (firstOctet >= 240 && firstOctet <= 255) return 'E';
return t('tools.ip_lookup.unknown');
};
// 获取IP分类
const getIPClass = (ip: string) => {
if (!ip || ip === t('tools.ip_lookup.unknown')) return t('tools.ip_lookup.unknown');
const firstOctet = parseInt(ip.split('.')[0]);
if (firstOctet === 10) return t('tools.ip_lookup.ip_classes.private');
if (firstOctet === 172 && parseInt(ip.split('.')[1]) >= 16 && parseInt(ip.split('.')[1]) <= 31) return t('tools.ip_lookup.ip_classes.private');
if (firstOctet === 192 && parseInt(ip.split('.')[1]) === 168) return t('tools.ip_lookup.ip_classes.private');
if (firstOctet === 127) return t('tools.ip_lookup.ip_classes.loopback');
if (firstOctet === 169 && parseInt(ip.split('.')[1]) === 254) return t('tools.ip_lookup.ip_classes.link_local');
return t('tools.ip_lookup.ip_classes.public');
};
// IP转二进制
const ipToBinary = (ip: string) => {
if (!ip || ip === t('tools.ip_lookup.unknown')) return t('tools.ip_lookup.unknown');
return ip.split('.').map(num => parseInt(num).toString(2).padStart(8, '0')).join('.');
};
// IP转十六进制
const ipToHex = (ip: string) => {
if (!ip || ip === t('tools.ip_lookup.unknown')) return t('tools.ip_lookup.unknown');
return ip.split('.').map(num => parseInt(num).toString(16).padStart(2, '0')).join('.');
};
// 处理搜索点击
const handleSearchClick = () => {
searchIP();
};
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
<ToolHeader
icon={faNetworkWired}
toolCode="ip_lookup"
title=""
description=""
/>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="lg:col-span-5">
<div className={styles.card}>
<h2 className="text-lg font-medium text-primary mb-4">{t('tools.ip_lookup.title')}</h2>
<div className="mb-6">
<label className={styles.label}>{t('tools.ip_lookup.input_label')}</label>
<div className="mt-2 flex gap-2">
<div className="relative flex-1">
<input
type="text"
value={ipAddress}
onChange={(e) => setIpAddress(e.target.value.trim())}
onKeyDown={(e) => {
if (e.key === 'Enter') {
searchIP();
}
}}
placeholder={t('tools.ip_lookup.input_placeholder_example')}
className={styles.input}
/>
</div>
<button
className={styles.primaryBtn}
onClick={handleSearchClick}
disabled={loading}
>
{loading ? (
<span className="animate-spin">
<FontAwesomeIcon icon={faSearch} />
</span>
) : (
<>
<FontAwesomeIcon icon={faSearch} />
{t('tools.ip_lookup.search_button')}
</>
)}
</button>
</div>
<button
className="mt-3 btn-secondary text-sm"
onClick={handleMyIpClick}
disabled={loading}
>
{t('tools.ip_lookup.my_ip_button')}
</button>
{error && (
<div className="mt-3 p-2 bg-red-900/20 border border-red-700/30 text-red-500 rounded-lg">
{error}
</div>
)}
</div>
<div>
<h3 className="text-primary font-medium mb-2">{t('tools.ip_lookup.instruction_title')}</h3>
<ul className="list-disc pl-5 space-y-1 text-sm text-tertiary">
<li>{t('tools.ip_lookup.instructions.line1')}</li>
<li>{t('tools.ip_lookup.instructions.line2')}</li>
<li>{t('tools.ip_lookup.instructions.line3')}</li>
<li>{t('tools.ip_lookup.instructions.line4')}</li>
</ul>
</div>
</div>
</div>
<div className="lg:col-span-7">
<div className={styles.card}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium text-primary">{t('tools.ip_lookup.results.title')}</h2>
</div>
{ipInfo ? (
<div className="space-y-4">
<div className={styles.resultBox}>
<div className="flex justify-between items-center mb-3 pb-2 border-b border-purple-glow/20">
<h3 className="text-primary font-medium">{t('tools.ip_lookup.ip_info.ip')}: {ipInfo.ip}</h3>
<button
onClick={copyToClipboard}
className={styles.iconButton}
title={t('tools.ip_lookup.copy')}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} className="ml-1" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.country')}:</span>
<span className={styles.resultValue}>{ipInfo.country || t('tools.ip_lookup.unknown')}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.region')}:</span>
<span className={styles.resultValue}>{ipInfo.region || t('tools.ip_lookup.unknown')}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.city')}:</span>
<span className={styles.resultValue}>{ipInfo.city || t('tools.ip_lookup.unknown')}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.isp')}:</span>
<span className={styles.resultValue}>{ipInfo.isp || t('tools.ip_lookup.unknown')}</span>
</div>
{ipInfo.lat && ipInfo.lon && (
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.coordinates')}:</span>
<span className={styles.resultValue}>{ipInfo.lat}, {ipInfo.lon}</span>
</div>
)}
{ipInfo.timezone && (
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.timezone')}:</span>
<span className={styles.resultValue}>{ipInfo.timezone}</span>
</div>
)}
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.source')}:</span>
<span className={styles.resultValue}>{ipInfo.source || t('tools.ip_lookup.unknown')}</span>
</div>
</div>
</div>
<div className="bg-block p-4 rounded-lg border border-purple-glow/20">
<h3 className="text-primary font-medium mb-3">{t('tools.ip_lookup.technical_details')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.ip_type')}:</span>
<span className={styles.resultValue}>{getIPType(ipInfo.ip)}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.ip_class')}:</span>
<span className={styles.resultValue}>{getIPClass(ipInfo.ip)}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.binary')}:</span>
<span className={`${styles.resultValue} font-mono text-xs`}>{ipToBinary(ipInfo.ip)}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.ip_lookup.ip_info.hex')}:</span>
<span className={`${styles.resultValue} font-mono`}>{ipToHex(ipInfo.ip)}</span>
</div>
</div>
</div>
</div>
) : (
<div className="p-12 flex flex-col items-center justify-center text-tertiary">
<FontAwesomeIcon icon={faNetworkWired} className="text-4xl mb-4 opacity-20" />
<p>{t('tools.ip_lookup.results.empty_state')}</p>
</div>
)}
</div>
</div>
</div>
</div>
);
}
+567
View File
@@ -0,0 +1,567 @@
// JSON转换工具函数
/**
* 将JSON转换为XML格式
*/
export function jsonToXml(jsonString: string, options?: { rootName?: string }): string {
try {
const jsonObj = JSON.parse(jsonString);
const rootName = options?.rootName || 'root';
// XML头部
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
// 递归转换JSON对象为XML
const jsonObjToXml = (obj: unknown, nodeName: string, indent = ''): string => {
if (obj === null || obj === undefined) {
return `${indent}<${nodeName}></${nodeName}>\n`;
}
if (typeof obj !== 'object') {
// 处理基本类型
return `${indent}<${nodeName}>${escapeXml(String(obj))}</${nodeName}>\n`;
}
if (Array.isArray(obj)) {
// 处理数组:每个元素都使用相同的节点名
return obj.map(item => jsonObjToXml(item, nodeName, indent)).join('');
}
// 处理对象
let result = `${indent}<${nodeName}>\n`;
// 遍历对象属性
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = (obj as Record<string, unknown>)[key];
result += jsonObjToXml(value, key, indent + ' ');
}
}
result += `${indent}</${nodeName}>\n`;
return result;
};
// 转换JSON对象并添加到XML字符串
xml += jsonObjToXml(jsonObj, rootName);
return xml;
} catch (error) {
if (error instanceof Error) {
throw new Error(`JSON转XML失败: ${error.message}`);
}
throw new Error('JSON转XML失败');
}
}
/**
* 将XML转换为JSON格式
*/
export function xmlToJson(xmlString: string): string {
try {
// 创建DOM解析器
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlString, 'text/xml');
// 检查解析错误
const parseError = xmlDoc.getElementsByTagName('parsererror');
if (parseError.length > 0) {
throw new Error('XML解析错误:无效的XML格式');
}
// 递归处理XML节点
const processNode = (node: Element): unknown => {
// 如果节点没有子元素,返回节点的文本内容
if (node.childNodes.length === 0 ||
(node.childNodes.length === 1 && node.childNodes[0].nodeType === 3)) {
const text = node.textContent || '';
// 尝试转换为数字或布尔值
if (text === 'true') return true;
if (text === 'false') return false;
if (!isNaN(Number(text)) && text.trim() !== '') return Number(text);
return text;
}
// 处理有子元素的节点
const result: Record<string, unknown> = {};
const childElements = Array.from(node.children);
// 对每个子元素类型进行分组,检测数组
const elementCounts: Record<string, Element[]> = {};
childElements.forEach(child => {
if (!elementCounts[child.nodeName]) {
elementCounts[child.nodeName] = [];
}
elementCounts[child.nodeName].push(child);
});
// 处理每种子元素
for (const [nodeName, elements] of Object.entries(elementCounts)) {
if (elements.length === 1) {
// 单个元素
result[nodeName] = processNode(elements[0]);
} else {
// 多个同名元素,作为数组处理
result[nodeName] = elements.map(processNode);
}
}
return result;
};
// 处理XML文档的根元素
const root = xmlDoc.documentElement;
const jsonObj = processNode(root);
// 将对象转换为格式化的JSON字符串
return JSON.stringify(jsonObj, null, 2);
} catch (error) {
if (error instanceof Error) {
throw new Error(`XML转JSON失败: ${error.message}`);
}
throw new Error('XML转JSON失败');
}
}
/**
* 将JSON转换为CSV格式
*/
export function jsonToCsv(jsonString: string, options?: { delimiter?: string; header?: boolean }): string {
try {
const jsonObj = JSON.parse(jsonString);
const delimiter = options?.delimiter || ',';
const includeHeader = options?.header !== false;
// 处理数组数据
if (Array.isArray(jsonObj)) {
if (jsonObj.length === 0) {
return '';
}
// 提取所有可能的字段
const fields = new Set<string>();
jsonObj.forEach(item => {
if (typeof item === 'object' && item !== null) {
Object.keys(item).forEach(key => fields.add(key));
}
});
const fieldNames = Array.from(fields);
// 生成CSV头
let csv = includeHeader ? fieldNames.map(escapeCSV).join(delimiter) + '\n' : '';
// 生成CSV数据行
jsonObj.forEach(item => {
const row = fieldNames.map(field => {
const value = (item as Record<string, unknown>)[field];
// 处理嵌套对象或数组
if (value !== null && typeof value === 'object') {
return escapeCSV(JSON.stringify(value));
}
return value !== undefined ? escapeCSV(String(value)) : '';
});
csv += row.join(delimiter) + '\n';
});
return csv;
} else if (typeof jsonObj === 'object' && jsonObj !== null) {
// 如果是单个对象,将其转换为单行CSV
const fields = Object.keys(jsonObj);
let csv = includeHeader ? fields.map(escapeCSV).join(delimiter) + '\n' : '';
// 生成数据行
const row = fields.map(field => {
const value = (jsonObj as Record<string, unknown>)[field];
// 处理嵌套对象或数组
if (value !== null && typeof value === 'object') {
return escapeCSV(JSON.stringify(value));
}
return value !== undefined ? escapeCSV(String(value)) : '';
});
csv += row.join(delimiter) + '\n';
return csv;
} else {
throw new Error('无法转换为CSVJSON必须是对象或数组');
}
} catch (error) {
if (error instanceof Error) {
throw new Error(`JSON转CSV失败: ${error.message}`);
}
throw new Error('JSON转CSV失败');
}
}
/**
* 将CSV转换为JSON格式
*/
export function csvToJson(csvString: string, options?: { delimiter?: string; header?: boolean }): string {
try {
const delimiter = options?.delimiter || ',';
const hasHeader = options?.header !== false;
// 分割CSV行
const lines = csvString.trim().split(/\r?\n/);
if (lines.length === 0) {
return '[]';
}
// 解析CSV行,处理引号内的分隔符
const parseCSVLine = (line: string): string[] => {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
// 引号处理:检查是否为转义的引号
if (i + 1 < line.length && line[i + 1] === '"') {
current += '"';
i++; // 跳过下一个引号
} else {
inQuotes = !inQuotes;
}
} else if (char === delimiter && !inQuotes) {
// 找到分隔符且不在引号内
result.push(current);
current = '';
} else {
current += char;
}
}
// 添加最后一个字段
result.push(current);
return result;
};
let headers: string[];
let startIndex: number;
if (hasHeader) {
// 使用第一行作为字段名
headers = parseCSVLine(lines[0]);
startIndex = 1;
} else {
// 自动生成字段名
const firstLine = parseCSVLine(lines[0]);
headers = firstLine.map((_, index) => `field${index}`);
startIndex = 0;
}
// 处理CSV数据行
const result: Record<string, unknown>[] = [];
for (let i = startIndex; i < lines.length; i++) {
if (!lines[i].trim()) continue; // 跳过空行
const values = parseCSVLine(lines[i]);
const obj: Record<string, unknown> = {};
// 将每个值与相应的字段名匹配
for (let j = 0; j < headers.length; j++) {
if (j < values.length) {
const value = values[j].trim();
// 尝试转换为合适的数据类型
if (value === 'true') obj[headers[j]] = true;
else if (value === 'false') obj[headers[j]] = false;
else if (!isNaN(Number(value)) && value !== '') obj[headers[j]] = Number(value);
else obj[headers[j]] = value;
} else {
obj[headers[j]] = '';
}
}
result.push(obj);
}
return JSON.stringify(result, null, 2);
} catch (error) {
if (error instanceof Error) {
throw new Error(`CSV转JSON失败: ${error.message}`);
}
throw new Error('CSV转JSON失败');
}
}
/**
* 将JSON转换为YAML格式
*/
export function jsonToYaml(jsonString: string): string {
try {
const jsonObj = JSON.parse(jsonString);
// 递归转换JSON对象为YAML
const convertToYaml = (obj: unknown, indent = 0): string => {
if (obj === null || obj === undefined) {
return 'null';
}
const spaces = ' '.repeat(indent);
if (typeof obj !== 'object') {
// 处理基本类型
if (typeof obj === 'string') {
// 检查是否需要引号
if (
/^[-:?!,[\]{}#&*!|>'"%@\`]|^[0-9]/.test(obj) ||
/^(true|false|null|y|n|yes|no|on|off)$/i.test(obj) ||
obj.includes('\n') ||
obj.includes(' ')
) {
// 对特殊字符进行转义
return `'${obj.replace(/'/g, "''")}'`;
}
return obj;
}
return String(obj);
}
if (Array.isArray(obj)) {
// 如果是空数组
if (obj.length === 0) {
return '[]';
}
// 处理数组:每个元素前加-号
return obj.map(item => {
if (typeof item === 'object' && item !== null) {
return `${spaces}- ${convertToYaml(item, indent + 2).trimStart()}`;
} else {
return `${spaces}- ${convertToYaml(item, indent)}`;
}
}).join('\n');
}
// 如果是空对象
if (Object.keys(obj as Record<string, unknown>).length === 0) {
return '{}';
}
// 处理对象
return Object.entries(obj as Record<string, unknown>).map(([key, value]) => {
const yamlKey = /^[a-zA-Z0-9_]+$/.test(key) ? key : `'${key}'`;
if (typeof value === 'object' && value !== null) {
return `${spaces}${yamlKey}:\n${convertToYaml(value, indent + 2)}`;
} else {
return `${spaces}${yamlKey}: ${convertToYaml(value, indent)}`;
}
}).join('\n');
};
return convertToYaml(jsonObj);
} catch (error) {
if (error instanceof Error) {
throw new Error(`JSON转YAML失败: ${error.message}`);
}
throw new Error('JSON转YAML失败');
}
}
/**
* 将YAML转换为JSON格式
*/
export function yamlToJson(yamlString: string): string {
try {
// YAML解析是复杂的,通常使用库,但这里我们简化实现
// 主要解析简单的YAML格式:缩进表示层级,冒号分隔键值对
const lines = yamlString.split(/\r?\n/);
// 处理缩进
const getIndent = (line: string): number => {
let i = 0;
while (i < line.length && line[i] === ' ') i++;
return i;
};
// 递归解析YAML
const parseYaml = (currentIndex: number, minIndent: number): [unknown, number] => {
let result: unknown = null;
let i = currentIndex;
// 确定当前行的缩进级别
const currentIndent = getIndent(lines[i]);
let isArray = lines[i].trimStart().startsWith('-');
if (isArray) {
// 解析数组
result = [];
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) {
i++;
continue; // 跳过空行
}
const lineIndent = getIndent(line);
if (lineIndent < minIndent) break; // 缩进减少,退出当前层级
if (line.trimStart().startsWith('-')) {
// 数组项
const itemText = line.trim().substring(1).trimStart();
if (itemText.includes(':')) {
// 数组项是对象
const [key, valuePart] = splitKeyValue(itemText);
const arrayItem: Record<string, unknown> = {};
if (valuePart.trim()) {
// 行内值
arrayItem[key] = parseScalar(valuePart.trim());
} else {
// 子对象,下一行应该有更多缩进
if (i + 1 < lines.length && getIndent(lines[i + 1]) > lineIndent) {
const [nestedObj, nextIndex] = parseYaml(i + 1, lineIndent + 2);
arrayItem[key] = nestedObj;
i = nextIndex - 1; // 回退一行,下次循环会递增
} else {
arrayItem[key] = null; // 键值为空
}
}
(result as unknown[]).push(arrayItem);
} else if (itemText) {
// 简单值
(result as unknown[]).push(parseScalar(itemText));
} else {
// 下一行有更多缩进
if (i + 1 < lines.length && getIndent(lines[i + 1]) > lineIndent) {
const [nestedObj, nextIndex] = parseYaml(i + 1, lineIndent + 2);
(result as unknown[]).push(nestedObj);
i = nextIndex - 1;
} else {
(result as unknown[]).push(null);
}
}
} else if (lineIndent === currentIndent) {
// 同一缩进级别但不是数组项,说明数组结束
break;
}
i++;
}
} else {
// 解析对象
result = {};
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) {
i++;
continue; // 跳过空行
}
const lineIndent = getIndent(line);
if (lineIndent < minIndent) break; // 缩进减少,退出当前层级
if (lineIndent === minIndent) {
if (line.trimStart().startsWith('-')) {
// 数组开始,与对象同级
isArray = true;
break;
}
if (line.includes(':')) {
// 键值对
const [key, valuePart] = splitKeyValue(line.trim());
if (valuePart.trim()) {
// 行内值
(result as Record<string, unknown>)[key] = parseScalar(valuePart.trim());
} else {
// 子对象或数组,下一行应该有更多缩进
if (i + 1 < lines.length && getIndent(lines[i + 1]) > lineIndent) {
const [nestedObj, nextIndex] = parseYaml(i + 1, lineIndent + 2);
(result as Record<string, unknown>)[key] = nestedObj;
i = nextIndex - 1; // 回退一行,下次循环会递增
} else {
(result as Record<string, unknown>)[key] = null; // 键值为空
}
}
}
} else if (lineIndent > minIndent) {
// 缩进增加,属于上一个键的子内容
continue;
}
i++;
}
}
return [result, i];
};
// 辅助函数:分割键值对
const splitKeyValue = (line: string): [string, string] => {
const colonIndex = line.indexOf(':');
if (colonIndex === -1) return [line, ''];
let key = line.substring(0, colonIndex).trim();
// 如果键名有引号,去掉引号
if ((key.startsWith("'") && key.endsWith("'")) ||
(key.startsWith('"') && key.endsWith('"'))) {
key = key.substring(1, key.length - 1);
}
const value = line.substring(colonIndex + 1);
return [key, value];
};
// 辅助函数:解析标量值
const parseScalar = (value: string): unknown => {
// 去掉引号
if ((value.startsWith("'") && value.endsWith("'")) ||
(value.startsWith('"') && value.endsWith('"'))) {
return value.substring(1, value.length - 1);
}
// 解析为特定类型
if (value === 'null' || value === '~' || value === '') return null;
if (value === 'true' || value === 'yes' || value === 'on') return true;
if (value === 'false' || value === 'no' || value === 'off') return false;
if (!isNaN(Number(value))) return Number(value);
return value;
};
// 开始解析
const [result] = parseYaml(0, 0);
return JSON.stringify(result, null, 2);
} catch (error) {
if (error instanceof Error) {
throw new Error(`YAML转JSON失败: ${error.message}`);
}
throw new Error('YAML转JSON失败');
}
}
// 辅助函数:转义XML特殊字符
function escapeXml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// 辅助函数:转义CSV字段
function escapeCSV(value: string): string {
// 如果字段包含逗号、双引号或换行符,需要用双引号包围并处理
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
// 双引号内的双引号需要再次转义
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
+552
View File
@@ -0,0 +1,552 @@
'use client';
import React, { useState, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faExchangeAlt, faCopy, faCheck, faSyncAlt,
faEraser, faDownload, faCog, faInfoCircle,
faCode
} from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import tools from '@/config/tools';
import { useLanguage } from '@/context/LanguageContext';
// 格式转换函数
import {
jsonToXml, xmlToJson,
jsonToCsv, csvToJson,
jsonToYaml, yamlToJson
} from './converters';
// 添加CSS变量样式
const styles = {
card: "card p-6",
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
input: "search-input w-full",
textarea: "w-full p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple transition-all",
label: "text-sm text-secondary font-medium",
secondaryText: "text-sm text-tertiary",
statusLabel: "px-2 py-1 rounded-md text-xs",
selectBox: "w-full px-3 py-2 bg-block border border-purple-glow rounded text-primary focus:outline-none focus:border-purple",
errorBox: "p-3 bg-red-900/20 border border-red-700/30 rounded-lg text-error",
iconButton: "p-1 text-secondary hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed",
directionIndicator: (active: boolean) => active ? "text-primary" : "text-tertiary",
formatTypeBtn: "flex items-center gap-2 p-3 rounded-lg border border-purple-glow/20 transition-all hover:border-purple-glow",
formatTypeBtnActive: "bg-purple-glow/10 border-purple",
actionBtn: "btn-secondary flex items-center gap-2",
actionBtnPrimary: "btn-primary flex items-center gap-2",
swapBtn: "bg-block p-2 rounded-full hover:bg-block-hover transition-colors text-purple",
advancedOptionsBtn: "text-sm flex items-center gap-1 text-secondary hover:text-primary transition-colors",
advancedOptionsContainer: "mt-4 p-4 bg-block-strong rounded-lg",
progressContainer: "flex items-center justify-center min-h-[200px]",
flexRow: "flex flex-col sm:flex-row gap-4 justify-between items-center",
formatGroup: "grid grid-cols-1 md:grid-cols-3 gap-3",
}
export default function JsonConverter() {
const { t } = useLanguage();
// 从工具配置中获取当前工具信息
const toolConfig = tools.find(tool => tool.code === 'json_converter');
// 格式类型选项
const formatTypes = [
{ id: 'xml', name: t('tools.json_converter.format_types.xml.name'), description: t('tools.json_converter.format_types.xml.description') },
{ id: 'csv', name: t('tools.json_converter.format_types.csv.name'), description: t('tools.json_converter.format_types.csv.description') },
{ id: 'yaml', name: t('tools.json_converter.format_types.yaml.name'), description: t('tools.json_converter.format_types.yaml.description') },
];
// 状态管理
const [inputText, setInputText] = useState('');
const [outputText, setOutputText] = useState('');
const [formatType, setFormatType] = useState('xml');
const [direction, setDirection] = useState('json_to_format'); // 'json_to_format' 或 'format_to_json'
const [error, setError] = useState('');
const [copied, setCopied] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [advancedOptions, setAdvancedOptions] = useState(false);
const [csvDelimiter, setCsvDelimiter] = useState(',');
const [csvHeader, setCsvHeader] = useState(true);
const [xmlRootName, setXmlRootName] = useState('root');
// 引用
const inputRef = useRef<HTMLTextAreaElement>(null);
const outputRef = useRef<HTMLTextAreaElement>(null);
// 执行转换
const performConversion = () => {
if (!inputText.trim()) {
setOutputText('');
setError('');
return;
}
setIsProcessing(true);
setError('');
try {
let result = '';
const options = {
delimiter: csvDelimiter,
header: csvHeader,
rootName: xmlRootName
};
if (direction === 'json_to_format') {
// 首先验证输入是有效的JSON
try {
JSON.parse(inputText);
} catch {
throw new Error(t('tools.json_converter.errors.invalid_json'));
}
// JSON 转换为其他格式
if (formatType === 'xml') {
result = jsonToXml(inputText, options);
} else if (formatType === 'csv') {
result = jsonToCsv(inputText, options);
} else if (formatType === 'yaml') {
result = jsonToYaml(inputText);
}
} else {
// 其他格式转换为JSON
if (formatType === 'xml') {
result = xmlToJson(inputText);
} else if (formatType === 'csv') {
result = csvToJson(inputText, options);
} else if (formatType === 'yaml') {
result = yamlToJson(inputText);
}
}
setOutputText(result);
} catch (err) {
if (err instanceof Error) {
setError(err.message);
} else {
setError(t('tools.json_converter.errors.conversion_error'));
}
setOutputText('');
} finally {
setIsProcessing(false);
}
};
// 复制输出内容到剪贴板
const copyToClipboard = () => {
if (!outputText) return;
navigator.clipboard.writeText(outputText)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => {
console.error(t('tools.json_converter.errors.copy_failed'), err);
setError(t('tools.json_converter.errors.clipboard_error'));
});
};
// 下载输出结果
const downloadOutput = () => {
if (!outputText) return;
let extension = 'txt';
let mimeType = 'text/plain';
if (direction === 'json_to_format') {
if (formatType === 'xml') {
extension = 'xml';
mimeType = 'application/xml';
} else if (formatType === 'csv') {
extension = 'csv';
mimeType = 'text/csv';
} else if (formatType === 'yaml') {
extension = 'yaml';
mimeType = 'application/x-yaml';
}
} else {
extension = 'json';
mimeType = 'application/json';
}
const blob = new Blob([outputText], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `converted.${extension}`;
document.body.appendChild(link);
link.click();
URL.revokeObjectURL(url);
document.body.removeChild(link);
};
// 清空输入和输出
const clearAll = () => {
setInputText('');
setOutputText('');
setError('');
if (inputRef.current) {
inputRef.current.focus();
}
};
// 交换方向
const swapDirection = () => {
setDirection(direction === 'json_to_format' ? 'format_to_json' : 'json_to_format');
// 交换输入和输出的内容
setInputText(outputText);
setOutputText('');
setError('');
};
// 加载示例
const loadExample = () => {
const jsonExample = `{
"person": {
"name": "张三",
"age": 28,
"isStudent": false,
"address": {
"city": "北京",
"district": "海淀区",
"postal": "100000"
},
"hobbies": ["读书", "旅游", "编程"]
}
}`;
const xmlExample = `<?xml version="1.0" encoding="UTF-8"?>
<root>
<person>
<n>张三</n>
<age>28</age>
<isStudent>false</isStudent>
<address>
<city>北京</city>
<district>海淀区</district>
<postal>100000</postal>
</address>
<hobbies>读书</hobbies>
<hobbies>旅游</hobbies>
<hobbies>编程</hobbies>
</person>
</root>`;
const csvExample = `name,age,isStudent,city,district,postal,hobbies
张三,28,false,北京,海淀区,100000,"读书,旅游,编程"`;
const yamlExample = `person:
name: 张三
age: 28
isStudent: false
address:
city: 北京
district: 海淀区
postal: '100000'
hobbies:
- 读书
- 旅游
- 编程`;
// 根据当前格式和方向加载示例
if (direction === 'json_to_format') {
setInputText(jsonExample);
} else {
if (formatType === 'xml') {
setInputText(xmlExample);
} else if (formatType === 'csv') {
setInputText(csvExample);
} else if (formatType === 'yaml') {
setInputText(yamlExample);
}
}
setOutputText('');
setError('');
};
return (
<div className={styles.container}>
{/* 工具头部 */}
<ToolHeader
icon={toolConfig?.icon || faCode}
toolCode="json_converter"
title=""
description=""
/>
{/* 主内容区域 */}
<div className={styles.card}>
{/* 格式类型选择 */}
<div className="space-y-6">
{/* 格式选择区域 */}
<div>
<h2 className="text-primary font-medium mb-4">{t('tools.json_converter.select_format_type')}</h2>
<div className={styles.formatGroup}>
{formatTypes.map((type) => (
<button
key={type.id}
className={`${styles.formatTypeBtn} ${formatType === type.id ? styles.formatTypeBtnActive : ''}`}
onClick={() => setFormatType(type.id)}
>
<span className="font-medium">{type.name}</span>
<span className="text-sm text-tertiary">{type.description}</span>
</button>
))}
</div>
</div>
{/* 转换方向选择 */}
<div className={styles.flexRow}>
<div className="flex items-center space-x-4">
<span className={styles.directionIndicator(direction === 'json_to_format')}>
{t('tools.json_converter.direction.json_to_format')}
</span>
<button
onClick={swapDirection}
className={styles.swapBtn}
>
<FontAwesomeIcon
icon={faExchangeAlt}
className={`transition-transform ${direction === 'format_to_json' ? 'rotate-180' : ''}`}
/>
</button>
<span className={styles.directionIndicator(direction === 'format_to_json')}>
{t('tools.json_converter.direction.format_to_json').replace('{format}', formatType.toUpperCase())}
</span>
</div>
<div className="flex gap-2">
<button
onClick={loadExample}
className={styles.actionBtn}
>
<FontAwesomeIcon icon={faSyncAlt} />
{t('tools.json_converter.actions.load_example')}
</button>
<button
onClick={clearAll}
disabled={!inputText}
className={styles.actionBtn}
>
<FontAwesomeIcon icon={faEraser} />
{t('tools.json_converter.actions.clear')}
</button>
</div>
</div>
{/* 高级选项切换 */}
<div>
<button
onClick={() => setAdvancedOptions(!advancedOptions)}
className={styles.advancedOptionsBtn}
>
<FontAwesomeIcon icon={faCog} />
{advancedOptions
? t('tools.json_converter.advanced_options.hide')
: t('tools.json_converter.advanced_options.show')}
</button>
{/* 高级选项面板 */}
{advancedOptions && (
<div className={styles.advancedOptionsContainer}>
{formatType === 'csv' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className={styles.label}>{t('tools.json_converter.advanced_options.csv.delimiter')}</label>
<select
value={csvDelimiter}
onChange={(e) => setCsvDelimiter(e.target.value)}
className={styles.selectBox}
>
<option value=",">{t('tools.json_converter.advanced_options.csv.delimiters.comma')}</option>
<option value=";">{t('tools.json_converter.advanced_options.csv.delimiters.semicolon')}</option>
<option value="\t">{t('tools.json_converter.advanced_options.csv.delimiters.tab')}</option>
<option value="|">{t('tools.json_converter.advanced_options.csv.delimiters.pipe')}</option>
</select>
</div>
<div>
<label className={styles.label}>{t('tools.json_converter.advanced_options.csv.include_header')}</label>
<div className="mt-2">
<label className="flex items-center">
<input
type="checkbox"
checked={csvHeader}
onChange={(e) => setCsvHeader(e.target.checked)}
className="mr-2"
/>
<span className="text-secondary">
{direction === 'json_to_format'
? t('tools.json_converter.advanced_options.csv.generate_header')
: t('tools.json_converter.advanced_options.csv.parse_header')}
</span>
</label>
</div>
</div>
</div>
)}
{formatType === 'xml' && (
<div>
<label className={styles.label}>{t('tools.json_converter.advanced_options.xml.root_element')}</label>
<input
type="text"
value={xmlRootName}
onChange={(e) => setXmlRootName(e.target.value)}
placeholder={t('tools.json_converter.advanced_options.xml.root_placeholder')}
className={styles.input}
/>
</div>
)}
<div className="mt-4">
<div className="flex items-center">
<FontAwesomeIcon icon={faInfoCircle} className="text-tertiary mr-2" />
<p className={styles.secondaryText}>{t('tools.json_converter.advanced_options.description')}</p>
</div>
</div>
</div>
)}
</div>
{/* 转换区域 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 输入框 */}
<div className="space-y-3">
<label className={styles.label}>
{direction === 'json_to_format'
? t('tools.json_converter.input.json')
: t('tools.json_converter.input.format').replace('{format}', formatType.toUpperCase())}
</label>
<textarea
ref={inputRef}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
className={styles.textarea}
placeholder={direction === 'json_to_format'
? t('tools.json_converter.input.json_placeholder')
: t('tools.json_converter.input.format_placeholder').replace('{format}', formatType.toUpperCase())}
rows={15}
/>
</div>
{/* 输出框 */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<label className={styles.label}>
{direction === 'json_to_format'
? t('tools.json_converter.output.format').replace('{format}', formatType.toUpperCase())
: t('tools.json_converter.output.json')}
</label>
<div className="flex gap-2">
<button
onClick={performConversion}
className={styles.actionBtnPrimary}
disabled={!inputText || isProcessing}
>
{isProcessing ? t('tools.json_converter.actions.converting') : t('tools.json_converter.actions.convert')}
</button>
</div>
</div>
{isProcessing ? (
<div className={styles.progressContainer}>
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-purple"></div>
</div>
) : (
<>
<textarea
ref={outputRef}
value={outputText}
readOnly
className={styles.textarea}
placeholder={direction === 'json_to_format'
? t('tools.json_converter.output.format_placeholder').replace('{format}', formatType.toUpperCase())
: t('tools.json_converter.output.json_placeholder')}
rows={15}
/>
{/* 错误信息 */}
{error && (
<div className={styles.errorBox}>
{error}
</div>
)}
{/* 操作按钮 */}
{outputText && (
<div className="flex justify-end gap-2">
<button
onClick={copyToClipboard}
className={styles.actionBtn}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
{copied ? t('tools.json_converter.actions.copied') : t('tools.json_converter.actions.copy')}
</button>
<button
onClick={downloadOutput}
className={styles.actionBtn}
>
<FontAwesomeIcon icon={faDownload} />
{t('tools.json_converter.actions.download')}
</button>
</div>
)}
</>
)}
</div>
</div>
{/* 说明部分 */}
<div className="p-4 bg-block rounded-lg">
<h3 className="text-primary font-medium mb-2">{t('tools.json_converter.notes.title')}</h3>
<div className="text-sm text-tertiary">
{formatType === 'xml' && (
<div>
<p>{t('tools.json_converter.notes.xml.title')}</p>
<ul className="list-disc pl-5 mt-2">
<li>{t('tools.json_converter.notes.xml.items.0')}</li>
<li>{t('tools.json_converter.notes.xml.items.1')}</li>
<li>{t('tools.json_converter.notes.xml.items.2')}</li>
</ul>
</div>
)}
{formatType === 'csv' && (
<div>
<p>{t('tools.json_converter.notes.csv.title')}</p>
<ul className="list-disc pl-5 mt-2">
<li>{t('tools.json_converter.notes.csv.items.0')}</li>
<li>{t('tools.json_converter.notes.csv.items.1')}</li>
<li>{t('tools.json_converter.notes.csv.items.2')}</li>
</ul>
</div>
)}
{formatType === 'yaml' && (
<div>
<p>{t('tools.json_converter.notes.yaml.title')}</p>
<ul className="list-disc pl-5 mt-2">
<li>{t('tools.json_converter.notes.yaml.items.0')}</li>
<li>{t('tools.json_converter.notes.yaml.items.1')}</li>
<li>{t('tools.json_converter.notes.yaml.items.2')}</li>
</ul>
</div>
)}
</div>
</div>
</div>
</div>
{/* 回到顶部按钮 */}
<BackToTop />
</div>
);
}
+271
View File
@@ -0,0 +1,271 @@
'use client';
import React, { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faCopy, faCheck, faDownload,
faUpload, faTrash
} from '@fortawesome/free-solid-svg-icons';
import dynamic from 'next/dynamic';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import tools from '@/config/tools';
import type { Content } from 'vanilla-jsoneditor';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
card: "card p-6",
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
actionBar: "flex flex-wrap items-center gap-3 p-4 bg-block rounded-lg border border-purple-glow",
input: "bg-block-strong text-primary border border-purple-glow/30 rounded px-3 py-1 text-sm focus:outline-none focus:border-purple focus:ring-1 focus:ring-purple",
statusMsg: "px-4 py-2 bg-block rounded-lg border border-purple-glow/30 text-secondary animate-fadeIn",
editorContainer: "overflow-hidden border border-purple-glow/30 rounded-lg shadow-lg",
fileNameLabel: "text-sm text-secondary",
loaderContainer: "h-[400px] flex items-center justify-center bg-block rounded-lg",
loaderBox: "flex flex-col items-center",
loaderSpinner: "w-8 h-8 border-2 border-purple border-t-transparent rounded-full animate-spin mb-4",
loaderText: "text-secondary"
};
// 动态导入JsonEditor组件,避免SSR问题
const JsonEditor = dynamic(() => import('@/components/JsonEditor'), {
ssr: false,
loading: () => {
return (
<div className={styles.loaderContainer}>
<div className={styles.loaderBox}>
<div className={styles.loaderSpinner}></div>
<span className={styles.loaderText}>Loading JSON Editor...</span>
</div>
</div>
);
}
});
export default function JsonEditorTool() {
// 从工具配置中获取当前工具信息
const toolConfig = tools.find(tool => tool.code === 'json_editor');
const { t } = useLanguage();
// 状态管理
const [content, setContent] = useState<Content>({ json: { example: t('tools.json_editor.edit_json_here') } });
const [copied, setCopied] = useState(false);
const [fileName, setFileName] = useState('data.json');
const [statusMessage, setStatusMessage] = useState('');
// 复制JSON到剪贴板
const copyToClipboard = () => {
try {
// 获取当前JSON内容
let jsonString;
if ('json' in content && content.json) {
jsonString = JSON.stringify(content.json, null, 2);
} else if ('text' in content && content.text) {
jsonString = content.text;
} else {
throw new Error(t('tools.json_editor.invalid_json'));
}
// 复制到剪贴板
navigator.clipboard.writeText(jsonString)
.then(() => {
setCopied(true);
setStatusMessage(t('tools.json_editor.copied_to_clipboard'));
setTimeout(() => {
setCopied(false);
setStatusMessage('');
}, 2000);
})
.catch(err => {
console.error(t('tools.json_editor.copy_failed'), err);
setStatusMessage(t('tools.json_editor.copy_failed'));
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_error) {
setStatusMessage(t('tools.json_editor.copy_failed'));
}
};
// 下载JSON文件
const downloadJson = () => {
try {
// 获取当前JSON内容
let jsonString;
if ('json' in content && content.json) {
jsonString = JSON.stringify(content.json, null, 2);
} else if ('text' in content && content.text) {
jsonString = content.text;
} else {
throw new Error(t('tools.json_editor.invalid_json'));
}
// 创建下载链接
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
// 清理
URL.revokeObjectURL(url);
document.body.removeChild(link);
setStatusMessage(t('tools.json_editor.download_success').replace('{fileName}', fileName));
setTimeout(() => setStatusMessage(''), 2000);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_error) {
setStatusMessage(t('tools.json_editor.download_failed'));
}
};
// 上传JSON文件
const uploadJson = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setFileName(file.name);
const reader = new FileReader();
reader.onload = (e) => {
try {
const fileContent = e.target?.result as string;
try {
// 尝试解析为JSON
const jsonData = JSON.parse(fileContent);
setContent({ json: jsonData });
setStatusMessage(t('tools.json_editor.loaded_file').replace('{fileName}', file.name));
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_parseError) {
// 如果解析失败,则以文本形式加载
setContent({ text: fileContent });
setStatusMessage(t('tools.json_editor.loaded_as_text').replace('{fileName}', file.name));
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_error) {
setStatusMessage(t('tools.json_editor.read_file_failed'));
}
// 重置文件输入框
event.target.value = '';
setTimeout(() => setStatusMessage(''), 2000);
};
reader.readAsText(file);
};
// 清空编辑器
const clearEditor = () => {
if (confirm(t('tools.json_editor.confirm_clear'))) {
setContent({ json: {} });
setStatusMessage(t('tools.json_editor.editor_cleared'));
setTimeout(() => setStatusMessage(''), 2000);
}
};
// 修改文件名
const handleFileNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
let name = event.target.value.trim();
if (!name) name = 'data.json';
if (!name.endsWith('.json')) name += '.json';
setFileName(name);
};
// 编辑器内容变化处理
const handleContentChange = (updatedContent: Content) => {
setContent(updatedContent);
};
return (
<div className={styles.container}>
{/* 工具头部 */}
<ToolHeader
toolCode="json_editor"
icon={toolConfig?.icon || tools[0].icon}
title=""
description=""
/>
{/* 主要内容区 */}
<div className="grid grid-cols-1 gap-6">
{/* 操作栏 */}
<div className={styles.actionBar}>
{/* 文件操作按钮 */}
<div className="flex flex-wrap items-center gap-2">
<button
className="btn-secondary"
onClick={copyToClipboard}
title={t('tools.json_editor.copy')}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} className="mr-2" />
{t('tools.json_editor.copy')}
</button>
<button
className="btn-secondary"
onClick={downloadJson}
title={t('tools.json_editor.download')}
>
<FontAwesomeIcon icon={faDownload} className="mr-2" />
{t('tools.json_editor.download')}
</button>
<label className="btn-secondary cursor-pointer">
<FontAwesomeIcon icon={faUpload} className="mr-2" />
{t('tools.json_editor.upload')}
<input
type="file"
accept=".json,application/json"
className="hidden"
onChange={uploadJson}
/>
</label>
<button
className="btn-secondary"
onClick={clearEditor}
title={t('tools.json_editor.clear')}
>
<FontAwesomeIcon icon={faTrash} className="mr-2" />
{t('tools.json_editor.clear')}
</button>
</div>
{/* 文件名输入框 */}
<div className="flex items-center gap-2 ml-auto">
<label htmlFor="filename" className={styles.fileNameLabel}>{t('tools.json_editor.file_name')}</label>
<input
id="filename"
type="text"
value={fileName}
onChange={handleFileNameChange}
className={styles.input}
/>
</div>
</div>
{/* 状态消息 */}
{statusMessage && (
<div className={styles.statusMsg}>
{statusMessage}
</div>
)}
{/* JSON编辑器 */}
<div className={styles.editorContainer}>
<JsonEditor
content={content}
onChange={handleContentChange}
className="h-[600px] w-full"
/>
</div>
</div>
{/* 返回顶部按钮 */}
<BackToTop />
</div>
);
}
+1058
View File
@@ -0,0 +1,1058 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faCode, faCopy, faCheck, faCompress, faExpand,
faSearch, faTrash, faSync, faFolderOpen, faFolder, faSpinner,
faSave, faHistory, faTimes, faEdit, faStar, faTrashAlt
} from '@fortawesome/free-solid-svg-icons';
import dynamic from 'next/dynamic';
import BackToTop from '@/components/BackToTop';
import ToolHeader from '@/components/ToolHeader';
import { useLanguage } from '@/context/LanguageContext';
// 动态导入@uiw/react-json-view组件,避免SSR问题
const ReactJson = dynamic(() => import('@uiw/react-json-view'), { ssr: false });
// 定义历史记录条目类型
interface JsonHistoryItem {
id: string;
title: string;
json: string;
timestamp: number;
isFavorite?: boolean;
}
export default function JsonFormatter() {
const { t } = useLanguage();
const [jsonInput, setJsonInput] = useState('');
const jsonInputRef = useRef<HTMLTextAreaElement>(null);
const jsonPathInputRef = useRef<HTMLInputElement>(null);
// 状态管理
const [jsonOutput, setJsonOutput] = useState<string>('');
const [jsonPath, setJsonPath] = useState<string>('');
const [pathResult, setPathResult] = useState<string>('');
const [errorMessage, setErrorMessage] = useState<string>('');
const [isCompressed, setIsCompressed] = useState<boolean>(false);
const [isFoldable, setIsFoldable] = useState<boolean>(true);
const [copied, setCopied] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isLargeJson, setIsLargeJson] = useState<boolean>(false);
const [validationResult, setValidationResult] = useState<{
isValid: boolean;
message: string;
}>({ isValid: false, message: '' });
// 历史记录相关状态
const [historyItems, setHistoryItems] = useState<JsonHistoryItem[]>([]);
const [isHistoryOpen, setIsHistoryOpen] = useState<boolean>(false);
const [savingTitle, setSavingTitle] = useState<string>('');
const [isSaveModalOpen, setIsSaveModalOpen] = useState<boolean>(false);
const [editingItem, setEditingItem] = useState<JsonHistoryItem | null>(null);
// 参考值,确保能在格式化过程中保持加载状态
const processingRef = useRef<boolean>(false);
// 从本地存储加载历史记录
useEffect(() => {
const savedHistory = localStorage.getItem('json_formatter_history');
if (savedHistory) {
try {
const parsedHistory = JSON.parse(savedHistory) as JsonHistoryItem[];
setHistoryItems(parsedHistory);
} catch (e) {
console.error(t('tools.json_formatter.load_history_error'), e);
}
}
}, [t]);
// 保存历史记录到本地存储
const saveHistoryToLocalStorage = (items: JsonHistoryItem[]) => {
localStorage.setItem('json_formatter_history', JSON.stringify(items));
};
// 格式化JSON
const formatJson = (json: string, compress = false) => {
if (!json.trim()) {
setJsonOutput('');
setValidationResult({ isValid: false, message: '' });
return;
}
// 检查JSON大小
const isLarge = json.length > 100000;
setIsLargeJson(isLarge);
// 设置加载状态和处理参考值
setIsLoading(true);
processingRef.current = true;
// 使用setTimeout确保UI先更新,但不添加不必要的延迟
setTimeout(() => {
try {
// 处理可能的JS对象文本 (将单引号转为双引号)
const processedJson = json
.replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2":') // 键名标准化
.replace(/'/g, '"'); // 单引号转双引号
try {
let parsed;
try {
parsed = JSON.parse(processedJson);
} catch (e) {
// 尝试使用eval处理JS对象(不安全,但为了更好的兼容性)
try {
// eslint-disable-next-line no-eval
parsed = eval('(' + json + ')');
} catch (/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
_) {
throw e; // 如果eval也失败了,抛出原始错误
}
}
// 根据模式输出不同格式
let formattedJson;
if (compress) {
formattedJson = JSON.stringify(parsed);
} else {
formattedJson = JSON.stringify(parsed, null, 2);
}
// 设置输出
setJsonOutput(formattedJson);
// 计算大小
const sizeKB = (formattedJson.length / 1024).toFixed(1);
const largeJsonMessage = t('tools.json_formatter.large_json_processed').replace('{size}', sizeKB);
setValidationResult({
isValid: true,
message: isLarge ? largeJsonMessage : t('tools.json_formatter.json_valid')
});
setErrorMessage('');
// 如果有JSONPath查询,执行查询
if (jsonPath) {
queryJsonPath(parsed, jsonPath);
}
// 完成后取消加载状态和处理参考值
setIsLoading(false);
processingRef.current = false;
} catch (error) {
if (error instanceof Error) {
setErrorMessage(error.message);
setValidationResult({ isValid: false, message: t('tools.json_formatter.json_invalid') });
setIsLoading(false);
processingRef.current = false;
}
}
} catch (error) {
if (error instanceof Error) {
setErrorMessage(error.message);
setValidationResult({ isValid: false, message: t('tools.json_formatter.json_invalid') });
setIsLoading(false);
processingRef.current = false;
}
}
}, 0);
};
// 取消正在进行的格式化操作
const cancelFormatting = () => {
processingRef.current = false;
setIsLoading(false);
};
// 清除组件卸载时可能的处理操作
useEffect(() => {
return () => {
processingRef.current = false;
};
}, []);
// 压缩/美化切换
const toggleCompression = () => {
setIsCompressed(!isCompressed);
formatJson(jsonInput, !isCompressed);
};
// 切换折叠功能
const toggleFoldable = () => {
setIsFoldable(!isFoldable);
};
// 复制结果到剪贴板
const copyToClipboard = () => {
if (jsonOutput) {
navigator.clipboard.writeText(jsonOutput)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => console.error(t('tools.json_formatter.copy_failed'), err));
}
};
// 清空输入
const clearInput = () => {
// 如果正在处理,先取消
if (isLoading) {
cancelFormatting();
}
setJsonInput('');
setJsonOutput('');
setErrorMessage('');
setValidationResult({ isValid: false, message: '' });
setPathResult('');
if (jsonInputRef.current) {
jsonInputRef.current.focus();
}
};
// 处理输入变化
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setJsonInput(value);
};
// 处理粘贴事件
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
// 获取粘贴的内容
const pastedText = e.clipboardData.getData('text');
if (pastedText && pastedText.trim().length > 0) {
// 如果正在处理,先取消
if (isLoading) {
cancelFormatting();
}
// 更新输入内容
setJsonInput(pastedText);
// 立即设置加载状态但延迟执行格式化,确保UI更新
setIsLoading(true);
processingRef.current = true;
setTimeout(() => formatJson(pastedText, isCompressed), 100);
}
};
// 路径查询变化
const handlePathChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setJsonPath(value);
// 如果有有效的JSON和路径,执行查询
if (value && jsonOutput) {
try {
const parsed = JSON.parse(jsonOutput);
queryJsonPath(parsed, value);
} catch (/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
_error) {
// JSON解析错误,忽略
}
} else {
setPathResult('');
}
};
// 执行JSONPath查询
const queryJsonPath = (json: Record<string, unknown>, path: string) => {
if (!path) {
setPathResult('');
return;
}
try {
// 简单的路径解析,支持点符号和方括号
const segments = path
.replace(/\[(\w+)\]/g, '.$1') // 将[abc]转换为.abc
.replace(/^\./, '') // 移除开头的点
.split('.');
let result: unknown = json;
for (const segment of segments) {
if (typeof result === 'object' && result !== null && segment in result) {
result = (result as Record<string, unknown>)[segment];
} else {
throw new Error(`路径 '${path}' 不存在`);
}
}
// 格式化结果
if (typeof result === 'object' && result !== null) {
setPathResult(JSON.stringify(result, null, 2));
} else {
setPathResult(String(result));
}
} catch (error) {
if (error instanceof Error) {
setPathResult(`查询错误: ${error.message}`);
}
}
};
// 加载示例JSON
const loadExample = () => {
// 如果正在处理,先取消
if (isLoading) {
cancelFormatting();
}
const example = {
name: "极速箱",
version: "1.0.0",
description: "高效开发工具集成平台",
author: {
name: "JiSuXiang开发团队",
email: "[email protected]"
},
features: [
"JSON格式化与验证",
"时间戳转换",
"编码转换工具",
"正则表达式测试"
],
statistics: {
tools: 2400,
users: 10000000,
rating: 4.9
},
isOpenSource: true,
lastUpdate: "2063-12-01T08:00:00Z"
};
const exampleJson = JSON.stringify(example);
setJsonInput(exampleJson);
formatJson(exampleJson, isCompressed);
};
// 重新格式化
const reformat = () => {
// 如果正在处理,先取消
if (isLoading) {
cancelFormatting();
}
formatJson(jsonInput, isCompressed);
};
// 渲染可折叠的JSON
const renderFoldableJson = (jsonStr: string) => {
if (!jsonStr) return null;
try {
const jsonObj = JSON.parse(jsonStr);
return (
<div className="json-viewer-theme">
<ReactJson
value={jsonObj}
style={{
backgroundColor: 'transparent',
fontFamily: 'monospace',
fontSize: '0.95rem',
lineHeight: '1.7',
color: 'rgb(var(--color-text-primary))'
}}
displayObjectSize={true}
enableClipboard={false}
displayDataTypes={false}
/>
</div>
);
} catch (/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
_error) {
// 如果解析失败,回退到普通模式
return <pre className="whitespace-pre-wrap m-0">{jsonStr}</pre>;
}
};
// 处理输入/输出区域的样式
const getTextareaClasses = (hasError: boolean) => {
return `w-full p-4 font-mono text-sm rounded-md border ${
hasError ? 'border-[rgb(var(--color-error))]' : 'border-[rgba(var(--color-primary),0.2)]'
} focus:outline-none focus:border-[rgb(var(--color-primary))] focus:ring-1 focus:ring-[rgb(var(--color-primary))] min-h-[350px] transition-all bg-[rgb(var(--color-bg-secondary))] text-[rgb(var(--color-text-primary))]`;
};
// 右侧输出区域样式
const outputAreaClasses = `${getTextareaClasses(false)} overflow-auto relative flex-grow json-output-area`;
// 工具栏按钮样式
const toolbarButtonClass = "px-3 py-1.5 rounded text-sm flex items-center gap-1 transition-all border border-transparent hover:border-[rgba(var(--color-primary),0.3)]";
// 历史记录项目样式
const historyItemClass = "p-3 rounded-md border border-[rgba(var(--color-primary),0.15)] hover:border-[rgba(var(--color-primary),0.4)] transition-all cursor-pointer bg-[rgb(var(--color-bg-secondary))] flex justify-between items-center mb-2";
// 自动格式化(仅在首次输入更改后)
useEffect(() => {
if (jsonInput) {
formatJson(jsonInput, isCompressed);
}
}, []);
// 保存当前JSON到历史记录
const saveToHistory = () => {
if (!jsonOutput || !jsonOutput.trim()) return;
if (editingItem) {
// 更新现有项目
const updatedItem = {
...editingItem,
title: savingTitle || `未命名 ${new Date().toLocaleString()}`,
json: jsonOutput,
timestamp: Date.now()
};
const updatedHistory = historyItems.map(item =>
item.id === editingItem.id ? updatedItem : item
);
setHistoryItems(updatedHistory);
saveHistoryToLocalStorage(updatedHistory);
} else {
// 创建新项目
const newItem: JsonHistoryItem = {
id: Date.now().toString(),
title: savingTitle || `未命名 ${new Date().toLocaleString()}`,
json: jsonOutput,
timestamp: Date.now()
};
const updatedHistory = [newItem, ...historyItems];
setHistoryItems(updatedHistory);
saveHistoryToLocalStorage(updatedHistory);
}
// 重置状态
setSavingTitle('');
setIsSaveModalOpen(false);
setEditingItem(null);
};
// 加载历史记录中的JSON
const loadFromHistory = (item: JsonHistoryItem) => {
setJsonInput(item.json);
formatJson(item.json, isCompressed);
setIsHistoryOpen(false);
};
// 删除历史记录项目
const deleteHistoryItem = (id: string, e: React.MouseEvent) => {
e.stopPropagation(); // 防止触发父元素的点击事件
const updatedHistory = historyItems.filter(item => item.id !== id);
setHistoryItems(updatedHistory);
saveHistoryToLocalStorage(updatedHistory);
};
// 编辑历史记录项目标题
const startEditingTitle = (item: JsonHistoryItem, e: React.MouseEvent) => {
e.stopPropagation(); // 防止触发父元素的点击事件
setEditingItem(item);
setSavingTitle(item.title);
setIsSaveModalOpen(true);
};
// 切换收藏状态
const toggleFavorite = (id: string, e: React.MouseEvent) => {
e.stopPropagation(); // 防止触发父元素的点击事件
const updatedHistory = historyItems.map(item => {
if (item.id === id) {
return { ...item, isFavorite: !item.isFavorite };
}
return item;
});
setHistoryItems(updatedHistory);
saveHistoryToLocalStorage(updatedHistory);
};
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
{/* 使用自定义样式 */}
<style jsx global>{`
.json-viewer-theme {
--w-rjv-border-left-color: rgba(var(--color-primary), 0.5);
--w-rjv-border-left-width: 1px;
--w-rjv-color: rgb(var(--color-text-primary));
--w-rjv-key-string: rgb(var(--color-primary-light));
--w-rjv-type-string-color: rgb(var(--color-success));
--w-rjv-type-int-color: rgb(var(--color-warning));
--w-rjv-type-float-color: rgb(var(--color-warning));
--w-rjv-type-boolean-color: rgb(var(--color-primary-hover));
--w-rjv-arrow-color: rgb(var(--color-text-secondary));
--w-rjv-background-color: transparent;
}
/* 增强JSON展示区域样式 */
.json-output-area {
font-size: 0.95rem !important;
letter-spacing: 0.01em;
background-color: rgb(var(--color-bg-secondary)) !important;
}
.json-output-area pre {
font-size: 0.95rem !important;
color: rgb(var(--color-text-primary)) !important;
}
/* 增强JSON视图组件中的文本样式 */
.json-viewer-theme > div {
font-size: 0.95rem !important;
letter-spacing: 0.01em;
}
/* 优化字符串、数字和布尔值显示 */
.json-viewer-theme [data-type="string"] {
color: rgb(var(--color-success)) !important;
opacity: 0.95;
}
.json-viewer-theme [data-type="number"],
.json-viewer-theme [data-type="int"] {
color: rgb(var(--color-warning)) !important;
opacity: 0.95;
}
.json-viewer-theme [data-type="boolean"] {
color: rgb(var(--color-primary-hover)) !important;
opacity: 0.95;
}
/* 优化键名显示 */
.json-viewer-theme [data-key] {
color: rgb(var(--color-primary-light)) !important;
font-weight: 500;
opacity: 0.9;
}
/* 优化项目计数显示 */
.json-viewer-theme .w-rjv-objects {
color: rgb(var(--color-text-secondary)) !important;
opacity: 0.9;
font-size: 0.9em;
}
/* 优化同级元素对齐 */
.json-viewer-theme .w-rjv-object-key,
.json-viewer-theme .w-rjv-array-key {
position: relative;
display: inline-flex;
min-width: 1em;
}
/* 使展开/折叠按钮更加合理 */
.json-viewer-theme .w-rjv-item {
position: relative;
}
.json-viewer-theme .w-rjv-arrow {
position: absolute;
left: -15px;
top: 3px;
}
/* 减弱引号的显示 */
.json-viewer-theme .w-rjv-qoute {
opacity: 0.5;
color: rgb(var(--color-text-tertiary)) !important;
}
/* 增强非折叠模式下的JSON显示 */
.json-output-area pre.text-base {
padding: 0.5rem;
}
/* 增强JSONPath查询结果区域 */
.card pre {
font-size: 0.95rem !important;
color: rgb(var(--color-text-primary)) !important;
}
/* 暗色主题特定样式 */
[data-theme="dark"] .json-output-area pre {
text-shadow: none;
}
[data-theme="dark"] .json-viewer-theme [data-type="string"] {
text-shadow: none;
}
[data-theme="dark"] .json-viewer-theme [data-type="number"],
[data-theme="dark"] .json-viewer-theme [data-type="int"] {
text-shadow: none;
}
[data-theme="dark"] .json-viewer-theme [data-type="boolean"] {
text-shadow: none;
}
/* 历史记录面板样式 */
.history-panel {
position: fixed;
top: 0;
right: 0;
height: 100vh;
width: 350px;
background-color: rgb(var(--color-bg-primary));
border-left: 1px solid rgba(var(--color-primary), 0.2);
z-index: 50;
transform: translateX(100%);
transition: transform 0.3s ease-in-out;
box-shadow: -5px 0 15px rgba(0, 0, 0, 0.1);
padding: 1rem;
overflow-y: auto;
}
.history-panel.open {
transform: translateX(0);
}
.history-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 40;
backdrop-filter: blur(2px);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease-in-out;
}
.history-overlay.open {
opacity: 1;
pointer-events: auto;
}
/* 模态框样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 60;
display: flex;
justify-content: center;
align-items: center;
backdrop-filter: blur(3px);
}
.modal-container {
background-color: rgb(var(--color-bg-primary));
border-radius: 0.5rem;
padding: 1.5rem;
width: 90%;
max-width: 500px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
border: 1px solid rgba(var(--color-primary), 0.2);
}
/* 收藏图标样式 */
.favorite-icon {
color: rgba(var(--color-text-tertiary));
transition: color 0.2s ease-in-out;
}
.favorite-icon.active {
color: rgb(var(--color-warning));
}
`}</style>
{/* 使用 ToolHeader 组件 */}
<ToolHeader
toolCode="json_formatter"
icon={faCode}
title={t('tools.json_formatter.title')}
description={t('tools.json_formatter.description')}
/>
{/* 验证状态显示 */}
<div className="text-center mb-4">
{validationResult.message && !isLoading && (
<span className={validationResult.isValid ? 'text-[rgb(var(--color-success))]' : 'text-[rgb(var(--color-error))]'}>
{validationResult.message}
</span>
)}
{isLoading && (
<span className="flex items-center justify-center" style={{color: 'rgb(var(--color-text-tertiary))'}}>
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-1" />
<span>{isLargeJson ? t('tools.json_formatter.processing_large_json') : t('tools.json_formatter.parsing_json')}</span>
</span>
)}
</div>
{/* 工具栏 */}
<div className="flex flex-wrap gap-2 mb-4">
<button
className={`${toolbarButtonClass} bg-[rgb(var(--color-bg-secondary))] text-[rgb(var(--color-text-secondary))] hover:text-[rgb(var(--color-text-primary))]`}
onClick={toggleCompression}
disabled={isLoading}
>
<FontAwesomeIcon icon={isCompressed ? faExpand : faCompress} />
<span>{isCompressed ? t('tools.json_formatter.beautify') : t('tools.json_formatter.compress')}</span>
</button>
<button
className={`${toolbarButtonClass} bg-[rgb(var(--color-bg-secondary))] text-[rgb(var(--color-text-secondary))] hover:text-[rgb(var(--color-text-primary))]`}
onClick={toggleFoldable}
disabled={isLoading || !jsonOutput}
>
<FontAwesomeIcon icon={isFoldable ? faFolder : faFolderOpen} />
<span>{isFoldable ? t('tools.json_formatter.normal_mode') : t('tools.json_formatter.fold_mode')}</span>
</button>
<button
className={`${toolbarButtonClass} bg-[rgb(var(--color-bg-secondary))] text-[rgb(var(--color-text-secondary))] hover:text-[rgb(var(--color-text-primary))]`}
onClick={copyToClipboard}
disabled={!jsonOutput || isLoading}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
<span>{copied ? t('common.copySuccess') : t('tools.json_formatter.copy')}</span>
</button>
<button
className={`${toolbarButtonClass} bg-[rgb(var(--color-bg-secondary))] text-[rgb(var(--color-text-secondary))] hover:text-[rgb(var(--color-text-primary))]`}
onClick={clearInput}
disabled={isLoading}
>
<FontAwesomeIcon icon={faTrash} />
<span>{t('tools.json_formatter.clear')}</span>
</button>
<button
className={`${toolbarButtonClass} bg-[rgb(var(--color-bg-secondary))] text-[rgb(var(--color-text-secondary))] hover:text-[rgb(var(--color-text-primary))]`}
onClick={loadExample}
disabled={isLoading}
>
<FontAwesomeIcon icon={faCode} />
<span>{t('tools.json_formatter.load_example')}</span>
</button>
<button
className={`${toolbarButtonClass} bg-[rgb(var(--color-bg-secondary))] text-[rgb(var(--color-text-secondary))] hover:text-[rgb(var(--color-text-primary))]`}
onClick={reformat}
disabled={!jsonInput || isLoading}
>
<FontAwesomeIcon icon={faSync} className={isLoading ? 'animate-spin' : ''} />
<span>{isLoading ? t('tools.json_formatter.processing') : t('tools.json_formatter.reformat')}</span>
</button>
{/* 新增的保存和历史记录按钮 */}
<button
className={`${toolbarButtonClass} bg-[rgb(var(--color-bg-secondary))] text-[rgb(var(--color-text-secondary))] hover:text-[rgb(var(--color-text-primary))]`}
onClick={() => setIsSaveModalOpen(true)}
disabled={!jsonOutput || isLoading}
>
<FontAwesomeIcon icon={faSave} />
<span>{t('tools.json_formatter.save')}</span>
</button>
<button
className={`${toolbarButtonClass} bg-[rgb(var(--color-bg-secondary))] text-[rgb(var(--color-text-secondary))] hover:text-[rgb(var(--color-text-primary))]`}
onClick={() => setIsHistoryOpen(true)}
>
<FontAwesomeIcon icon={faHistory} />
<span>{t('tools.json_formatter.history')}</span>
</button>
{isLoading && (
<button
className="px-3 py-1.5 rounded text-sm flex items-center gap-1 transition-all border"
style={{
backgroundColor: 'rgb(var(--color-bg-secondary))',
color: 'rgb(var(--color-text-secondary))',
borderColor: 'rgb(var(--color-primary))',
opacity: 0.8
}}
onClick={cancelFormatting}
>
<FontAwesomeIcon icon={faSpinner} className="animate-spin" />
<span>{t('tools.json_formatter.cancel')}</span>
</button>
)}
</div>
{/* 主内容区 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6 flex-grow">
{/* 输入区域 */}
<div className="flex flex-col h-full">
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium" style={{color: 'rgb(var(--color-text-secondary))'}}>{t('tools.json_formatter.input_json')}</label>
<div className="text-xs" style={{color: 'rgb(var(--color-text-tertiary))'}}>{t('tools.json_formatter.paste_json_here')}</div>
</div>
<div className="flex-grow flex flex-col">
<textarea
ref={jsonInputRef}
className={getTextareaClasses(!!errorMessage) + " flex-grow"}
value={jsonInput}
onChange={handleInputChange}
onBlur={() => jsonInput && !isLoading && formatJson(jsonInput, isCompressed)}
onPaste={handlePaste}
placeholder={t('tools.json_formatter.paste_json_placeholder')}
disabled={isLoading}
/>
{errorMessage && (
<div className="mt-2 text-sm text-[rgb(var(--color-error))]">
{errorMessage}
</div>
)}
</div>
</div>
{/* 输出区域 */}
<div className="flex flex-col h-full">
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium" style={{color: 'rgb(var(--color-text-secondary))'}}>{t('tools.json_formatter.output')}</label>
<div className="text-xs" style={{color: 'rgb(var(--color-text-tertiary))'}}>
{jsonOutput && !isLoading && `${jsonOutput.length.toLocaleString()} ${t('tools.json_formatter.characters')}`}
{isLoading && (
<span className="flex items-center">
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-1" />
<span>{isLargeJson ? t('tools.json_formatter.processing_large_json') : t('tools.json_formatter.processing')}</span>
</span>
)}
</div>
</div>
<div className={outputAreaClasses}>
{isLoading ? (
<div className="absolute inset-0 flex items-center justify-center backdrop-blur-sm"
style={{backgroundColor: 'rgba(var(--color-bg-secondary), 0.7)'}}>
<div className="flex flex-col items-center space-y-2">
<FontAwesomeIcon icon={faSpinner} className="animate-spin text-2xl" style={{color: 'rgb(var(--color-primary))'}} />
<span style={{color: 'rgb(var(--color-text-secondary))'}} className="text-center">
{isLargeJson ?
t('tools.json_formatter.processing_large_json_message') :
t('tools.json_formatter.parsing_json')}
</span>
<button
className="mt-3 px-3 py-1.5 text-xs rounded transition-all border"
style={{
backgroundColor: 'rgb(var(--color-bg-secondary))',
color: 'rgb(var(--color-text-secondary))',
borderColor: 'rgba(var(--color-primary), 0.5)'
}}
onClick={cancelFormatting}
>
{t('tools.json_formatter.cancel_processing')}
</button>
</div>
</div>
) : isFoldable ? (
renderFoldableJson(jsonOutput)
) : (
<pre className="whitespace-pre-wrap m-0 text-base leading-7" style={{color: 'rgb(var(--color-text-primary))'}}>{jsonOutput}</pre>
)}
</div>
</div>
</div>
{/* JSONPath查询 */}
<div className="mt-4 card p-4">
<div className="mb-2">
<label className="text-sm font-medium" style={{color: 'rgb(var(--color-text-secondary))'}}>{t('tools.json_formatter.jsonpath_query')}</label>
<div className="text-xs mt-1" style={{color: 'rgb(var(--color-text-tertiary))'}}>
{t('tools.json_formatter.enter_jsonpath')}
</div>
</div>
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<FontAwesomeIcon
icon={faSearch}
className="absolute left-3 top-1/2 -translate-y-1/2"
style={{color: 'rgb(var(--color-text-tertiary))'}}
/>
<input
ref={jsonPathInputRef}
type="text"
value={jsonPath}
onChange={handlePathChange}
placeholder={t('tools.json_formatter.jsonpath_placeholder')}
className="search-input pl-10"
disabled={isLoading || !jsonOutput}
/>
</div>
</div>
<div className="flex-1">
<div className="p-3 rounded-md min-h-[40px] text-sm"
style={{
backgroundColor: 'rgb(var(--color-bg-secondary))',
color: 'rgb(var(--color-text-secondary))'
}}>
<pre className="whitespace-pre-wrap">{pathResult || t('tools.json_formatter.query_result_placeholder')}</pre>
</div>
</div>
</div>
</div>
{/* 历史记录侧边栏 */}
<div className={`history-overlay ${isHistoryOpen ? 'open' : ''}`} onClick={() => setIsHistoryOpen(false)}></div>
<div className={`history-panel ${isHistoryOpen ? 'open' : ''}`}>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium" style={{color: 'rgb(var(--color-text-primary))'}}>{t('tools.json_formatter.history')}</h3>
<button
className="p-2 rounded-full hover:bg-[rgba(var(--color-primary),0.1)]"
onClick={() => setIsHistoryOpen(false)}
>
<FontAwesomeIcon icon={faTimes} style={{color: 'rgb(var(--color-text-secondary))'}} />
</button>
</div>
{historyItems.length === 0 ? (
<div className="text-center py-8" style={{color: 'rgb(var(--color-text-tertiary))'}}>
<p>{t('tools.json_formatter.no_saved_records')}</p>
<p className="text-sm mt-2">{t('tools.json_formatter.save_first_record')}</p>
</div>
) : (
<div>
{/* 收藏的项目 */}
{historyItems.some(item => item.isFavorite) && (
<div className="mb-4">
<h4 className="text-sm font-medium mb-2" style={{color: 'rgb(var(--color-text-secondary))'}}>{t('tools.json_formatter.favorites')}</h4>
{historyItems
.filter(item => item.isFavorite)
.map(item => (
<div key={item.id} className={historyItemClass} onClick={() => loadFromHistory(item)}>
<div className="flex-1 truncate">
<div className="font-medium truncate">{item.title}</div>
<div className="text-xs" style={{color: 'rgb(var(--color-text-tertiary))'}}>
{new Date(item.timestamp).toLocaleString()}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => toggleFavorite(item.id, e)}
title={item.isFavorite ? t('tools.json_formatter.remove_favorite') : t('tools.json_formatter.add_favorite')}
>
<FontAwesomeIcon
icon={faStar}
className={`favorite-icon ${item.isFavorite ? 'active' : ''}`}
/>
</button>
<button onClick={(e) => startEditingTitle(item, e)} title={t('tools.json_formatter.edit_title')}>
<FontAwesomeIcon
icon={faEdit}
style={{color: 'rgb(var(--color-text-tertiary))'}}
/>
</button>
<button onClick={(e) => deleteHistoryItem(item.id, e)} title={t('tools.json_formatter.delete')}>
<FontAwesomeIcon
icon={faTrashAlt}
style={{color: 'rgb(var(--color-text-tertiary))'}}
/>
</button>
</div>
</div>
))}
</div>
)}
{/* 全部历史记录 */}
<h4 className="text-sm font-medium mb-2" style={{color: 'rgb(var(--color-text-secondary))'}}>{t('tools.json_formatter.all_history')}</h4>
{historyItems.map(item => (
<div key={item.id} className={historyItemClass} onClick={() => loadFromHistory(item)}>
<div className="flex-1 truncate">
<div className="font-medium truncate">{item.title}</div>
<div className="text-xs" style={{color: 'rgb(var(--color-text-tertiary))'}}>
{new Date(item.timestamp).toLocaleString()}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => toggleFavorite(item.id, e)}
title={item.isFavorite ? t('tools.json_formatter.remove_favorite') : t('tools.json_formatter.add_favorite')}
>
<FontAwesomeIcon
icon={faStar}
className={`favorite-icon ${item.isFavorite ? 'active' : ''}`}
/>
</button>
<button onClick={(e) => startEditingTitle(item, e)} title={t('tools.json_formatter.edit_title')}>
<FontAwesomeIcon
icon={faEdit}
style={{color: 'rgb(var(--color-text-tertiary))'}}
/>
</button>
<button onClick={(e) => deleteHistoryItem(item.id, e)} title={t('tools.json_formatter.delete')}>
<FontAwesomeIcon
icon={faTrashAlt}
style={{color: 'rgb(var(--color-text-tertiary))'}}
/>
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* 保存模态框 */}
{isSaveModalOpen && (
<div className="modal-overlay">
<div className="modal-container">
<h3 className="text-lg font-medium mb-4" style={{color: 'rgb(var(--color-text-primary))'}}>
{editingItem ? t('tools.json_formatter.edit_saved_json') : t('tools.json_formatter.save_to_history')}
</h3>
<div className="mb-4">
<label className="block text-sm font-medium mb-1" style={{color: 'rgb(var(--color-text-secondary))'}}>
{t('tools.json_formatter.title')}
</label>
<input
type="text"
value={savingTitle}
onChange={(e) => setSavingTitle(e.target.value)}
placeholder={t('tools.json_formatter.enter_title')}
className="search-input w-full"
/>
</div>
<div className="flex justify-end gap-2">
<button
className="px-4 py-2 rounded-md text-sm"
style={{
backgroundColor: 'rgb(var(--color-bg-secondary))',
color: 'rgb(var(--color-text-secondary))'
}}
onClick={() => {
setIsSaveModalOpen(false);
setEditingItem(null);
setSavingTitle('');
}}
>
{t('tools.json_formatter.cancel')}
</button>
<button
className="px-4 py-2 rounded-md text-sm"
style={{
backgroundColor: 'rgb(var(--color-primary))',
color: 'white'
}}
onClick={saveToHistory}
>
{editingItem ? t('tools.json_formatter.update') : t('tools.json_formatter.save')}
</button>
</div>
</div>
</div>
)}
{/* 说明信息 */}
<div className="mt-8 text-sm" style={{color: 'rgb(var(--color-text-tertiary))'}}>
<h3 className="mb-2 font-medium" style={{color: 'rgb(var(--color-text-secondary))'}}>{t('tools.json_formatter.usage_guide')}</h3>
<ul className="list-disc pl-5 space-y-1">
<li>{t('tools.json_formatter.guide_1')}</li>
<li>{t('tools.json_formatter.guide_2')}</li>
<li>{t('tools.json_formatter.guide_3')}</li>
<li>{t('tools.json_formatter.guide_4')}</li>
<li>{t('tools.json_formatter.guide_5')}</li>
<li>{t('tools.json_formatter.guide_6')}</li>
<li>{t('tools.json_formatter.guide_7')}</li>
</ul>
</div>
{/* 回到顶部按钮 */}
<BackToTop position="bottom-right" offset={30} size="medium" />
</div>
);
}
+536
View File
@@ -0,0 +1,536 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLock, faCopy, faCheck, faEraser, faSync, faInfoCircle } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import tools from '@/config/tools';
import { useLanguage } from '@/context/LanguageContext';
interface JwtPayload {
exp?: number;
iat?: number;
sub?: string;
[key: string]: unknown;
}
interface JwtHeader {
alg: string;
typ: string;
[key: string]: unknown;
}
interface DecodedJwt {
header: JwtHeader;
payload: JwtPayload;
signature: string;
isValid: boolean;
expirationStatus: 'valid' | 'expired' | 'not-set';
expiresIn?: string;
}
// 添加CSS变量样式
const styles = {
card: "card p-6",
textArea: "w-full h-24 p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple transition-all resize-none font-mono",
preArea: "w-full h-80 p-3 bg-block border border-purple-glow rounded-lg text-primary overflow-auto scrollbar-thin scrollbar-thumb-block-strong scrollbar-track-block font-mono text-sm whitespace-pre-wrap",
label: "text-secondary font-medium",
error: "p-3 bg-red-900/20 border border-red-700/30 text-red-500 rounded-lg",
success: "p-3 bg-green-900/20 border border-green-700/30 text-success rounded-lg",
tabButton: (isActive: boolean) => `px-4 py-2 font-medium text-sm ${isActive ? 'text-purple border-b-2 border-purple' : 'text-tertiary hover:text-secondary'}`,
copyButton: "flex items-center gap-1 text-sm px-2 py-1 rounded bg-block-strong hover:bg-block-strong/80 text-secondary transition-colors",
statusBox: (type: 'valid' | 'expired' | 'not-set') => {
if (type === 'valid') return "px-2 py-1 rounded-md text-xs bg-green-900/10 text-success";
if (type === 'expired') return "px-2 py-1 rounded-md text-xs bg-red-900/10 text-error";
return "px-2 py-1 rounded-md text-xs bg-block-strong text-tertiary";
},
heading: "text-primary font-medium mb-2",
secondaryText: "text-sm text-tertiary",
highlight: "text-purple",
tokenPart: "rounded px-2 py-1 text-xs text-white font-mono",
headerPart: "bg-blue-500",
payloadPart: "bg-purple-500",
signaturePart: "bg-green-500"
}
export default function JwtDecoder() {
// 从工具配置中获取当前工具信息
const toolConfig = tools.find(tool => tool.code === 'jwt_decoder');
const { t } = useLanguage();
// 状态管理
const [jwtToken, setJwtToken] = useState('');
const [decodedJwt, setDecodedJwt] = useState<DecodedJwt | null>(null);
const [activeTab, setActiveTab] = useState<'header' | 'payload' | 'signature'>('payload');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [copied, setCopied] = useState(false);
const [showTokenParts, setShowTokenParts] = useState(false);
// 解析JWT
useEffect(() => {
if (!jwtToken.trim()) {
setDecodedJwt(null);
setError('');
return;
}
try {
const result = decodeJwt(jwtToken);
setDecodedJwt(result);
setError('');
} catch (err) {
if (err instanceof Error) {
setError(err.message);
} else {
setError(t('tools.jwt_decoder.parsing_error'));
}
setDecodedJwt(null);
}
}, [jwtToken, t]);
// 解码JWT令牌
const decodeJwt = (token: string): DecodedJwt => {
if (!token) {
throw new Error(t('tools.jwt_decoder.token_empty'));
}
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error(t('tools.jwt_decoder.invalid_format'));
}
try {
// 解码header和payload
const header = JSON.parse(base64UrlDecode(parts[0])) as JwtHeader;
const payload = JSON.parse(base64UrlDecode(parts[1])) as JwtPayload;
const signature = parts[2];
// 计算过期状态
let expirationStatus: 'valid' | 'expired' | 'not-set' = 'not-set';
let expiresIn: string | undefined;
if (payload.exp) {
const expiration = new Date(payload.exp * 1000);
const now = new Date();
if (expiration > now) {
expirationStatus = 'valid';
expiresIn = getTimeRemaining(expiration);
} else {
expirationStatus = 'expired';
}
}
// 简单验证
const isValid = verifySignatureFormat(token);
return {
header,
payload,
signature,
isValid,
expirationStatus,
expiresIn
};
} catch {
throw new Error(t('tools.jwt_decoder.parsing_failed'));
}
};
// Base64 URL解码
const base64UrlDecode = (str: string): string => {
// 替换URL安全Base64字符为标准Base64
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
// 添加填充字符
while (base64.length % 4) {
base64 += '=';
}
try {
// 解码
return decodeURIComponent(
atob(base64)
.split('')
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
} catch {
throw new Error(t('tools.jwt_decoder.invalid_base64'));
}
};
// 验证签名格式
const verifySignatureFormat = (token: string): boolean => {
const parts = token.split('.');
return parts.length === 3 && !!parts[2];
};
// 格式化JSON数据
const formatJson = (obj: unknown): string => {
return JSON.stringify(obj, null, 2);
};
// 计算剩余时间
const getTimeRemaining = (expirationDate: Date): string => {
const now = new Date();
const diff = expirationDate.getTime() - now.getTime();
if (diff <= 0) {
return t('tools.jwt_decoder.expired');
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
let timeStr = '';
if (days > 0) timeStr += `${days}${t('tools.jwt_decoder.days')} `;
if (hours > 0 || days > 0) timeStr += `${hours}${t('tools.jwt_decoder.hours')} `;
if (minutes > 0 || hours > 0 || days > 0) timeStr += `${minutes}${t('tools.jwt_decoder.minutes')} `;
timeStr += `${seconds}${t('tools.jwt_decoder.seconds')}`;
return timeStr;
};
// 复制当前标签内容到剪贴板
const copyToClipboard = () => {
if (!decodedJwt) return;
let contentToCopy = '';
if (activeTab === 'header') {
contentToCopy = formatJson(decodedJwt.header);
} else if (activeTab === 'payload') {
contentToCopy = formatJson(decodedJwt.payload);
} else {
contentToCopy = decodedJwt.signature;
}
navigator.clipboard.writeText(contentToCopy)
.then(() => {
setCopied(true);
setSuccess(t('tools.jwt_decoder.copied_content'));
setTimeout(() => {
setCopied(false);
setSuccess('');
}, 2000);
})
.catch(err => {
console.error(t('tools.jwt_decoder.copy_failed'), err);
setError(t('tools.jwt_decoder.clipboard_error'));
});
};
// 复制完整的JWT令牌
const copyFullToken = () => {
if (!jwtToken) return;
navigator.clipboard.writeText(jwtToken)
.then(() => {
setSuccess(t('tools.jwt_decoder.token_copied'));
setTimeout(() => setSuccess(''), 2000);
})
.catch(err => {
console.error(t('tools.jwt_decoder.copy_failed'), err);
setError(t('tools.jwt_decoder.clipboard_error'));
});
};
// 清空输入
const clearAll = () => {
setJwtToken('');
setDecodedJwt(null);
setError('');
setSuccess('');
};
// 加载示例JWT
const loadExample = () => {
// 示例JWT(过期时间设置为创建后的1小时)
const now = Math.floor(Date.now() / 1000);
const payload = {
sub: '1234567890',
name: t('tools.jwt_decoder.example_user'),
iat: now,
exp: now + 3600
};
// 创建示例token的header和payload部分
const header = { alg: 'HS256', typ: 'JWT' };
// 使用安全的方式转换为Base64URL
const headerB64 = base64UrlEncode(JSON.stringify(header));
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
// 签名部分用随机字符替代
const fakeSig = 'XqYSaj1HB9h0X5mJJwD9x_Z_U_Fel9YQcpP9ehZ0-0w';
// 完整示例JWT
const exampleJwt = `${headerB64}.${payloadB64}.${fakeSig}`;
setJwtToken(exampleJwt);
};
// Base64 URL编码(支持Unicode字符)
const base64UrlEncode = (str: string): string => {
// 将字符串转换为UTF-8编码的字节数组
const utf8Bytes = new TextEncoder().encode(str);
// 将字节数组转换为二进制字符串
let binaryStr = '';
utf8Bytes.forEach(byte => {
binaryStr += String.fromCharCode(byte);
});
// 使用btoa进行Base64编码,然后转换为URL安全的Base64
return btoa(binaryStr)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
};
// 获取过期状态的显示文本和类名
const getExpirationInfo = () => {
if (!decodedJwt || decodedJwt.expirationStatus === 'not-set') {
return {
text: t('tools.jwt_decoder.not_set'),
className: 'text-tertiary'
};
}
if (decodedJwt.expirationStatus === 'valid') {
return {
text: t('tools.jwt_decoder.valid_remaining').replace('{time}', decodedJwt.expiresIn || ''),
className: 'text-success'
};
} else {
return {
text: t('tools.jwt_decoder.expired'),
className: 'text-error'
};
}
};
// 获取JWT令牌各部分
const getTokenParts = () => {
if (!jwtToken) return null;
const parts = jwtToken.split('.');
if (parts.length !== 3) return null;
return {
header: parts[0],
payload: parts[1],
signature: parts[2]
};
};
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
<ToolHeader
toolCode="jwt_decoder"
icon={toolConfig?.icon || faLock}
title=""
description=""
/>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* 左侧面板 */}
<div className="lg:col-span-5">
<div className={styles.card}>
<h2 className="text-lg font-medium text-primary mb-4">{t('tools.jwt_decoder.decode_jwt')}</h2>
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<label className={styles.label}>{t('tools.jwt_decoder.jwt_token')}</label>
<button
className="text-xs text-tertiary hover:text-secondary"
onClick={() => setShowTokenParts(!showTokenParts)}
>
{showTokenParts ? t('tools.jwt_decoder.hide_parts') : t('tools.jwt_decoder.show_parts')}
</button>
</div>
<textarea
className={styles.textArea}
value={jwtToken}
onChange={(e) => setJwtToken(e.target.value)}
placeholder={t('tools.jwt_decoder.paste_jwt')}
/>
</div>
{/* 显示令牌分段 */}
{showTokenParts && (
<div className="space-y-2">
<div className="text-xs text-tertiary">{t('tools.jwt_decoder.token_structure')}</div>
<div className="flex flex-col gap-2 text-xs">
{getTokenParts() && (
<>
<div className="flex items-center gap-2">
<span className={`${styles.tokenPart} ${styles.headerPart}`}>{t('tools.jwt_decoder.header')}</span>
<span className="text-tertiary font-mono break-all">{getTokenParts()?.header}</span>
</div>
<div className="flex items-center gap-2">
<span className={`${styles.tokenPart} ${styles.payloadPart}`}>{t('tools.jwt_decoder.payload')}</span>
<span className="text-tertiary font-mono break-all">{getTokenParts()?.payload}</span>
</div>
<div className="flex items-center gap-2">
<span className={`${styles.tokenPart} ${styles.signaturePart}`}>{t('tools.jwt_decoder.signature')}</span>
<span className="text-tertiary font-mono break-all">{getTokenParts()?.signature}</span>
</div>
</>
)}
</div>
</div>
)}
{error && (
<div className={styles.error}>
{error}
</div>
)}
{success && (
<div className={styles.success}>
{success}
</div>
)}
<div className="flex flex-wrap gap-2">
<button className="btn-primary" onClick={loadExample}>
<FontAwesomeIcon icon={faSync} className="mr-2" />
{t('tools.jwt_decoder.load_example')}
</button>
<button className="btn-secondary" onClick={clearAll}>
<FontAwesomeIcon icon={faEraser} className="mr-2" />
{t('tools.jwt_decoder.clear')}
</button>
<button
className="btn-secondary"
onClick={copyFullToken}
disabled={!jwtToken}
>
<FontAwesomeIcon icon={faCopy} className="mr-2" />
{t('tools.jwt_decoder.copy_token')}
</button>
</div>
{decodedJwt && (
<div className="mt-6 space-y-4">
<div className="flex items-center justify-between">
<div className="text-secondary">{t('tools.jwt_decoder.jwt_status')}</div>
<div className={`px-2 py-1 rounded-md text-xs ${decodedJwt.isValid ? 'bg-green-900/10 text-success' : 'bg-red-900/10 text-error'}`}>
{decodedJwt.isValid ? t('tools.jwt_decoder.format_valid') : t('tools.jwt_decoder.format_invalid')}
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-secondary">{t('tools.jwt_decoder.expiration_status')}</div>
<div className={styles.statusBox(decodedJwt.expirationStatus)}>
{getExpirationInfo().text}
</div>
</div>
{decodedJwt.payload.iat && (
<div className="flex items-center justify-between">
<div className="text-secondary">{t('tools.jwt_decoder.issue_time')}</div>
<div className="text-xs text-tertiary">
{new Date(decodedJwt.payload.iat * 1000).toLocaleString()}
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* 右侧面板 */}
<div className="lg:col-span-7">
{decodedJwt ? (
<div className={styles.card}>
<div className="border-b border-purple-glow/30 mb-4">
<div className="flex">
<button
className={styles.tabButton(activeTab === 'header')}
onClick={() => setActiveTab('header')}
>
{t('tools.jwt_decoder.header_tab')}
</button>
<button
className={styles.tabButton(activeTab === 'payload')}
onClick={() => setActiveTab('payload')}
>
{t('tools.jwt_decoder.payload_tab')}
</button>
<button
className={styles.tabButton(activeTab === 'signature')}
onClick={() => setActiveTab('signature')}
>
{t('tools.jwt_decoder.signature_tab')}
</button>
</div>
</div>
<div className="mb-4 flex justify-end">
<button
className={styles.copyButton}
onClick={copyToClipboard}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
{copied ? t('tools.jwt_decoder.copied') : t('tools.jwt_decoder.copy')}
</button>
</div>
<pre className={styles.preArea}>
{activeTab === 'header' && formatJson(decodedJwt.header)}
{activeTab === 'payload' && formatJson(decodedJwt.payload)}
{activeTab === 'signature' && decodedJwt.signature}
</pre>
</div>
) : (
<div className={styles.card}>
<h3 className={styles.heading}>{t('tools.jwt_decoder.what_is_jwt')}</h3>
<div className="flex items-center gap-2 mb-4">
<FontAwesomeIcon icon={faInfoCircle} className="text-purple" />
<span className="text-secondary">{t('tools.jwt_decoder.jwt_full_name')}</span>
</div>
<p className={styles.secondaryText}>
{t('tools.jwt_decoder.jwt_intro')}
</p>
<p className={styles.secondaryText}>
{t('tools.jwt_decoder.jwt_parts')}
</p>
<ul className="list-disc pl-5 text-sm text-tertiary">
<li><span className={styles.highlight}>Header</span> - {t('tools.jwt_decoder.header_desc')}</li>
<li><span className={styles.highlight}>Payload</span> - {t('tools.jwt_decoder.payload_desc')}</li>
<li><span className={styles.highlight}>Signature</span> - {t('tools.jwt_decoder.signature_desc')}</li>
</ul>
<p className={styles.secondaryText}>
{t('tools.jwt_decoder.tool_purpose')}
</p>
<div className="mt-4 p-3 bg-block-strong rounded-lg">
<h4 className="text-sm font-medium text-secondary mb-2">{t('tools.jwt_decoder.common_use_cases')}</h4>
<ul className="list-disc pl-5 text-sm text-tertiary">
<li>{t('tools.jwt_decoder.use_case_1')}</li>
<li>{t('tools.jwt_decoder.use_case_2')}</li>
<li>{t('tools.jwt_decoder.use_case_3')}</li>
<li>{t('tools.jwt_decoder.use_case_4')}</li>
<li>{t('tools.jwt_decoder.use_case_5')}</li>
</ul>
</div>
</div>
)}
</div>
</div>
<BackToTop />
</div>
);
}
+514
View File
@@ -0,0 +1,514 @@
'use client';
import React, { useState, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faImage, faCopy, faCheck, faDownload, faTrash, faRedo } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import { QRCode } from 'react-qrcode-logo';
import { useLanguage } from '@/context/LanguageContext';
// 定义QR码样式类型
type QRStyle = 'squares' | 'dots';
// 定义颜色预设
const colorPresets = [
{ bg: '#FFFFFF', fg: '#000000', name: 'preset_classic_bw' },
{ bg: '#0088CC', fg: '#FFFFFF', name: 'preset_blue_white' },
{ bg: '#EF4444', fg: '#FFFFFF', name: 'preset_vibrant_red' },
{ bg: '#10B981', fg: '#FFFFFF', name: 'preset_fresh_green' },
{ bg: '#6366F1', fg: '#FFFFFF', name: 'preset_tech_purple' },
{ bg: '#262626', fg: '#F5F5F5', name: 'preset_dark_mode' },
{ bg: '#FFFFFF', fg: '#F97316', name: 'preset_orange_accent' },
{ bg: '#FFEDD5', fg: '#7C2D12', name: 'preset_warm_brown' },
];
// 添加CSS变量样式
const styles = {
card: "card p-4",
heading: "text-md font-medium text-primary mb-4",
label: "block text-sm text-secondary mb-2",
input: "search-input w-full",
rangeValue: "text-sm text-primary min-w-[40px] text-right",
buttonActive: "px-4 py-2 rounded-md transition-all bg-gradient-to-r from-[rgb(var(--color-primary))] to-[rgb(var(--color-primary-hover))] text-white shadow-sm shadow-[rgba(var(--color-primary),0.3)]",
buttonInactive: "px-4 py-2 rounded-md transition-all btn-secondary",
presetButton: "p-2 rounded-md border transition-all hover:border-purple",
colorBox: "w-5 h-5 rounded-sm border border-purple-glow/30",
secondaryText: "text-sm text-tertiary",
flexCenter: "flex items-center justify-center",
preview: "rounded-xl overflow-hidden border border-purple-glow/20 bg-block-strong p-2",
placeholder: "flex items-center justify-center p-4 text-tertiary",
}
export default function QRCodeGenerator() {
const { t } = useLanguage();
// QR码内容和样式状态
const [value, setValue] = useState('https://example.com');
const [size, setSize] = useState(200);
const [bgColor, setBgColor] = useState('#FFFFFF');
const [fgColor, setFgColor] = useState('#000000');
const [quietZone, setQuietZone] = useState(10);
const [qrStyle, setQrStyle] = useState<QRStyle>('squares');
// Logo相关状态
const [logoImage, setLogoImage] = useState<string | null>(null);
const [logoWidth, setLogoWidth] = useState(60);
const [logoHeight, setLogoHeight] = useState(60);
const [logoOpacity, setLogoOpacity] = useState(1);
const [removeQrCodeBehindLogo, setRemoveQrCodeBehindLogo] = useState(true);
// 眼睛(定位图案)相关状态
const [eyeColor, setEyeColor] = useState('#000000');
const [eyeRadius, setEyeRadius] = useState(0);
// 其他状态
const [copied, setCopied] = useState(false);
const [downloading, setDownloading] = useState(false);
// QR码引用
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const qrCodeRef = useRef<any>(null);
// 文件上传引用
const fileInputRef = useRef<HTMLInputElement>(null);
// 处理Logo图片上传
const handleLogoUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result as string;
setLogoImage(result);
};
reader.readAsDataURL(file);
}
};
// 触发文件上传点击
const triggerLogoUpload = () => {
fileInputRef.current?.click();
};
// 移除Logo
const removeLogo = () => {
setLogoImage(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
// 复制QR码内容
const copyQRValue = () => {
navigator.clipboard.writeText(value)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => console.error(t('tools.qrcode_generator.copy_failed'), err));
};
// 下载QR码图片
const downloadQRCode = () => {
if (qrCodeRef.current) {
setDownloading(true);
try {
qrCodeRef.current.download('png', '二维码');
} catch (err) {
console.error(t('tools.qrcode_generator.download_failed'), err);
} finally {
setDownloading(false);
}
}
};
// 应用颜色预设
const applyColorPreset = (preset: { bg: string; fg: string }) => {
setBgColor(preset.bg);
setFgColor(preset.fg);
setEyeColor(preset.fg);
};
// 重置所有设置
const resetSettings = () => {
setValue('https://example.com');
setSize(200);
setBgColor('#FFFFFF');
setFgColor('#000000');
setQuietZone(10);
setQrStyle('squares');
setLogoImage(null);
setLogoWidth(60);
setLogoHeight(60);
setLogoOpacity(1);
setRemoveQrCodeBehindLogo(true);
setEyeColor('#000000');
setEyeRadius(0);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
<ToolHeader
toolCode="qrcode_generator"
icon={faImage}
title=""
description=""
/>
{/* 主内容区 */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* 左侧面板 - 设置选项 */}
<div className="lg:col-span-2 space-y-6">
{/* 基本设置 */}
<div className={styles.card}>
<h2 className={styles.heading}>{t('tools.qrcode_generator.basic_settings')}</h2>
<div className="space-y-4">
<div>
<label className={styles.label}>{t('tools.qrcode_generator.qrcode_content')}</label>
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={t('tools.qrcode_generator.input_placeholder')}
className="search-input min-h-[80px] w-full resize-y"
/>
</div>
<div>
<label className={styles.label}>{t('tools.qrcode_generator.size_pixels')}</label>
<div className="flex items-center gap-4">
<input
type="range"
min="100"
max="400"
step="10"
value={size}
onChange={(e) => setSize(Number(e.target.value))}
className="w-full"
/>
<span className={styles.rangeValue}>{size}</span>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.qrcode_generator.margin_pixels')}</label>
<div className="flex items-center gap-4">
<input
type="range"
min="0"
max="50"
step="5"
value={quietZone}
onChange={(e) => setQuietZone(Number(e.target.value))}
className="w-full"
/>
<span className={styles.rangeValue}>{quietZone}</span>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.qrcode_generator.dot_style')}</label>
<div className="flex gap-4">
<button
className={qrStyle === 'squares' ? styles.buttonActive : styles.buttonInactive}
onClick={() => setQrStyle('squares')}
>
{t('tools.qrcode_generator.squares')}
</button>
<button
className={qrStyle === 'dots' ? styles.buttonActive : styles.buttonInactive}
onClick={() => setQrStyle('dots')}
>
{t('tools.qrcode_generator.dots')}
</button>
</div>
</div>
</div>
</div>
{/* 颜色设置 */}
<div className={styles.card}>
<h2 className={styles.heading}>{t('tools.qrcode_generator.color_settings')}</h2>
<div className="space-y-4">
<div>
<label className={styles.label}>{t('tools.qrcode_generator.background_color')}</label>
<div className="flex items-center gap-2">
<input
type="color"
value={bgColor}
onChange={(e) => setBgColor(e.target.value)}
className="w-10 h-10 rounded cursor-pointer border-none"
/>
<input
type="text"
value={bgColor}
onChange={(e) => setBgColor(e.target.value)}
className="search-input"
/>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.qrcode_generator.foreground_color')}</label>
<div className="flex items-center gap-2">
<input
type="color"
value={fgColor}
onChange={(e) => setFgColor(e.target.value)}
className="w-10 h-10 rounded cursor-pointer border-none"
/>
<input
type="text"
value={fgColor}
onChange={(e) => setFgColor(e.target.value)}
className="search-input"
/>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.qrcode_generator.eye_color')}</label>
<div className="flex items-center gap-2">
<input
type="color"
value={eyeColor}
onChange={(e) => setEyeColor(e.target.value)}
className="w-10 h-10 rounded cursor-pointer border-none"
/>
<input
type="text"
value={eyeColor}
onChange={(e) => setEyeColor(e.target.value)}
className="search-input"
/>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.qrcode_generator.eye_radius')}</label>
<div className="flex items-center gap-4">
<input
type="range"
min="0"
max="50"
step="5"
value={eyeRadius}
onChange={(e) => setEyeRadius(Number(e.target.value))}
className="w-full"
/>
<span className={styles.rangeValue}>{eyeRadius}%</span>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.qrcode_generator.preset_colors')}</label>
<div className="grid grid-cols-4 gap-2 mt-2">
{colorPresets.map((preset, index) => (
<button
key={index}
className={styles.presetButton}
onClick={() => applyColorPreset(preset)}
title={t(`tools.qrcode_generator.${preset.name}`)}
>
<div className="flex flex-col items-center">
<div
className={styles.colorBox}
style={{ backgroundColor: preset.fg, borderColor: preset.bg }}
></div>
<div className="text-xs mt-1 truncate w-full text-center">{t(`tools.qrcode_generator.${preset.name}`)}</div>
</div>
</button>
))}
</div>
</div>
</div>
</div>
{/* Logo设置 */}
<div className={styles.card}>
<h2 className={styles.heading}>{t('tools.qrcode_generator.logo_settings')}</h2>
<div className="space-y-4">
<div>
<input
type="file"
ref={fileInputRef}
accept="image/*"
onChange={handleLogoUpload}
style={{ display: 'none' }}
/>
<div className="flex justify-between items-center mb-2">
<button
className="btn-primary px-3 py-2 text-sm"
onClick={triggerLogoUpload}
>
<FontAwesomeIcon icon={faImage} className="mr-2" />
{t('tools.qrcode_generator.upload_logo')}
</button>
{logoImage && (
<button
className="btn-secondary px-3 py-2 text-sm text-error"
onClick={removeLogo}
>
<FontAwesomeIcon icon={faTrash} className="mr-2" />
{t('tools.qrcode_generator.remove')}
</button>
)}
</div>
{logoImage && (
<div className="rounded bg-block-strong p-3 mb-4 flex justify-center items-center">
<img src={logoImage} alt="Logo" className="max-h-20 max-w-full" />
</div>
)}
</div>
{logoImage && (
<>
<div>
<label className={styles.label}>{t('tools.qrcode_generator.logo_width')}</label>
<div className="flex items-center gap-4">
<input
type="range"
min="20"
max="150"
value={logoWidth}
onChange={(e) => setLogoWidth(Number(e.target.value))}
className="w-full"
/>
<span className={styles.rangeValue}>{logoWidth}</span>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.qrcode_generator.logo_height')}</label>
<div className="flex items-center gap-4">
<input
type="range"
min="20"
max="150"
value={logoHeight}
onChange={(e) => setLogoHeight(Number(e.target.value))}
className="w-full"
/>
<span className={styles.rangeValue}>{logoHeight}</span>
</div>
</div>
<div>
<label className={styles.label}>{t('tools.qrcode_generator.logo_opacity')}</label>
<div className="flex items-center gap-4">
<input
type="range"
min="0"
max="1"
step="0.1"
value={logoOpacity}
onChange={(e) => setLogoOpacity(Number(e.target.value))}
className="w-full"
/>
<span className={styles.rangeValue}>{Math.round(logoOpacity * 100)}%</span>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="removeQrCodeBehindLogo"
checked={removeQrCodeBehindLogo}
onChange={(e) => setRemoveQrCodeBehindLogo(e.target.checked)}
className="form-checkbox"
/>
<label htmlFor="removeQrCodeBehindLogo" className="text-sm cursor-pointer">
{t('tools.qrcode_generator.remove_code_behind_logo')}
</label>
</div>
</>
)}
</div>
</div>
</div>
{/* 右侧面板 - 预览和说明 */}
<div className="lg:col-span-2 space-y-6">
<div className={styles.card}>
<div className="flex justify-between items-center mb-4">
<h2 className={styles.heading}>{t('tools.qrcode_generator.preview')}</h2>
<div className="flex space-x-2">
<button
onClick={copyQRValue}
className="text-tertiary hover:text-purple transition-colors"
title={t('tools.qrcode_generator.copy_content')}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
</button>
<button
onClick={downloadQRCode}
className="text-tertiary hover:text-purple transition-colors"
title={t('tools.qrcode_generator.download_qrcode')}
disabled={downloading}
>
<FontAwesomeIcon icon={faDownload} className={downloading ? 'animate-pulse' : ''} />
</button>
<button
onClick={resetSettings}
title={t('tools.qrcode_generator.reset_settings')}
className="text-tertiary hover:text-purple transition-colors"
>
<FontAwesomeIcon icon={faRedo} />
</button>
</div>
</div>
<div className={styles.preview}>
{value ? (
<div className={styles.flexCenter}>
<QRCode
ref={qrCodeRef}
value={value}
size={size}
quietZone={quietZone}
bgColor={bgColor}
fgColor={fgColor}
logoImage={logoImage || undefined}
logoWidth={logoWidth}
logoHeight={logoHeight}
logoOpacity={logoOpacity}
removeQrCodeBehindLogo={removeQrCodeBehindLogo}
eyeColor={eyeColor}
eyeRadius={eyeRadius}
qrStyle={qrStyle}
logoPadding={0}
ecLevel="H"
/>
</div>
) : (
<div className={styles.placeholder}>{t('tools.qrcode_generator.please_input_content')}</div>
)}
</div>
</div>
<div className={styles.card}>
<h2 className={styles.heading}>{t('tools.qrcode_generator.instructions')}</h2>
<div className="space-y-2 text-secondary text-sm">
<p>{t('tools.qrcode_generator.instruction_1')}</p>
<p>{t('tools.qrcode_generator.instruction_2')}</p>
<p>{t('tools.qrcode_generator.instruction_3')}</p>
<p>{t('tools.qrcode_generator.instruction_4')}</p>
</div>
<p className={styles.secondaryText}>{t('tools.qrcode_generator.note')}</p>
</div>
</div>
</div>
</div>
);
}
+426
View File
@@ -0,0 +1,426 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faKey, faCopy, faCheck, faExclamationTriangle, faInfoCircle, faTrash } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
card: "card p-4",
input: "search-input w-full",
secondaryBtn: "btn-secondary",
primaryText: "text-primary",
secondaryText: "text-secondary",
tertiaryText: "text-tertiary",
block: "bg-block",
codeBlock: "bg-block px-1 rounded",
iconButton: "text-tertiary hover:text-purple transition-colors",
selectedFlag: "bg-purple-glow/20 text-purple",
buttonActive: "px-3 py-1 text-xs rounded-md transition-all bg-gradient-to-r from-[rgb(var(--color-primary))] to-[rgb(var(--color-primary-hover))] text-white shadow-sm shadow-[rgba(var(--color-primary),0.3)]",
buttonInactive: "px-3 py-1 text-xs rounded-md transition-all btn-secondary",
error: "text-error",
highlightBg: "bg-purple-500/30 text-white font-medium",
}
// 定义匹配项类型
type MatchResult = RegExpExecArray;
type MatchResultArray = MatchResult[];
export default function RegexTester() {
const { t } = useLanguage();
// 正则表达式输入
const [regexString, setRegexString] = useState('');
const [flags, setFlags] = useState('g');
const [testString, setTestString] = useState('');
// 测试结果
const [matches, setMatches] = useState<MatchResultArray>([]);
const [matchCount, setMatchCount] = useState(0);
// 高级选项
const [showGroups, setShowGroups] = useState(true);
const [regexError, setRegexError] = useState<string | null>(null);
// 复制状态
const [copiedRegex, setCopiedRegex] = useState(false);
// 安全处理HTML转义
const escapeHtml = (text: string) => {
if (text === undefined || text === null) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
// 常用正则表达式示例
const examples = [
{ name: t('tools.regex_tester.examples.email'), pattern: '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', flags: 'g', testText: '[email protected], invalid-email, [email protected]' },
{ name: t('tools.regex_tester.examples.phone'), pattern: '1[3-9]\\d{9}', flags: 'g', testText: t('tools.regex_tester.example_texts.phone') },
{ name: t('tools.regex_tester.examples.url'), pattern: 'https?://[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&//=]*)', flags: 'g', testText: t('tools.regex_tester.example_texts.url') },
{ name: t('tools.regex_tester.examples.ip'), pattern: '\\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\b', flags: 'g', testText: t('tools.regex_tester.example_texts.ip') },
{ name: t('tools.regex_tester.examples.chinese'), pattern: '[\\u4e00-\\u9fa5]', flags: 'g', testText: t('tools.regex_tester.example_texts.chinese') },
];
// 当输入改变时更新测试结果
useEffect(() => {
testRegex();
}, [regexString, flags, testString, showGroups]);
// 测试正则表达式
const testRegex = () => {
if (!regexString || !testString) {
setMatches([]);
setMatchCount(0);
setRegexError(null);
return;
}
try {
// 验证正则表达式是否有效
new RegExp(regexString, flags);
setRegexError(null);
if (flags.includes('g')) {
// 获取所有匹配
const allMatches: MatchResultArray = [];
let match: RegExpExecArray | null;
const regexWithGroups = new RegExp(regexString, flags);
// 收集所有匹配和捕获组
while ((match = regexWithGroups.exec(testString)) !== null) {
allMatches.push(match);
// 防止无限循环,如果匹配长度为0,手动增加索引
if (match.index === regexWithGroups.lastIndex) {
regexWithGroups.lastIndex++;
}
}
setMatches(allMatches);
setMatchCount(allMatches.length);
} else {
// 单次匹配模式
const regexWithoutG = new RegExp(regexString, flags.replace('g', ''));
const execMatch = regexWithoutG.exec(testString);
if (execMatch) {
setMatches([execMatch]);
setMatchCount(1);
} else {
setMatches([]);
setMatchCount(0);
}
}
} catch (error) {
console.error(t('tools.regex_tester.regex_error'), error);
setRegexError((error as Error).message);
setMatches([]);
setMatchCount(0);
}
};
// 复制正则表达式
const copyRegex = () => {
const regexText = `/${regexString}/${flags}`;
navigator.clipboard.writeText(regexText)
.then(() => {
setCopiedRegex(true);
setTimeout(() => setCopiedRegex(false), 2000);
})
.catch(err => console.error(t('tools.regex_tester.copy_failed'), err));
};
// 应用示例
const applyExample = (example: { pattern: string; flags: string; testText: string }) => {
setRegexString(example.pattern);
setFlags(example.flags);
setTestString(example.testText);
};
// 清空所有内容
const clearAll = () => {
setRegexString('');
setFlags('g');
setTestString('');
setMatches([]);
setMatchCount(0);
setRegexError(null);
};
// 切换标志位
const toggleFlag = (flag: string) => {
if (flags.includes(flag)) {
setFlags(flags.replace(flag, ''));
} else {
setFlags(flags + flag);
}
};
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
<ToolHeader
toolCode="regex_tester"
title=""
description=""
icon={faKey}
/>
{/* 主内容区 */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* 左侧面板 - 正则表达式输入和选项 */}
<div className="lg:col-span-1 space-y-6">
{/* 常用示例 */}
<div className={styles.card}>
<h2 className="text-md font-medium text-primary mb-4">{t('tools.regex_tester.examples.title')}</h2>
<div className="space-y-2">
{examples.map((example, index) => (
<button
key={index}
className="text-left w-full px-3 py-2 rounded-md text-sm text-secondary hover:bg-block-hover transition-colors"
onClick={() => applyExample(example)}
>
{example.name}
</button>
))}
</div>
</div>
{/* 正则选项 */}
<div className={styles.card}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-md font-medium text-primary">{t('tools.regex_tester.options')}</h2>
<button
className="text-tertiary hover:text-error transition-colors"
onClick={clearAll}
>
<FontAwesomeIcon icon={faTrash} className="mr-1" />
{t('tools.regex_tester.clear')}
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm text-secondary mb-2">{t('tools.regex_tester.flags')}</label>
<div className="flex flex-wrap gap-2">
<button
className={flags.includes('g') ? styles.buttonActive : styles.buttonInactive}
onClick={() => toggleFlag('g')}
>
g ({t('tools.regex_tester.flag_descriptions.global')})
</button>
<button
className={flags.includes('i') ? styles.buttonActive : styles.buttonInactive}
onClick={() => toggleFlag('i')}
>
i ({t('tools.regex_tester.flag_descriptions.case_insensitive')})
</button>
<button
className={flags.includes('m') ? styles.buttonActive : styles.buttonInactive}
onClick={() => toggleFlag('m')}
>
m ({t('tools.regex_tester.flag_descriptions.multiline')})
</button>
<button
className={flags.includes('s') ? styles.buttonActive : styles.buttonInactive}
onClick={() => toggleFlag('s')}
>
s ({t('tools.regex_tester.flag_descriptions.dotall')})
</button>
</div>
</div>
<div>
<label className="flex items-center text-sm text-secondary">
<input
type="checkbox"
checked={showGroups}
onChange={() => setShowGroups(!showGroups)}
className="mr-2"
/>
{t('tools.regex_tester.show_capture_groups')}
</label>
</div>
</div>
</div>
</div>
{/* 右侧面板 - 测试区域 */}
<div className="lg:col-span-3 space-y-6">
{/* 正则表达式输入 */}
<div className={styles.card}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-md font-medium text-primary">{t('tools.regex_tester.regex_expression')}</h2>
<button
className={styles.iconButton}
onClick={copyRegex}
disabled={!regexString}
>
<FontAwesomeIcon icon={copiedRegex ? faCheck : faCopy} className="mr-1" />
{copiedRegex ? t('common.copySuccess') : t('tools.regex_tester.copy')}
</button>
</div>
<div className="relative">
<div className="absolute left-3 top-[13px] text-tertiary">/</div>
<input
type="text"
value={regexString}
onChange={(e) => setRegexString(e.target.value)}
placeholder={t('tools.regex_tester.enter_regex')}
className="search-input pl-7 pr-14"
/>
<div className="absolute right-14 top-[13px] text-tertiary">/</div>
<input
type="text"
value={flags}
onChange={(e) => setFlags(e.target.value)}
placeholder="flags"
className="absolute right-3 top-[13px] w-8 bg-transparent border-none outline-none text-tertiary"
/>
</div>
{regexError && (
<div className="mt-2 text-sm text-error flex items-center gap-2">
<FontAwesomeIcon icon={faExclamationTriangle} />
<span>{regexError}</span>
</div>
)}
</div>
{/* 测试输入 */}
<div className={styles.card}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-md font-medium text-primary">{t('tools.regex_tester.test_text')}</h2>
<div className="text-sm text-secondary">
{t('tools.regex_tester.character_count')}: <span className="text-purple">{testString.length}</span>
</div>
</div>
<textarea
value={testString}
onChange={(e) => setTestString(e.target.value)}
placeholder={t('tools.regex_tester.enter_test_text')}
className="search-input min-h-[150px] w-full resize-y"
/>
</div>
{/* 匹配结果 */}
<div className={styles.card}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-md font-medium text-primary">{t('tools.regex_tester.match_results')}</h2>
<div className="text-sm text-secondary">
{t('tools.regex_tester.match_count')}: <span className="text-purple">{matchCount}</span>
</div>
</div>
{testString && (
<div className="space-y-4">
{/* 高亮显示的匹配文本 */}
<div className="bg-block-strong rounded-md p-4 whitespace-pre-wrap font-mono text-sm">
{testString && (
<div className="match-results">
{matchCount > 0 ? (
<>
<div className="mb-3 text-tertiary text-xs flex items-center justify-between">
<span>
{t('tools.regex_tester.found')} <span className="text-purple font-medium">{matchCount}</span> {t('tools.regex_tester.matches')}
</span>
<span className="text-tertiary text-xs">
{t('tools.regex_tester.original_text_length')}: {testString.length} {t('tools.regex_tester.result_characters')}
</span>
</div>
{(() => {
// 创建包含高亮的全文显示
let result = '';
let lastIndex = 0;
// 按索引顺序排序匹配项
const sortedMatches = [...matches].sort((a, b) => a.index - b.index);
// 遍历每个匹配项
sortedMatches.forEach(match => {
// 添加匹配前的文本
result += escapeHtml(testString.substring(lastIndex, match.index));
// 添加高亮的匹配内容
result += `<span style="background-color:rgba(139, 92, 246, 0.5); color:white; font-weight:bold; padding:0 4px; border-radius:3px;">${escapeHtml(match[0])}</span>`;
// 更新lastIndex
lastIndex = match.index + match[0].length;
});
// 添加最后一个匹配后的文本
if (lastIndex < testString.length) {
result += escapeHtml(testString.substring(lastIndex));
}
return <div dangerouslySetInnerHTML={{ __html: result }} />;
})()}
</>
) : (
<span className="text-tertiary">{t('tools.regex_tester.no_matches')}</span>
)}
</div>
)}
{!testString && (
<span className="text-tertiary">{t('tools.regex_tester.no_matches')}</span>
)}
</div>
{/* 捕获组详情 */}
{showGroups && matchCount > 0 && (
<div>
<h3 className="text-sm font-medium text-primary mb-2">{t('tools.regex_tester.capture_groups')}</h3>
<div className="space-y-2">
{Array.isArray(matches) && matches.map((match, index) => (
<div key={index} className="bg-block-strong rounded-md p-3">
<div className="text-xs text-tertiary mb-2">
{t('tools.regex_tester.match')} #{index + 1} ({t('tools.regex_tester.position')}: {match.index})
</div>
<div className="space-y-1">
{match.length > 0 && (
<div className="flex items-start gap-2">
<span className="text-xs text-tertiary min-w-[40px]">{t('tools.regex_tester.full')}:</span>
<code className="text-sm text-primary bg-purple-glow/10 px-1 rounded break-all">
{escapeHtml(match[0] || '')}
</code>
</div>
)}
{match.length > 1 && Array.from({ length: match.length - 1 }, (_, i) => i + 1).map(group => (
<div key={group} className="flex items-start gap-2">
<span className="text-xs text-tertiary min-w-[40px]">{t('tools.regex_tester.group')} {group}:</span>
<code className="text-sm text-primary bg-purple-glow/10 px-1 rounded break-all">
{match[group] ? escapeHtml(match[group]) : t('tools.regex_tester.empty')}
</code>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{!testString && (
<div className="flex items-center justify-center p-4 text-tertiary">
<FontAwesomeIcon icon={faInfoCircle} className="mr-2" />
{t('tools.regex_tester.enter_text_prompt')}
</div>
)}
</div>
</div>
</div>
</div>
);
}
+282
View File
@@ -0,0 +1,282 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalculator, faCopy, faCheck, faTrash, faInfoCircle } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
card: "card p-6",
input: "search-input w-full",
textarea: "w-full p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-[#6366F1] focus:outline-none focus:ring-1 focus:ring-[#6366F1] transition-all resize-none",
label: "text-secondary font-medium",
secondaryText: "text-sm text-tertiary",
resultItem: "flex justify-between items-center py-2 border-b border-purple-glow/10",
resultLabel: "text-sm text-secondary",
resultValue: "text-sm text-primary font-semibold",
iconButton: "text-tertiary hover:text-purple transition-colors",
button: "text-left w-full px-3 py-2 rounded-md text-sm text-secondary hover:bg-block-hover transition-colors",
}
// 字数统计结果类型
interface CountResult {
characters: number;
charactersNoSpaces: number;
words: number;
chineseWords: number;
englishWords: number;
sentences: number;
paragraphs: number;
lines: number;
chineseCharacters: number;
}
export default function TextCounter() {
const { t } = useLanguage();
// 输入文本
const [text, setText] = useState('');
// 统计结果
const [counts, setCounts] = useState<CountResult>({
characters: 0,
charactersNoSpaces: 0,
words: 0,
chineseWords: 0,
englishWords: 0,
sentences: 0,
paragraphs: 0,
lines: 0,
chineseCharacters: 0
});
// 复制状态
const [copied, setCopied] = useState(false);
// 文本变化时更新统计结果
useEffect(() => {
countText(text);
}, [text]);
// 统计文本
const countText = (value: string) => {
if (!value) {
setCounts({
characters: 0,
charactersNoSpaces: 0,
words: 0,
chineseWords: 0,
englishWords: 0,
sentences: 0,
paragraphs: 0,
lines: 0,
chineseCharacters: 0
});
return;
}
// 总字符数
const characters = value.length;
// 不含空格的字符数
const charactersNoSpaces = value.replace(/\s/g, '').length;
// 中文字符数
const chineseCharacters = (value.match(/[\u4e00-\u9fa5]/g) || []).length;
// 英文单词数
const englishWords = value.match(/[a-zA-Z]+/g)?.length || 0;
// 中文词数 (根据标点符号和空格分隔)
const chineseText = value.match(/[\u4e00-\u9fa5]+/g)?.join('') || '';
// 中文大约每2个字一个词
const chineseWords = Math.ceil(chineseText.length / 2);
// 总词数 (简单估算)
const words = englishWords + chineseWords;
// 句子数 (根据句号、问号、感叹号统计)
const sentences = (value.match(/[.!?。!?]+/g) || []).length || (value.length > 0 ? 1 : 0);
// 段落数 (根据空行分隔)
const paragraphs = value.split(/\n\s*\n/).filter(Boolean).length || (value.length > 0 ? 1 : 0);
// 行数
const lines = value.split('\n').length;
setCounts({
characters,
charactersNoSpaces,
words,
chineseWords,
englishWords,
sentences,
paragraphs,
lines,
chineseCharacters
});
};
// 复制统计结果
const copyResults = () => {
const resultText = t('tools.text_counter.copy_result_text')
.replace('{characters}', counts.characters.toString())
.replace('{charactersNoSpaces}', counts.charactersNoSpaces.toString())
.replace('{chineseCharacters}', counts.chineseCharacters.toString())
.replace('{words}', counts.words.toString())
.replace('{chineseWords}', counts.chineseWords.toString())
.replace('{englishWords}', counts.englishWords.toString())
.replace('{sentences}', counts.sentences.toString())
.replace('{paragraphs}', counts.paragraphs.toString())
.replace('{lines}', counts.lines.toString());
navigator.clipboard.writeText(resultText)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => console.error(t('tools.text_counter.copy_failed'), err));
};
// 清空输入
const clearText = () => {
setText('');
};
// 加载示例文本
const loadExample = (type: 'chinese' | 'english') => {
if (type === 'chinese') {
setText('这是一个中文文本示例。\n\n这是第二段落,包含了一些中文内容。这个工具可以统计文本中的字符数、词数和其他信息。\n\n第三段落结束。');
} else {
setText('This is an English text example.\n\nThis is the second paragraph, containing English content. This tool can count characters, words, and other information in the text.\n\nThe third paragraph ends here.');
}
};
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
<ToolHeader
toolCode="text_counter"
icon={faCalculator}
title=""
description=""
/>
{/* 主内容区 */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-6">
{/* 统计结果 */}
<div className="md:col-span-4 space-y-6 order-2 md:order-1">
<div className={styles.card}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-md font-medium text-primary">{t('tools.text_counter.statistics_results')}</h2>
<button
className={styles.iconButton}
onClick={copyResults}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} className="mr-1" />
{copied ? t('tools.text_counter.copied') : t('tools.text_counter.copy_results')}
</button>
</div>
<div className="space-y-1">
{/* 字符统计 */}
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.text_counter.statistics.total_characters')}</span>
<span className={styles.resultValue}>{counts.characters}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.text_counter.statistics.characters_no_spaces')}</span>
<span className={styles.resultValue}>{counts.charactersNoSpaces}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.text_counter.statistics.chinese_characters')}</span>
<span className={styles.resultValue}>{counts.chineseCharacters}</span>
</div>
{/* 单词统计 */}
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.text_counter.statistics.total_words')}</span>
<span className={styles.resultValue}>{counts.words}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.text_counter.statistics.chinese_words')}</span>
<span className={styles.resultValue}>{counts.chineseWords}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.text_counter.statistics.english_words')}</span>
<span className={styles.resultValue}>{counts.englishWords}</span>
</div>
{/* 其他统计 */}
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.text_counter.statistics.sentences')}</span>
<span className={styles.resultValue}>{counts.sentences}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.text_counter.statistics.paragraphs')}</span>
<span className={styles.resultValue}>{counts.paragraphs}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>{t('tools.text_counter.statistics.lines')}</span>
<span className={styles.resultValue}>{counts.lines}</span>
</div>
</div>
</div>
<div className={styles.card}>
<h2 className="text-md font-medium text-primary mb-4">{t('tools.text_counter.tool_options')}</h2>
<div className="space-y-2">
<button
className={styles.button}
onClick={() => loadExample('chinese')}
>
{t('tools.text_counter.load_chinese_example')}
</button>
<button
className={styles.button}
onClick={() => loadExample('english')}
>
{t('tools.text_counter.load_english_example')}
</button>
</div>
</div>
</div>
{/* 文本输入 */}
<div className="md:col-span-8 order-1 md:order-2">
<div className={styles.card}>
<h2 className="text-md font-medium text-primary mb-4">{t('tools.text_counter.input_text')}</h2>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={t('tools.text_counter.input_placeholder')}
className={styles.textarea}
rows={20}
/>
<div className="flex justify-end mt-4">
<button
className="btn-secondary"
onClick={clearText}
>
<FontAwesomeIcon icon={faTrash} className="mr-2" />
{t('tools.text_counter.clear')}
</button>
</div>
</div>
{!text && (
<div className="flex items-center justify-center p-4 mt-4 text-tertiary">
<FontAwesomeIcon icon={faInfoCircle} className="mr-2" />
{t('tools.text_counter.empty_notice')}
</div>
)}
</div>
</div>
</div>
);
}
+360
View File
@@ -0,0 +1,360 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faClock, faExchangeAlt, faSync, faCopy, faCheck } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
card: "card p-6",
input: "search-input w-full",
secondaryBtn: "btn-secondary text-xs px-3 py-1",
primaryText: "text-primary",
secondaryText: "text-secondary",
tertiaryText: "text-tertiary",
formattedBlock: "p-2 bg-block rounded-md border border-purple-glow w-full",
codeBlock: "bg-block px-1 rounded",
iconButton: "text-tertiary hover:text-purple transition-colors",
swapBtn: "text-tertiary hover:text-purple transition-colors p-3 rounded-full hover:bg-purple-glow/10",
}
export default function TimestampConverter() {
const { t } = useLanguage();
// 当前系统时间
const [currentTime, setCurrentTime] = useState(new Date());
// 时间戳和日期时间的状态
const [timestamp, setTimestamp] = useState('');
const [dateTime, setDateTime] = useState('');
const [formattedDateTime, setFormattedDateTime] = useState('');
// 是否交换位置
const [swapped, setSwapped] = useState(false);
// 常用时间格式列表
const [commonTimestamps, setCommonTimestamps] = useState<{ label: string; value: number }[]>([]);
// 复制状态
const [copiedTimestamp, setCopiedTimestamp] = useState(false);
const [copiedDateTime, setCopiedDateTime] = useState(false);
// 添加初始化标记
const [isInitialized, setIsInitialized] = useState(false);
// 首次加载时初始化数据
useEffect(() => {
if (!isInitialized) {
const now = new Date();
// 只初始化当前时间和常用时间戳列表,不设置输入框的初始值
setCurrentTime(now);
updateCommonTimestamps(now);
// 标记为已初始化
setIsInitialized(true);
}
}, []);
// 设置定时器每秒更新当前时间
useEffect(() => {
// 启动定时器每秒更新当前时间
const timer = setInterval(updateCurrentTime, 1000);
// 清理函数
return () => clearInterval(timer);
}, []);
// 更新当前时间和相关值
const updateCurrentTime = () => {
const now = new Date();
setCurrentTime(now);
// 只更新常用时间戳列表,不修改用户输入
updateCommonTimestamps(now);
};
// 更新常用时间戳列表
const updateCommonTimestamps = (date: Date) => {
const nowTs = Math.floor(date.getTime() / 1000);
const commonTs = [
{ label: t('tools.timestamp_converter.current_time'), value: nowTs },
{ label: t('tools.timestamp_converter.today_zero'), value: Math.floor(new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000) },
{ label: t('tools.timestamp_converter.this_monday'), value: Math.floor(new Date(date.getFullYear(), date.getMonth(), date.getDate() - date.getDay() + 1).getTime() / 1000) },
{ label: t('tools.timestamp_converter.this_month_start'), value: Math.floor(new Date(date.getFullYear(), date.getMonth(), 1).getTime() / 1000) },
{ label: t('tools.timestamp_converter.this_year_start'), value: Math.floor(new Date(date.getFullYear(), 0, 1).getTime() / 1000) },
];
setCommonTimestamps(commonTs);
};
// 时间戳转日期时间
const timestampToDateTime = (ts: string) => {
if (!ts) return;
try {
// 如果时间戳是毫秒级的(13位),转换为秒级
let timestampInSeconds = parseInt(ts);
if (ts.length >= 13) {
timestampInSeconds = Math.floor(timestampInSeconds / 1000);
}
const date = new Date(timestampInSeconds * 1000);
// 检查日期是否有效
if (isNaN(date.getTime())) {
return;
}
// 格式化日期时间
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
setDateTime(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}`);
setFormattedDateTime(`${year}-${month}-${day} ${hours}:${minutes}:${seconds}`);
} catch (error) {
console.error(t('tools.timestamp_converter.timestamp_conversion_error'), error);
}
};
// 日期时间转时间戳
const dateTimeToTimestamp = (dt: string) => {
if (!dt) return;
try {
const date = new Date(dt);
// 检查日期是否有效
if (isNaN(date.getTime())) {
return;
}
const ts = Math.floor(date.getTime() / 1000);
setTimestamp(ts.toString());
} catch (error) {
console.error(t('tools.timestamp_converter.datetime_conversion_error'), error);
}
};
// 处理时间戳输入变化
const handleTimestampChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setTimestamp(value);
timestampToDateTime(value);
};
// 处理日期时间输入变化
const handleDateTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setDateTime(value);
// 更新格式化的日期时间显示
if (value) {
try {
const date = new Date(value);
if (!isNaN(date.getTime())) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
setFormattedDateTime(`${year}-${month}-${day} ${hours}:${minutes}:${seconds}`);
}
} catch (error) {
console.error(t('tools.timestamp_converter.datetime_format_error'), error);
}
}
dateTimeToTimestamp(value);
};
// 刷新计算,使用当前时间
const refreshWithCurrentTime = () => {
const now = new Date();
const currentTimestamp = Math.floor(now.getTime() / 1000);
setTimestamp(currentTimestamp.toString());
timestampToDateTime(currentTimestamp.toString());
};
// 使用常用时间戳
const handleUseCommonTimestamp = (ts: number) => {
const tsStr = ts.toString();
setTimestamp(tsStr);
timestampToDateTime(tsStr);
};
// 复制时间戳到剪贴板
const copyTimestamp = () => {
if (!timestamp) return;
navigator.clipboard.writeText(timestamp)
.then(() => {
setCopiedTimestamp(true);
setTimeout(() => setCopiedTimestamp(false), 2000);
})
.catch(err => console.error(t('tools.timestamp_converter.copy_failed'), err));
};
// 复制日期时间到剪贴板
const copyDateTime = () => {
if (!formattedDateTime) return;
navigator.clipboard.writeText(formattedDateTime)
.then(() => {
setCopiedDateTime(true);
setTimeout(() => setCopiedDateTime(false), 2000);
})
.catch(err => console.error(t('tools.timestamp_converter.copy_failed'), err));
};
// 交换时间戳和日期时间位置
const swapPositions = () => {
// 添加动画过渡效果
const container = document.getElementById('converter-container');
if (container) {
container.classList.add('animate-pulse');
setTimeout(() => {
container.classList.remove('animate-pulse');
}, 500);
}
// 切换位置状态
setSwapped(!swapped);
};
// 渲染时间戳区域
const renderTimestampSection = () => (
<div id="timestamp-section" className="flex flex-col gap-4 transition-all duration-300">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium text-primary">{t('tools.timestamp_converter.timestamp')}</h2>
<div className="flex items-center text-xs text-tertiary">
<span className="mr-1">{t('tools.timestamp_converter.current_time_colon')}</span>
<span>{Math.floor(currentTime.getTime() / 1000)}</span>
</div>
</div>
<div className="relative">
<input
type="text"
value={timestamp}
onChange={handleTimestampChange}
placeholder={t('tools.timestamp_converter.enter_unix_timestamp')}
className={styles.input}
/>
<button
className="absolute right-3 top-1/2 -translate-y-1/2 text-tertiary hover:text-purple transition-colors"
onClick={refreshWithCurrentTime}
title={t('tools.timestamp_converter.use_current_time')}
>
<FontAwesomeIcon icon={faSync} />
</button>
</div>
<div className="text-sm text-tertiary flex gap-2 flex-wrap items-center">
<span className="whitespace-nowrap">{t('tools.timestamp_converter.common_timestamps')}:</span>
<div className="flex flex-wrap gap-2">
{commonTimestamps.map((ts, index) => (
<button
key={index}
className={styles.secondaryBtn}
onClick={() => handleUseCommonTimestamp(ts.value)}
>
{ts.label}
</button>
))}
</div>
</div>
{timestamp && (
<div className="flex items-center justify-between gap-2">
<div className={styles.formattedBlock}>
<code>{timestamp}</code>
</div>
<button
onClick={copyTimestamp}
className={styles.iconButton}
title={t('tools.timestamp_converter.copy_timestamp')}
>
<FontAwesomeIcon icon={copiedTimestamp ? faCheck : faCopy} />
</button>
</div>
)}
</div>
);
// 渲染日期时间区域
const renderDateTimeSection = () => (
<div id="datetime-section" className="flex flex-col gap-4 transition-all duration-300">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium text-primary">{t('tools.timestamp_converter.datetime')}</h2>
</div>
<div className="relative">
<input
type="datetime-local"
value={dateTime}
onChange={handleDateTimeChange}
className={styles.input}
step="1"
/>
</div>
{formattedDateTime && (
<div className="flex items-center justify-between gap-2">
<div className={styles.formattedBlock}>
<code>{formattedDateTime}</code>
</div>
<button
onClick={copyDateTime}
className={styles.iconButton}
title={t('tools.timestamp_converter.copy_datetime')}
>
<FontAwesomeIcon icon={copiedDateTime ? faCheck : faCopy} />
</button>
</div>
)}
</div>
);
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
<ToolHeader
icon={faClock}
toolCode="timestamp_converter"
title=""
description=""
/>
<div id="converter-container" className="relative card p-6">
<div className={`grid gap-6 ${swapped ? 'grid-rows-[auto_auto]' : 'grid-rows-[auto_auto]'}`}>
{swapped ? (
<>
{renderDateTimeSection()}
{renderTimestampSection()}
</>
) : (
<>
{renderTimestampSection()}
{renderDateTimeSection()}
</>
)}
</div>
<button
className={`absolute left-1/2 -translate-x-1/2 ${swapped ? 'top-[calc(50%+1rem)]' : 'top-[calc(50%+1rem)]'} ${styles.swapBtn}`}
onClick={swapPositions}
title={t('tools.timestamp_converter.swap_positions')}
>
<FontAwesomeIcon icon={faExchangeAlt} className={`${swapped ? 'rotate-90' : 'rotate-90'} transition-transform`} />
</button>
</div>
</div>
);
}
+382
View File
@@ -0,0 +1,382 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faClock, faCopy, faCheck } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
card: "card p-6",
input: "search-input w-full",
textarea: "w-full p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-[#6366F1] focus:outline-none focus:ring-1 focus:ring-[#6366F1] transition-all resize-none",
label: "text-secondary font-medium",
secondaryText: "text-sm text-tertiary",
resultItem: "flex justify-between items-center py-2 border-b border-purple-glow/10",
resultLabel: "text-sm text-secondary",
resultValue: "text-sm text-primary font-semibold",
iconButton: "text-tertiary hover:text-purple transition-colors",
button: "text-left w-full px-3 py-2 rounded-md text-sm text-secondary hover:bg-block-hover transition-colors",
formGroup: "mb-6",
resultBox: "p-2 bg-block rounded border border-purple-glow/30 break-all",
timezoneList: "bg-block-strong rounded-lg p-4 text-xs text-tertiary",
}
// 获取所有时区
const getTimezones = (): string[] => {
return [
'UTC',
'America/New_York', // 美国东部
'America/Chicago', // 美国中部
'America/Denver', // 美国山地
'America/Los_Angeles', // 美国西部
'Europe/London', // 英国
'Europe/Paris', // 法国
'Europe/Berlin', // 德国
'Europe/Moscow', // 俄罗斯
'Asia/Shanghai', // 中国
'Asia/Tokyo', // 日本
'Asia/Seoul', // 韩国
'Asia/Singapore', // 新加坡
'Asia/Dubai', // 迪拜
'Asia/Kolkata', // 印度
'Australia/Sydney', // 澳大利亚悉尼
'Pacific/Auckland', // 新西兰
'America/Sao_Paulo', // 巴西
];
};
// 时区分组
const timezoneGroups = [
{
name: 'asia_pacific',
zones: [
{ name: 'china', value: 'Asia/Shanghai', offset: '+08:00' },
{ name: 'japan', value: 'Asia/Tokyo', offset: '+09:00' },
{ name: 'korea', value: 'Asia/Seoul', offset: '+09:00' },
{ name: 'singapore', value: 'Asia/Singapore', offset: '+08:00' },
{ name: 'india', value: 'Asia/Kolkata', offset: '+05:30' },
{ name: 'australia', value: 'Australia/Sydney', offset: '+10:00/+11:00' },
]
},
{
name: 'europe',
zones: [
{ name: 'uk', value: 'Europe/London', offset: '+00:00/+01:00' },
{ name: 'france', value: 'Europe/Paris', offset: '+01:00/+02:00' },
{ name: 'germany', value: 'Europe/Berlin', offset: '+01:00/+02:00' },
{ name: 'russia', value: 'Europe/Moscow', offset: '+03:00' },
]
},
{
name: 'americas',
zones: [
{ name: 'us_eastern', value: 'America/New_York', offset: '-05:00/-04:00' },
{ name: 'us_central', value: 'America/Chicago', offset: '-06:00/-05:00' },
{ name: 'us_western', value: 'America/Los_Angeles', offset: '-08:00/-07:00' },
{ name: 'brazil', value: 'America/Sao_Paulo', offset: '-03:00/-02:00' },
]
}
];
// 获取指定时区当前时间
const getCurrentTimeInTimezone = (timezone: string): string => {
try {
const now = new Date();
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: timezone
};
return new Intl.DateTimeFormat('zh-CN', options).format(now).replace(/\//g, '-');
} catch (error) {
console.error('时区时间错误:', error);
return '无法获取时区时间';
}
};
export default function TimezoneConverter() {
const { t } = useLanguage();
// 日期时间字符串
const [dateTimeString, setDateTimeString] = useState('');
// 源时区
const [sourceTimezone, setSourceTimezone] = useState('Asia/Shanghai');
// 目标时区
const [targetTimezone, setTargetTimezone] = useState('America/New_York');
// 转换结果
const [convertedTime, setConvertedTime] = useState('');
// 详细转换结果
const [conversionDetails, setConversionDetails] = useState('');
// 可用时区列表
const [timezones, setTimezones] = useState<string[]>([]);
// 复制状态
const [copied, setCopied] = useState(false);
// 初始化时区列表
useEffect(() => {
setTimezones(getTimezones());
// 初始化当前时间
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
setDateTimeString(`${year}-${month}-${day}T${hours}:${minutes}`);
}, []);
// 当输入变化时更新转换结果
useEffect(() => {
if (dateTimeString && sourceTimezone && targetTimezone) {
convertTimezone();
}
}, [dateTimeString, sourceTimezone, targetTimezone]);
// 时区转换
const convertTimezone = () => {
try {
if (!dateTimeString) return;
// 创建源时区的日期对象
const sourceDate = new Date(dateTimeString);
// 检查日期是否有效
if (isNaN(sourceDate.getTime())) {
setConvertedTime(t('tools.timezone_converter.invalid_date_time'));
setConversionDetails(t('tools.timezone_converter.please_enter_valid_date_time'));
return;
}
// 获取源时区的时间表示
const sourceFormatter = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: sourceTimezone
});
// 获取目标时区的时间表示
const targetFormatter = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: targetTimezone
});
const sourceFormatted = sourceFormatter.format(sourceDate).replace(/\//g, '-');
const targetFormatted = targetFormatter.format(sourceDate).replace(/\//g, '-');
setConvertedTime(targetFormatted);
// 构建详细结果
const sourceTimezoneInfo = Intl.DateTimeFormat('zh-CN', { timeZoneName: 'long', timeZone: sourceTimezone }).format(sourceDate);
const targetTimezoneInfo = Intl.DateTimeFormat('zh-CN', { timeZoneName: 'long', timeZone: targetTimezone }).format(sourceDate);
const details = `${t('tools.timezone_converter.source_time')}: ${sourceFormatted} (${sourceTimezoneInfo})
${t('tools.timezone_converter.target_time')}: ${targetFormatted} (${targetTimezoneInfo})
${t('tools.timezone_converter.timestamp')}: ${Math.floor(sourceDate.getTime() / 1000)}
${t('tools.timezone_converter.iso_format')}: ${sourceDate.toISOString()}`;
setConversionDetails(details);
} catch (error) {
console.error(t('tools.timezone_converter.timezone_conversion_error')+':', error);
setConvertedTime(t('tools.timezone_converter.timezone_conversion_error'));
setConversionDetails(t('tools.timezone_converter.timezone_conversion_error'));
}
};
// 复制转换结果
const copyResult = () => {
if (!convertedTime) return;
navigator.clipboard.writeText(convertedTime)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => console.error(t('tools.timezone_converter.copy_failed')+':', err));
};
// 使用当前时间
const useCurrentTime = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
setDateTimeString(`${year}-${month}-${day}T${hours}:${minutes}`);
};
return (
<div className="min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6">
<ToolHeader
toolCode="timezone_converter"
icon={faClock}
title=""
description=""
/>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* 左侧面板 */}
<div className="lg:col-span-7 space-y-6">
{/* 日期时间输入 */}
<div className={styles.card}>
<h2 className="text-lg font-medium text-primary">{t('tools.timezone_converter.date_time')}</h2>
<div className="mt-4 flex items-center gap-2">
<input
type="datetime-local"
value={dateTimeString}
onChange={(e) => setDateTimeString(e.target.value)}
className={styles.input}
/>
<button
className="btn-secondary whitespace-nowrap"
onClick={useCurrentTime}
title={t('tools.timezone_converter.use_current_time')}
>
<FontAwesomeIcon icon={faClock} className="mr-2" />
{t('tools.timezone_converter.current_time')}
</button>
</div>
{/* 源时区选择 */}
<div className="mt-6">
<h2 className="text-lg font-medium text-primary">{t('tools.timezone_converter.source_timezone')}</h2>
<div className="mt-4">
<select
value={sourceTimezone}
onChange={(e) => setSourceTimezone(e.target.value)}
className={styles.input}
>
{timezones.map(timezone => (
<option key={timezone} value={timezone}>
{timezone} ({getCurrentTimeInTimezone(timezone)})
</option>
))}
</select>
</div>
</div>
{/* 目标时区选择 */}
<div className="mt-6">
<h2 className="text-lg font-medium text-primary">{t('tools.timezone_converter.target_timezone')}</h2>
<div className="mt-4">
<select
value={targetTimezone}
onChange={(e) => setTargetTimezone(e.target.value)}
className={styles.input}
>
{timezones.map(timezone => (
<option key={timezone} value={timezone}>
{timezone} ({getCurrentTimeInTimezone(timezone)})
</option>
))}
</select>
</div>
</div>
</div>
{/* 转换结果 */}
<div className={styles.card}>
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium text-primary">{t('tools.timezone_converter.conversion_result')}</h2>
<button
onClick={copyResult}
className={styles.iconButton}
disabled={!convertedTime}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} className="mr-1" />
{copied ? t('tools.timezone_converter.copied') : t('tools.timezone_converter.copy')}
</button>
</div>
{convertedTime ? (
<div className="mt-4 space-y-4">
<div>
<h3 className="text-sm font-medium text-secondary">{t('tools.timezone_converter.converted_time')}</h3>
<div className="text-primary">
{convertedTime}
</div>
</div>
<div>
<h3 className="text-sm font-medium text-secondary">{t('tools.timezone_converter.detailed_result')}</h3>
<div className={styles.resultBox}>
<pre className="font-mono text-sm text-primary whitespace-pre-wrap">
{conversionDetails}
</pre>
</div>
<div className="mt-2 flex flex-col gap-1 text-xs text-tertiary">
<p>{t('tools.timezone_converter.timezone_display_note')}</p>
</div>
</div>
</div>
) : (
<div className={styles.timezoneList}>
{t('tools.timezone_converter.input_date_time_select_timezone')}
</div>
)}
<div className="mt-2 text-xs text-tertiary">
<p>{t('tools.timezone_converter.timezone_note')}</p>
</div>
</div>
</div>
{/* 右侧面板 */}
<div className="lg:col-span-5">
<div className={styles.card}>
<h3 className="text-sm font-medium text-secondary mb-2">{t('tools.timezone_converter.common_timezone_info')}</h3>
<div className={styles.timezoneList}>
{timezoneGroups.map((group) => (
<div key={group.name} className="mb-4">
<h4 className="font-medium mb-2">{t(`tools.timezone_converter.${group.name}`)}</h4>
<div className="pl-2 border-l-2 border-purple-glow/50">
{group.zones.map((zone) => (
<div key={zone.value} className="mb-2">
<div className="font-medium">{t(`tools.timezone_converter.${zone.name}`)}</div>
<div className="flex justify-between text-xs">
<span>{zone.value}</span>
<span>{zone.offset}</span>
</div>
</div>
))}
</div>
</div>
))}
<div className="text-xs">
<p className="mb-2">{t('tools.timezone_converter.about_timezone')}</p>
<ul className="list-disc pl-4 space-y-1">
<li>{t('tools.timezone_converter.timezone_offset_info')}</li>
<li>{t('tools.timezone_converter.timezone_dst_info')}</li>
<li>{t('tools.timezone_converter.dst_implementation')}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+277
View File
@@ -0,0 +1,277 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFont, faExchangeAlt, faSyncAlt, faCopy, faCheck, faExclamationTriangle, faEraser } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import tools from '@/config/tools';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
card: "card p-6",
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
input: "search-input w-full",
textarea: "w-full p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple transition-all resize-none font-mono",
label: "text-sm text-secondary font-medium",
secondaryText: "text-sm text-tertiary",
secondaryBtn: "flex items-center gap-1 text-sm px-2 py-1 rounded bg-block-strong hover:bg-block-hover text-secondary transition-colors",
exchangeBtn: "bg-purple-glow/10 text-purple p-2 rounded-full hover:bg-purple-glow/20 transition-colors",
error: "p-3 bg-red-900/20 border border-red-700/30 rounded-lg text-error",
tabButton: "px-3 py-2 text-sm font-medium transition-all",
activeTab: "bg-block text-primary shadow-sm",
inactiveTab: "text-tertiary",
actionBtn: "btn-secondary flex items-center gap-2",
flexBetween: "flex flex-col sm:flex-row gap-4 justify-between items-center",
}
export default function UnicodeConverter() {
const { t } = useLanguage();
// 从工具配置中获取当前工具信息
const toolConfig = tools.find(tool => tool.code === 'unicode_converter');
// 状态管理
const [inputText, setInputText] = useState('');
const [outputText, setOutputText] = useState('');
const [operation, setOperation] = useState('encode'); // 'encode' 或 'decode'
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
// 当输入文本或操作变化时,自动执行转换
useEffect(() => {
processConversion();
}, [inputText, operation]);
// 执行转换操作
const processConversion = () => {
setError(null);
if (!inputText) {
setOutputText('');
return;
}
try {
if (operation === 'encode') {
// 文本转Unicode编码
const result = Array.from(inputText)
.map(char => {
const code = char.charCodeAt(0);
// 只转换非ASCII字符
if (code > 127) {
return `\\u${code.toString(16).padStart(4, '0')}`;
}
return char;
})
.join('');
setOutputText(result);
} else {
// Unicode编码转文本
const result = inputText.replace(/\\u([0-9a-fA-F]{4})/g, (match, group) => {
return String.fromCharCode(parseInt(group, 16));
});
setOutputText(result);
}
} catch (err) {
console.error(t('tools.unicode_converter.conversion_error'), err);
setError(`${t('tools.unicode_converter.conversion_error')}${(err as Error).message}`);
setOutputText('');
}
};
// 复制输出内容到剪贴板
const copyToClipboard = () => {
if (!outputText) return;
navigator.clipboard.writeText(outputText)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => console.error(t('tools.unicode_converter.copy_failed'), err));
};
// 清空输入和输出
const clearAll = () => {
setInputText('');
setOutputText('');
setError(null);
};
// 切换操作类型(编码/解码)
const toggleOperation = () => {
// 交换输入和输出文本
const newOperation = operation === 'encode' ? 'decode' : 'encode';
const temp = inputText;
setInputText(outputText);
setOutputText(temp);
setOperation(newOperation);
setError(null);
};
// 加载示例文本
const loadExample = () => {
const examples = {
encode: '你好,世界!Hello, World!',
decode: '\\u4f60\\u597d\\uff0c\\u4e16\\u754c\\uff01Hello, World!'
};
setInputText(examples[operation as keyof typeof examples]);
};
return (
<div className={styles.container}>
{toolConfig && (
<ToolHeader
toolCode="unicode_converter"
title=""
description=""
icon={toolConfig.icon || faFont}
/>
)}
{/* 主要内容区域 */}
<div className={styles.card}>
<div className="space-y-6">
{/* 操作类型切换 */}
<div className={styles.flexBetween}>
<div className="flex items-center gap-4">
<div className="flex items-center bg-block-strong rounded-md p-1">
<button
className={`${styles.tabButton} ${operation === 'encode' ? styles.activeTab : styles.inactiveTab}`}
onClick={() => setOperation('encode')}
>
{t('tools.unicode_converter.text_to_unicode')}
</button>
<button
className={`${styles.tabButton} ${operation === 'decode' ? styles.activeTab : styles.inactiveTab}`}
onClick={() => setOperation('decode')}
>
{t('tools.unicode_converter.unicode_to_text')}
</button>
</div>
<div className={styles.secondaryText}>
{operation === 'encode'
? t('tools.unicode_converter.text_to_unicode_description')
: t('tools.unicode_converter.unicode_to_text_description')}
</div>
</div>
<div className="flex gap-2">
<button
className={styles.actionBtn}
onClick={loadExample}
>
<FontAwesomeIcon icon={faSyncAlt} />
{t('tools.unicode_converter.load_example')}
</button>
<button
className={styles.actionBtn}
onClick={clearAll}
>
<FontAwesomeIcon icon={faEraser} />
{t('tools.unicode_converter.clear')}
</button>
</div>
</div>
{/* 输入输出区域 */}
<div className="grid grid-cols-2 gap-6">
{/* 输入框 */}
<div className="space-y-2">
<div className="flex justify-between items-center h-8">
<label className={styles.label}>
{operation === 'encode'
? t('tools.unicode_converter.original_text')
: t('tools.unicode_converter.unicode_encoding')}
</label>
</div>
<textarea
className={styles.textarea}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder={operation === 'encode'
? t('tools.unicode_converter.text_to_unicode_placeholder')
: t('tools.unicode_converter.unicode_to_text_placeholder')}
rows={10}
/>
</div>
{/* 输出框 */}
<div className="space-y-2">
<div className="flex justify-between items-center h-8">
<div className="flex items-center">
<label className={styles.label}>
{operation === 'encode'
? t('tools.unicode_converter.unicode_encoding')
: t('tools.unicode_converter.converted_text')}
</label>
<button
onClick={toggleOperation}
className={styles.exchangeBtn}
title={t('tools.unicode_converter.swap_input_output')}
>
<FontAwesomeIcon icon={faExchangeAlt} />
</button>
</div>
<div>
<button
onClick={copyToClipboard}
className={styles.secondaryBtn}
disabled={!outputText}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
{copied ? t('tools.unicode_converter.copied') : t('tools.unicode_converter.copy')}
</button>
</div>
</div>
<textarea
className={styles.textarea}
value={outputText}
readOnly
placeholder={operation === 'encode'
? t('tools.unicode_converter.unicode_result_placeholder')
: t('tools.unicode_converter.text_result_placeholder')}
rows={10}
/>
</div>
</div>
{/* 错误提示 */}
{error && (
<div className={styles.error}>
<FontAwesomeIcon icon={faExclamationTriangle} className="mr-2" />
{error}
</div>
)}
{/* 功能说明 */}
<div className="bg-block p-4 rounded-lg">
<h3 className="text-primary font-medium mb-2">{t('tools.unicode_converter.feature_intro')}</h3>
<p className={styles.secondaryText}>
{t('tools.unicode_converter.unicode_description')}
</p>
<p className={styles.secondaryText}>
{t('tools.unicode_converter.supported_operations')}
</p>
<ul className="list-disc pl-5 text-sm text-tertiary">
<li>{t('tools.unicode_converter.operation_text_to_unicode')}</li>
<li>{t('tools.unicode_converter.operation_unicode_to_text')}</li>
</ul>
<p className={styles.secondaryText}>
{t('tools.unicode_converter.note')}
</p>
</div>
</div>
</div>
{/* 回到顶部按钮 */}
<BackToTop />
</div>
);
}
+398
View File
@@ -0,0 +1,398 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExchangeAlt, faCopy, faCheck, faEraser, faHistory } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import tools from '@/config/tools';
import { useLanguage } from '@/context/LanguageContext';
// 添加CSS变量样式
const styles = {
card: "card p-6",
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
textarea: "w-full p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple transition-all",
label: "text-sm text-secondary font-medium",
secondaryText: "text-sm text-tertiary",
errorBox: "p-3 bg-red-900/20 border border-red-700/30 rounded-lg text-error",
successBox: "p-3 bg-green-900/20 border border-green-700/30 rounded-lg text-success",
iconButton: "p-1 text-secondary hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed",
directionIndicator: (active: boolean) => active ? "text-primary" : "text-tertiary",
historyItem: "flex px-3 py-2 text-sm rounded-lg cursor-pointer text-secondary hover:bg-block-hover transition-colors",
noHistoryText: "text-sm text-tertiary py-2 text-center italic",
swapButton: "bg-block p-2 rounded-full hover:bg-block-hover transition-colors",
encodeLabel: (active: boolean) => `text-sm ${active ? 'text-purple' : 'text-tertiary'} cursor-pointer`,
clearButton: "btn-secondary flex items-center gap-2",
copyButton: "btn-primary flex items-center gap-2",
historyButton: "btn-secondary flex items-center gap-2",
}
// 类型定义
type Direction = 'encode' | 'decode';
type EncodeMode = 'url' | 'component';
// 历史记录项目接口
interface HistoryItem {
id: string;
input: string;
output: string;
direction: Direction;
mode: EncodeMode;
timestamp: number;
}
export default function UrlEncoder() {
const { t } = useLanguage();
// 从工具配置中获取当前工具信息
const toolConfig = tools.find(tool => tool.code === 'url_encoder');
// 状态管理
const [inputText, setInputText] = useState('');
const [outputText, setOutputText] = useState('');
const [direction, setDirection] = useState<Direction>('encode');
const [encodeMode, setEncodeMode] = useState<EncodeMode>('url');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [copied, setCopied] = useState(false);
const [history, setHistory] = useState<HistoryItem[]>([]);
const [showHistory, setShowHistory] = useState(false);
// 从localStorage加载历史记录
useEffect(() => {
const savedHistory = localStorage.getItem('urlEncoderHistory');
if (savedHistory) {
try {
setHistory(JSON.parse(savedHistory));
} catch (e) {
console.error(t('tools.url_encoder.load_history_error'), e);
}
}
}, [t]);
// 保存历史记录到localStorage
useEffect(() => {
if (history.length > 0) {
localStorage.setItem('urlEncoderHistory', JSON.stringify(history));
}
}, [history]);
// 转换文本
useEffect(() => {
if (!inputText.trim()) {
setOutputText('');
setError('');
return;
}
try {
let result = '';
if (direction === 'encode') {
result = encodeMode === 'url'
? encodeURI(inputText)
: encodeURIComponent(inputText);
} else {
result = encodeMode === 'url'
? decodeURI(inputText)
: decodeURIComponent(inputText);
}
setOutputText(result);
setError('');
} catch (err) {
if (err instanceof Error) {
setError(err.message);
} else {
setError(t('tools.url_encoder.error_processing'));
}
setOutputText('');
}
}, [inputText, direction, encodeMode, t]);
// 反转转换方向
const swapDirection = () => {
setDirection(prev => prev === 'encode' ? 'decode' : 'encode');
setInputText(outputText);
setOutputText('');
setError('');
};
// 复制输出结果
const copyOutput = () => {
if (!outputText) return;
navigator.clipboard.writeText(outputText)
.then(() => {
setCopied(true);
setSuccess(t('tools.url_encoder.copied_to_clipboard'));
// 添加到历史记录
if (inputText.trim() && outputText.trim()) {
const newItem: HistoryItem = {
id: Date.now().toString(),
input: inputText,
output: outputText,
direction,
mode: encodeMode,
timestamp: Date.now()
};
setHistory(prev => [newItem, ...prev.slice(0, 9)]); // 保留最近10条记录
}
setTimeout(() => {
setCopied(false);
setSuccess('');
}, 2000);
})
.catch(err => {
console.error(t('tools.url_encoder.copy_failed'), err);
setError(t('tools.url_encoder.copy_failed'));
});
};
// 清空输入输出
const clearAll = () => {
setInputText('');
setOutputText('');
setError('');
setSuccess('');
};
// 从历史记录中恢复
const restoreFromHistory = (item: HistoryItem) => {
setInputText(item.input);
setDirection(item.direction);
setEncodeMode(item.mode);
setShowHistory(false);
};
// 清空历史记录
const clearHistory = () => {
setHistory([]);
localStorage.removeItem('urlEncoderHistory');
};
// 格式化时间
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleString();
};
return (
<div className={styles.container}>
{/* 工具头部 */}
<ToolHeader
toolCode="url_encoder"
icon={toolConfig?.icon || faExchangeAlt}
title=""
description=""
/>
{/* 主内容区 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧面板 - 输入区域 */}
<div className="lg:col-span-2 space-y-6">
{/* 转换选项 */}
<div className={styles.card}>
<div className="flex flex-col md:flex-row items-center justify-center space-y-4 md:space-y-0 md:space-x-6 mb-2">
{/* 转换方向选择器 */}
<div className="flex items-center space-x-4">
<span className={styles.directionIndicator(direction === 'encode')}>
{t('tools.url_encoder.original_text')}
</span>
<button onClick={swapDirection} className={styles.swapButton}>
<FontAwesomeIcon
icon={faExchangeAlt}
className={`${direction === 'encode' ? 'text-purple' : 'text-purple rotate-180'} transition-transform`}
/>
</button>
<span className={styles.directionIndicator(direction === 'decode')}>
{t('tools.url_encoder.url_encoded_text')}
</span>
</div>
{/* 编码模式选择器 */}
<div className="flex items-center space-x-4">
<label className={styles.encodeLabel(encodeMode === 'url')}>
<input
type="radio"
name="encodeMode"
value="url"
checked={encodeMode === 'url'}
onChange={() => setEncodeMode('url')}
className="sr-only"
/>
<span>{t('tools.url_encoder.uri_encoding')}</span>
</label>
<label className={styles.encodeLabel(encodeMode === 'component')}>
<input
type="radio"
name="encodeMode"
value="component"
checked={encodeMode === 'component'}
onChange={() => setEncodeMode('component')}
className="sr-only"
/>
<span>{t('tools.url_encoder.uri_component_encoding')}</span>
</label>
</div>
</div>
<div className="mt-4 text-sm text-tertiary">
<p>
{encodeMode === 'url'
? t('tools.url_encoder.uri_encoding_description')
: t('tools.url_encoder.uri_component_description')}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 输入文本区域 */}
<div className="space-y-3">
<label className={styles.label}>
{direction === 'encode' ? t('tools.url_encoder.input_original_text') : t('tools.url_encoder.input_encoded_text')}
</label>
<textarea
className={styles.textarea}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder={direction === 'encode' ? t('tools.url_encoder.enter_text_to_encode') : t('tools.url_encoder.enter_text_to_decode')}
rows={12}
/>
<div className="flex justify-between">
<button
onClick={clearAll}
disabled={!inputText}
className={styles.clearButton}
>
<FontAwesomeIcon icon={faEraser} />
{t('tools.url_encoder.clear')}
</button>
<button
onClick={() => setShowHistory(!showHistory)}
className={styles.historyButton}
>
<FontAwesomeIcon icon={faHistory} />
{showHistory ? t('tools.url_encoder.hide_history') : t('tools.url_encoder.show_history')}
</button>
</div>
</div>
{/* 输出文本区域 */}
<div className="space-y-3">
<label className={styles.label}>
{direction === 'encode' ? t('tools.url_encoder.encoding_result') : t('tools.url_encoder.decoding_result')}
</label>
<textarea
className={styles.textarea}
value={outputText}
readOnly
placeholder={direction === 'encode' ? t('tools.url_encoder.encoded_result_placeholder') : t('tools.url_encoder.decoded_result_placeholder')}
rows={12}
/>
<div className="flex justify-end">
<button
onClick={copyOutput}
disabled={!outputText}
className={styles.copyButton}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
{copied ? t('tools.url_encoder.copied') : t('tools.url_encoder.copy_result')}
</button>
</div>
{/* 消息区域 */}
{error && <div className={styles.errorBox}>{error}</div>}
{success && <div className={styles.successBox}>{success}</div>}
</div>
</div>
</div>
{/* 右侧面板 - 历史记录 */}
<div className="space-y-6">
<div className={styles.card}>
<div className="flex justify-between items-center mb-4">
<h2 className="font-medium text-primary">{t('tools.url_encoder.history')}</h2>
{history.length > 0 && (
<button
onClick={clearHistory}
className="text-sm text-secondary hover:text-primary transition-colors"
>
{t('tools.url_encoder.clear_history')}
</button>
)}
</div>
{history.length > 0 ? (
<div className="space-y-2 max-h-[500px] overflow-y-auto">
{history.map(item => (
<div
key={item.id}
className={styles.historyItem}
onClick={() => restoreFromHistory(item)}
>
<div className="flex-1 truncate">
<div className="font-medium mb-1 truncate">{item.input.substring(0, 30)}{item.input.length > 30 ? '...' : ''}</div>
<div className="flex items-center justify-between">
<span className="text-xs text-tertiary">
{item.direction === 'encode' ? t('tools.url_encoder.encode') : t('tools.url_encoder.decode')}
{item.mode === 'url' ? ' URI' : ' URI Component'}
</span>
<span className="text-xs text-tertiary">{formatTime(item.timestamp)}</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className={styles.noHistoryText}>
{t('tools.url_encoder.no_history')}
</div>
)}
</div>
{/* 说明卡片 */}
<div className={styles.card}>
<h2 className="font-medium text-primary mb-4">{t('tools.url_encoder.url_encoding_explanation')}</h2>
<div className="space-y-4 text-sm text-tertiary">
<div>
<h3 className="font-medium text-secondary mb-2">{t('tools.url_encoder.uri_vs_component')}</h3>
<ul className="space-y-2">
<li><span className="text-primary">encodeURI</span>: {t('tools.url_encoder.encode_uri_description')}</li>
<li><span className="text-primary">encodeURIComponent</span>: {t('tools.url_encoder.encode_uri_component_description')}</li>
</ul>
</div>
<div>
<h3 className="font-medium text-secondary mb-2">{t('tools.url_encoder.usage_scenarios')}</h3>
<ul className="space-y-2">
<li>{t('tools.url_encoder.scenario_1')}</li>
<li>{t('tools.url_encoder.scenario_2')}</li>
<li>{t('tools.url_encoder.scenario_3')}</li>
</ul>
</div>
<div>
<h3 className="font-medium text-secondary mb-2">{t('tools.url_encoder.encoding_rules')}</h3>
<p>{t('tools.url_encoder.rules_1')}</p>
<p>{t('tools.url_encoder.rules_2')}</p>
</div>
</div>
</div>
</div>
</div>
{/* 回到顶部按钮 */}
<BackToTop />
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
'use client';
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowUp } from '@fortawesome/free-solid-svg-icons';
interface BackToTopProps {
scrollThreshold?: number; // 显示按钮的滚动阈值(像素)
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; // 按钮位置
offset?: number; // 与屏幕边缘的距离(像素)
containerRef?: React.RefObject<HTMLElement>; // 滚动容器引用,默认为window
zIndex?: number; // 自定义z-index
size?: 'small' | 'medium' | 'large'; // 按钮大小
}
/**
* 回到顶部组件
*
* 用法:
* 1. 基本用法: <BackToTop />
* 2. 自定义: <BackToTop position="bottom-left" offset={30} size="large" />
* 3. 对特定容器:
* const containerRef = useRef<HTMLDivElement>(null);
* <div ref={containerRef} style={{height: '500px', overflow: 'auto'}}>
* 内容
* <BackToTop containerRef={containerRef} />
* </div>
*/
const BackToTop: React.FC<BackToTopProps> = ({
scrollThreshold = 300,
position = 'bottom-right',
offset = 20,
containerRef,
zIndex = 40,
size = 'medium'
}) => {
const [isVisible, setIsVisible] = useState(false);
// 生成位置样式
const getPositionStyle = () => {
const positionStyle: React.CSSProperties = {};
if (position.includes('bottom')) {
positionStyle.bottom = offset;
} else {
positionStyle.top = offset;
}
if (position.includes('right')) {
positionStyle.right = offset;
} else {
positionStyle.left = offset;
}
return positionStyle;
};
// 根据大小获取样式类名
const getSizeClassName = () => {
switch (size) {
case 'small':
return 'w-8 h-8';
case 'large':
return 'w-12 h-12';
case 'medium':
default:
return 'w-10 h-10';
}
};
// 处理滚动事件
const handleScroll = () => {
if (containerRef && containerRef.current) {
setIsVisible(containerRef.current.scrollTop > scrollThreshold);
} else {
setIsVisible(window.scrollY > scrollThreshold);
}
};
// 回到顶部
const scrollToTop = () => {
if (containerRef && containerRef.current) {
containerRef.current.scrollTo({
top: 0,
behavior: 'smooth'
});
} else {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
}
};
// 设置滚动监听
useEffect(() => {
const scrollElement = containerRef?.current || window;
scrollElement.addEventListener('scroll', handleScroll);
// 初始检查
handleScroll();
return () => {
scrollElement.removeEventListener('scroll', handleScroll);
};
}, [containerRef, scrollThreshold]);
// 如果不可见,不渲染
if (!isVisible) return null;
const positionStyle = getPositionStyle();
return (
<button
onClick={scrollToTop}
className={`
fixed
${getSizeClassName()}
bg-gradient-to-r from-[#6366F1] to-[#8B5CF6]
text-white
rounded-full
flex
items-center
justify-center
shadow-lg
hover:shadow-xl
transition-all
duration-300
hover:scale-110
focus:outline-none
focus:ring-2
focus:ring-[#6366F1]
focus:ring-opacity-50
`}
style={{
...positionStyle,
zIndex
}}
aria-label="回到顶部"
title="回到顶部"
>
<FontAwesomeIcon icon={faArrowUp} />
</button>
);
};
export default BackToTop;
+45
View File
@@ -0,0 +1,45 @@
'use client';
import { useEffect, useRef } from 'react';
import { createJSONEditor } from 'vanilla-jsoneditor';
import type { JSONEditorPropsOptional } from 'vanilla-jsoneditor';
interface JsonEditorProps extends JSONEditorPropsOptional {
className?: string;
}
const JsonEditor = (props: JsonEditorProps) => {
const { className, ...editorProps } = props;
const refContainer = useRef<HTMLDivElement>(null);
const refEditor = useRef<ReturnType<typeof createJSONEditor> | null>(null);
useEffect(() => {
// 确保只在客户端渲染
if (typeof window !== 'undefined' && refContainer.current && !refEditor.current) {
// 创建编辑器
refEditor.current = createJSONEditor({
target: refContainer.current,
props: editorProps
});
}
return () => {
// 销毁编辑器
if (refEditor.current) {
refEditor.current.destroy();
refEditor.current = null;
}
};
}, []);
// 更新props
useEffect(() => {
if (refEditor.current) {
refEditor.current.updateProps(editorProps);
}
}, [editorProps]);
return <div ref={refContainer} className={className}></div>;
};
export default JsonEditor;
+70
View File
@@ -0,0 +1,70 @@
'use client';
import React, { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLanguage } from '@fortawesome/free-solid-svg-icons';
import { useLanguage } from '@/context/LanguageContext';
import { Language } from '@/config/i18n';
export default function LanguageToggle() {
const { language, changeLanguage, t } = useLanguage();
const [showDropdown, setShowDropdown] = useState(false);
// 切换下拉菜单显示状态
const toggleDropdown = () => {
setShowDropdown(prev => !prev);
};
// 选择语言
const selectLanguage = (lang: Language) => {
changeLanguage(lang);
setShowDropdown(false);
};
return (
<div className="relative">
<button
className="btn-secondary w-10 h-10 rounded-full flex items-center justify-center group relative"
onClick={toggleDropdown}
aria-label={t('common.language.title')}
title={t('common.language.title')}
>
<FontAwesomeIcon
icon={faLanguage}
className="text-[rgb(var(--color-primary))] text-xl"
/>
<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.language.title')}
</span>
</button>
{/* 语言选择下拉菜单 */}
{showDropdown && (
<div
className="absolute right-0 top-full mt-2 py-2 w-32 bg-[rgb(var(--color-bg-card))] shadow-lg rounded-lg border border-[rgba(var(--color-primary),0.2)] z-50"
style={{
backgroundColor: 'rgb(var(--color-bg-card))',
border: '1px solid rgba(var(--color-primary), 0.2)'
}}
>
<button
className={`w-full text-left px-4 py-2 hover:bg-[rgba(var(--color-primary),0.1)] transition-colors duration-200 ${language === 'zh' ? 'text-[rgb(var(--color-primary))] font-medium' : ''}`}
onClick={() => selectLanguage('zh')}
>
{t('common.language.zh')}
</button>
<button
className={`w-full text-left px-4 py-2 hover:bg-[rgba(var(--color-primary),0.1)] transition-colors duration-200 ${language === 'en' ? 'text-[rgb(var(--color-primary))] font-medium' : ''}`}
onClick={() => selectLanguage('en')}
>
{t('common.language.en')}
</button>
</div>
)}
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
'use client';
import React, { useRef, useEffect } from 'react';
interface MarkdownPreviewProps {
htmlContent: string;
}
export default function MarkdownPreview({ htmlContent }: MarkdownPreviewProps) {
const previewRef = useRef<HTMLDivElement>(null);
// 在渲染后处理所有可能的脚本和危险内容
useEffect(() => {
if (!previewRef.current) return;
const container = previewRef.current;
// 禁用所有链接默认行为
const links = container.querySelectorAll('a');
links.forEach(link => {
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener noreferrer');
// 阻止链接点击默认行为,除非特别需要允许
link.addEventListener('click', (e) => {
e.preventDefault();
});
});
// 移除所有脚本标签
const scripts = container.querySelectorAll('script');
scripts.forEach(script => script.remove());
// 处理所有iframe,确保它们有sandbox属性
const iframes = container.querySelectorAll('iframe');
iframes.forEach(iframe => {
iframe.setAttribute('sandbox', 'allow-scripts');
iframe.setAttribute('loading', 'lazy');
});
}, [htmlContent]);
return (
<div
className="markdown-preview-container prose prose-invert"
ref={previewRef}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
);
}
+66
View File
@@ -0,0 +1,66 @@
'use client';
import React, { useEffect, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLightbulb } from '@fortawesome/free-solid-svg-icons';
import { faLightbulb as farLightbulb } from '@fortawesome/free-regular-svg-icons';
import { useLanguage } from '@/context/LanguageContext';
export default function ThemeToggle() {
const [theme, setTheme] = useState('dark');
const [mounted, setMounted] = useState(false);
const { t } = useLanguage();
// 组件挂载后执行
useEffect(() => {
setMounted(true);
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
setTheme(savedTheme);
document.documentElement.setAttribute('data-theme', savedTheme);
}
}, []);
// 切换主题
const toggleTheme = () => {
const newTheme = theme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
// 保存到本地存储并设置 data-theme 属性
localStorage.setItem('theme', newTheme);
document.documentElement.setAttribute('data-theme', newTheme);
};
// 如果组件尚未挂载,返回空白以避免服务器/客户端不匹配
if (!mounted) return null;
return (
<button
className="btn-secondary w-10 h-10 rounded-full flex items-center justify-center group relative overflow-hidden"
onClick={toggleTheme}
aria-label={theme === 'dark' ? t('common.theme.light') : t('common.theme.dark')}
title={theme === 'dark' ? t('common.theme.light') : t('common.theme.dark')}
>
{theme === 'dark' ? (
// 灯泡图标 - 深色模式下显示(实心灯泡表示可以"点亮")
<FontAwesomeIcon
icon={faLightbulb}
className="text-[rgb(var(--color-warning))] text-xl"
/>
) : (
// 灯泡轮廓图标 - 浅色模式下显示(空心灯泡表示可以"关闭")
<FontAwesomeIcon
icon={farLightbulb}
className="text-[rgb(var(--color-primary-light))] text-xl"
/>
)}
<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))'
}}>
{theme === 'dark' ? t('common.theme.light') : t('common.theme.dark')}
</span>
</button>
);
}
+46
View File
@@ -0,0 +1,46 @@
'use client';
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { IconDefinition } from '@fortawesome/fontawesome-svg-core';
import { useRouter } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
interface ToolHeaderProps {
title: string;
description: string;
icon: IconDefinition;
toolCode: string;
}
export default function ToolHeader({ title, description, icon, toolCode }: ToolHeaderProps) {
const router = useRouter();
const { t } = useLanguage();
// 返回首页
const goBack = () => {
router.push('/');
};
return (
<header className="flex items-center gap-4 mb-8 rounded-lg p-4 shadow-md border">
<button
className="btn-secondary px-3 py-2"
onClick={goBack}
>
{t('common.backToHome')}
</button>
<div className="flex items-center gap-2">
<div className="icon-container w-10 h-10 flex-shrink-0">
<FontAwesomeIcon icon={icon} className="icon" />
</div>
<div>
<h1 className="text-xl font-bold">{title || t(`tools.${toolCode}.title`)}</h1>
<p className="text-sm"
style={{color: 'rgb(var(--color-text-secondary))'}}
>{description || t(`tools.${toolCode}.description`)}</p>
</div>
</div>
</header>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { Category } from '@/types/tools';
// 定义工具类别
const categories: Category[] = [
{ code: "all", active: true },
{ code: "common", active: false },
{ code: "json", active: false },
{ code: "encoding", active: false },
{ code: "network", active: false },
{ code: "datetime", active: false },
{ code: "code", active: false },
{ code: "text", active: false },
{ code: "image", active: false },
{ code: "frontend", active: false },
];
export default categories;
+43
View File
@@ -0,0 +1,43 @@
export const commonEn = {
common: {
backToHome: 'Back',
theme: {
light: 'Light Mode',
dark: 'Dark Mode'
},
language: {
title: 'Language',
zh: 'Chinese',
en: 'English'
},
search: 'Search tools...',
favorites: 'Favorites',
viewFavorites: 'View Favorites',
addedToFavorites: 'Added to favorites!',
allTools: 'All Tools',
contact: 'Contact Us',
contactEmail: 'Contact Email',
copyEmail: 'Copy Email',
copySuccess: 'Copied Successfully',
loading: 'Loading...',
siteDesc: 'Efficient Development Tool Integration Platform',
productRecommend: 'Product Recommendations',
feedback: 'Provide Feedback',
siteName: 'JiSuXiang'
},
categories: {
all: 'All Tools',
common: 'Common Tools',
json: 'JSON Tools',
encoding: 'Encoding & Encryption',
network: 'Network Tools',
datetime: 'Date & Time',
code: 'Code Tools',
text: 'Text Processing',
image: 'Image Tools',
frontend: 'Frontend Dev',
favorites: 'My Favorites'
}
};
export default commonEn;
+9
View File
@@ -0,0 +1,9 @@
import commonZh from './zh';
import commonEn from './en';
export const common = {
zh: commonZh,
en: commonEn
};
export default common;
+43
View File
@@ -0,0 +1,43 @@
export const commonZh = {
common: {
backToHome: '返回',
theme: {
light: '浅色模式',
dark: '深色模式'
},
language: {
title: '语言',
zh: '中文',
en: '英文'
},
search: '搜索工具...',
favorites: '收藏夹',
viewFavorites: '查看收藏',
addedToFavorites: '已添加到收藏夹!',
allTools: '全部工具',
contact: '联系我们',
contactEmail: '联系邮箱',
copyEmail: '复制邮箱',
copySuccess: '复制成功',
loading: '加载中...',
siteDesc: '高效开发工具集成平台',
productRecommend: '产品推荐',
feedback: '提供反馈',
siteName: '极速箱'
},
categories: {
all: '全部工具',
common: '常用工具',
json: 'JSON工具',
encoding: '编码加密',
network: '网络工具',
datetime: '时间日期',
code: '代码工具',
text: '文本处理',
image: '图像工具',
frontend: '前端开发',
favorites: '我的收藏'
}
};
export default commonZh;
+31
View File
@@ -0,0 +1,31 @@
import commonEn from './common/en';
import tools from './tools';
export const en = {
...commonEn,
tools: {
json_formatter: tools.json_formatter.en,
regex_tester: tools.regex_tester.en,
timestamp_converter: tools.timestamp_converter.en,
encoding_converter: tools.encoding_converter.en,
code_formatter: tools.code_formatter.en,
color_tools: tools.color_tools.en,
crypto_tools: tools.crypto_tools.en,
css_gradient_generator: tools.css_gradient_generator.en,
date_calculator: tools.date_calculator.en,
html_markdown_converter: tools.html_markdown_converter.en,
http_tester: tools.http_tester.en,
image_compressor: tools.image_compressor.en,
ip_lookup: tools.ip_lookup.en,
json_converter: tools.json_converter.en,
json_editor: tools.json_editor.en,
jwt_decoder: tools.jwt_decoder.en,
qrcode_generator: tools.qrcode_generator.en,
text_counter: tools.text_counter.en,
timezone_converter: tools.timezone_converter.en,
unicode_converter: tools.unicode_converter.en,
url_encoder: tools.url_encoder.en
}
};
export default en;
+12
View File
@@ -0,0 +1,12 @@
import zh from './zh';
import en from './en';
export const languages = {
zh,
en
};
export type Language = keyof typeof languages;
export type LanguageData = typeof zh | typeof en;
export default languages;
@@ -0,0 +1,51 @@
export const codeFormatterEn = {
title: 'Code Formatter',
description: 'Code beautifier for HTML/CSS/JS/SQL and more',
warning_missing_files: 'Warning: Formatting tool files may be missing, functionality may be limited',
load_failed: 'Failed to load formatting library, please refresh the page and try again',
error_empty_input: 'Please enter code to format',
error_browser_only: 'Formatting is only available in browsers',
error_library_loading: 'Formatting library has not finished loading, please try again later',
error_unsupported_language: 'Unsupported language type: {language}',
error_prettier: 'Formatting library error: {message}',
error_formatting: 'Formatting error: {message}',
error_unknown: 'Unknown error',
error_initialization: 'Initialization failed',
input_code: 'Input Code',
formatted_result: 'Formatted Result',
input_placeholder: 'Please enter {language} code...',
result_placeholder: 'Formatted code will be displayed here',
click_format: 'Click the "Format" button below to process',
load_example: 'Load Example',
clear: 'Clear',
copy_failed: 'Copy failed:',
formatting_error_title: 'Formatting Error',
format: 'Format',
processing: 'Processing...',
loading_library: 'Loading formatting library...',
first_time_loading: 'First time use requires loading the formatting library, please wait...',
usage_guide: 'Usage Guide',
usage_step1: 'Select the code language type, enter or paste the code you want to format',
usage_step2: 'Click the "Format" button to process the code',
usage_step3: 'After formatting, you can copy or download the result',
usage_step4: 'If you encounter errors, check your code syntax',
prettier_core_loaded: 'Prettier core loaded successfully',
babel_parser_loaded: 'Babel parser loaded successfully',
html_parser_loaded: 'HTML parser loaded successfully',
css_parser_loaded: 'CSS parser loaded successfully',
typescript_parser_loaded: 'TypeScript parser loaded successfully',
markdown_parser_loaded: 'Markdown parser loaded successfully',
yaml_parser_loaded: 'YAML parser loaded successfully',
graphql_parser_loaded: 'GraphQL parser loaded successfully',
all_modules_loaded: 'All Prettier modules loaded!',
load_error: 'Failed to load Prettier:',
using_parser: 'Using parser:',
available_plugins: 'Available plugins:',
prettier_error_log: 'Prettier error:',
copy: 'Copy',
copied: 'Copied',
download: 'Download',
language_select: 'Select Language'
};
export default codeFormatterEn;
@@ -0,0 +1,9 @@
import codeFormatterZh from './zh';
import codeFormatterEn from './en';
export const codeFormatter = {
zh: codeFormatterZh,
en: codeFormatterEn
};
export default codeFormatter;
@@ -0,0 +1,51 @@
export const codeFormatterZh = {
title: '代码格式化',
description: 'HTML/CSS/JS/SQL等多种语言的代码美化',
warning_missing_files: '警告:格式化工具文件可能缺失,功能可能受限',
load_failed: '加载格式化库失败,请刷新页面重试',
error_empty_input: '请输入需要格式化的代码',
error_browser_only: '格式化功能仅在浏览器中可用',
error_library_loading: '格式化库尚未加载完成,请稍后再试',
error_unsupported_language: '不支持的语言类型: {language}',
error_prettier: '格式化库错误: {message}',
error_formatting: '格式化错误: {message}',
error_unknown: '未知错误',
error_initialization: '初始化失败',
input_code: '输入代码',
formatted_result: '格式化结果',
input_placeholder: '请输入{language}代码...',
result_placeholder: '格式化后的代码将显示在这里',
click_format: '点击下方的"格式化"按钮开始处理',
load_example: '加载示例',
clear: '清空',
copy_failed: '复制失败:',
formatting_error_title: '格式化错误',
format: '格式化',
processing: '处理中...',
loading_library: '加载格式化库...',
first_time_loading: '首次使用时需要加载格式化库,请稍等...',
usage_guide: '使用说明',
usage_step1: '选择代码语言类型,输入或粘贴需要格式化的代码',
usage_step2: '点击"格式化"按钮处理代码',
usage_step3: '格式化后可以复制或下载结果',
usage_step4: '如果遇到错误,请检查代码语法',
prettier_core_loaded: 'Prettier核心加载成功',
babel_parser_loaded: 'Babel解析器加载成功',
html_parser_loaded: 'HTML解析器加载成功',
css_parser_loaded: 'CSS解析器加载成功',
typescript_parser_loaded: 'TypeScript解析器加载成功',
markdown_parser_loaded: 'Markdown解析器加载成功',
yaml_parser_loaded: 'YAML解析器加载成功',
graphql_parser_loaded: 'GraphQL解析器加载成功',
all_modules_loaded: '所有Prettier模块加载完成!',
load_error: '加载Prettier失败:',
using_parser: '使用parser:',
available_plugins: '可用的插件:',
prettier_error_log: 'Prettier错误:',
copy: '复制',
copied: '已复制',
download: '下载',
language_select: '选择语言'
};
export default codeFormatterZh;
+59
View File
@@ -0,0 +1,59 @@
export const colorToolsEn = {
title: 'Color Tools',
description: 'Color picker, format converter, palette generator',
color_selection: 'Color Selection',
random_color: 'Random Color',
hex: 'HEX',
rgb: 'RGB',
hsl: 'HSL',
color_formats: {
hex: 'HEX',
rgb: 'RGB',
hsl: 'HSL'
},
color_palette: 'Color Palette',
palette_actions: {
add_color: 'Add Color',
random_color: 'Random Color',
save_palette: 'Save Palette',
clear_palette: 'Clear Palette',
save_to_local: 'Save to Local'
},
save_palette_success: 'Palette saved to local storage',
save_palette_error: 'Failed to save palette',
palette_color_name: 'Color Name',
palette_color_name_input: 'Enter color name...',
add: 'Add',
cancel: 'Cancel',
example_palette: {
primary: 'Primary',
secondary: 'Secondary',
accent: 'Accent',
dark: 'Dark',
light: 'Light'
},
color_preview: 'Color Preview',
contrast_effects: 'Contrast Effects',
white_text: 'White Text',
black_text: 'Black Text',
border_background: 'Border and Background',
border_effect: 'Border Effect',
transparent_background: 'Transparent Background',
color_variants: 'Color Variants',
complementary_color: 'Complementary Color',
analogous_harmony: 'Analogous Harmony',
usage_guide: {
title: 'Color Tools Usage Guide',
content: 'This tool provides color selection, format conversion, and palette functions, supporting:',
features: [
'Conversion and copying of HEX, RGB, and HSL color formats',
'Brightness scale display, quickly select different brightness of the same color',
'Complementary and adjacent harmony colors to help design harmonious color schemes',
'Personalized palette saving, support for adding and deleting custom colors'
]
},
copy_success: 'Copied',
copy_failed: 'Copy failed'
};
export default colorToolsEn;
@@ -0,0 +1,9 @@
import colorToolsZh from './zh';
import colorToolsEn from './en';
export const colorTools = {
zh: colorToolsZh,
en: colorToolsEn
};
export default colorTools;
+59
View File
@@ -0,0 +1,59 @@
export const colorToolsZh = {
title: '颜色工具',
description: '颜色选择、格式转换、调色板生成工具',
color_selection: '颜色选择',
random_color: '随机颜色',
hex: 'HEX',
rgb: 'RGB',
hsl: 'HSL',
color_formats: {
hex: 'HEX',
rgb: 'RGB',
hsl: 'HSL'
},
color_palette: '颜色调色板',
palette_actions: {
add_color: '添加颜色',
random_color: '随机颜色',
save_palette: '保存调色板',
clear_palette: '清空调色板',
save_to_local: '保存到本地'
},
save_palette_success: '调色板已保存到本地',
save_palette_error: '保存调色板失败',
palette_color_name: '颜色名称',
palette_color_name_input: '输入颜色名称...',
add: '添加',
cancel: '取消',
example_palette: {
primary: '主色',
secondary: '辅助色',
accent: '强调色',
dark: '深色',
light: '浅色'
},
color_preview: '颜色预览',
contrast_effects: '对比效果',
white_text: '白色文本',
black_text: '黑色文本',
border_background: '边框和背景',
border_effect: '边框效果',
transparent_background: '透明度背景',
color_variants: '颜色变体',
complementary_color: '互补色',
analogous_harmony: '邻近和谐色',
usage_guide: {
title: '颜色工具使用说明',
content: '本工具提供颜色选择、格式转换和调色板功能,支持:',
features: [
'HEX、RGB、HSL三种颜色格式的转换和复制',
'亮度色阶展示,快速选择不同亮度的相同颜色',
'互补色和邻近色展示,帮助设计和谐的配色方案',
'个性化调色板保存,支持添加和删除自定义颜色'
]
},
copy_success: '已复制',
copy_failed: '复制失败'
};
export default colorToolsZh;
+75
View File
@@ -0,0 +1,75 @@
export const cryptoToolsEn = {
title: 'Encryption Tools',
description: 'Common hash and encryption algorithms like MD5/SHA/AES/DES',
// Algorithm names and descriptions
algorithms: {
md5: {
name: 'MD5',
description: 'A widely used hash function that produces a 128-bit (16-byte) hash value. Irreversible, not suitable for security scenarios.',
additional_info: 'MD5 can be used for file verification, but should not be used for security purposes as it has been proven vulnerable to collision attacks.'
},
sha1: {
name: 'SHA-1',
description: 'Produces a 160-bit (20-byte) hash value. Irreversible, more secure than MD5, but not recommended for security-sensitive applications.',
additional_info: 'Although SHA-1 is more secure than MD5, it has also been proven vulnerable to collisions and is recommended for non-security scenarios.'
},
sha256: {
name: 'SHA-256',
description: 'A member of the SHA-2 family, produces a 256-bit (32-byte) hash value. Irreversible, relatively high security.',
additional_info: 'SHA-256 is a member of the SHA-2 family and is widely used for password storage and digital signatures.'
},
sha512: {
name: 'SHA-512',
description: 'A member of the SHA-2 family, produces a 512-bit (64-byte) hash value. Irreversible, very high security.',
additional_info: 'SHA-512 provides higher security and is suitable for scenarios with extremely high security requirements.'
},
aes: {
name: 'AES',
description: 'A symmetric encryption algorithm that requires a key, can be used to encrypt and decrypt data.',
additional_info: 'AES is currently the most popular symmetric encryption algorithm. This tool uses AES-256-CBC mode. The same key is required for both encryption and decryption.'
},
base64: {
name: 'Base64',
description: 'An encoding method that can be used to transmit binary data between incompatible systems. Can encode and decode.',
additional_info: 'Base64 is not an encryption algorithm but an encoding method, commonly used to transmit binary data in text protocols.'
}
},
// UI elements
input_text: 'Original Text',
encrypted_text: 'Encrypted Text',
base64_encoded: 'Base64 Encoded',
secret_key: 'Secret Key',
result: 'Result',
hash_result: 'Hash Result',
encrypted_result: 'Encryption Result',
decoded_result: 'Decoded Result',
// Buttons and operations
encode: 'Encode',
decode: 'Decode',
encrypt: 'Encrypt',
decrypt: 'Decrypt',
calculate: 'Calculate',
copy_result: 'Copy Result',
copied: 'Copied',
clear: 'Clear',
load_example: 'Load Example',
// Placeholders
input_placeholder: 'Please enter text to process...',
decrypt_placeholder: 'Please enter ciphertext to decrypt...',
base64_decode_placeholder: 'Please enter Base64 encoded text to decode...',
key_placeholder: 'Please enter secret key...',
result_placeholder: 'Result will be shown here...',
// Status messages
operation_success: 'Operation completed successfully',
encryption_success: 'Encryption completed successfully',
decryption_success: 'Decryption completed successfully',
copy_failed: 'Failed to copy to clipboard',
input_required: 'Please enter text to process',
key_required: 'Please enter secret key',
decryption_failed: 'Decryption failed, please check if the ciphertext and key are correct',
base64_decode_failed: 'Base64 decoding failed, please check if the input format is correct',
// Algorithm information
algorithm_info: 'Algorithm Information'
};
export default cryptoToolsEn;
@@ -0,0 +1,9 @@
import cryptoToolsZh from './zh';
import cryptoToolsEn from './en';
export const cryptoTools = {
zh: cryptoToolsZh,
en: cryptoToolsEn
};
export default cryptoTools;
+75
View File
@@ -0,0 +1,75 @@
export const cryptoToolsZh = {
title: '加密解密工具',
description: 'MD5/SHA/AES/DES等常用哈希和加密算法',
// 算法名称和描述
algorithms: {
md5: {
name: 'MD5',
description: '一种广泛使用的哈希函数,产生128位(16字节)哈希值。不可逆,不适用于安全场景。',
additional_info: 'MD5可以用于文件校验,但因已被证明存在碰撞攻击,不应用于安全场景。'
},
sha1: {
name: 'SHA-1',
description: '产生160位(20字节)哈希值。不可逆,安全性比MD5高,但不推荐用于安全敏感应用。',
additional_info: 'SHA-1虽比MD5更安全,但也已被证明存在碰撞风险,建议用于非安全场景。'
},
sha256: {
name: 'SHA-256',
description: 'SHA-2家族的成员,产生256位(32字节)哈希值。不可逆,安全性较高。',
additional_info: 'SHA-256是SHA-2家族的一员,被广泛用于密码存储和数字签名。'
},
sha512: {
name: 'SHA-512',
description: 'SHA-2家族的成员,产生512位(64字节)哈希值。不可逆,安全性很高。',
additional_info: 'SHA-512提供更高安全性,适用于对安全性要求极高的场景。'
},
aes: {
name: 'AES',
description: '对称加密算法,需要密钥,可用于加密和解密数据。',
additional_info: 'AES是目前最流行的对称加密算法,本工具使用的是AES-256-CBC模式。加密和解密都需要相同的密钥。'
},
base64: {
name: 'Base64',
description: '一种编码方式,可用于在不兼容的系统之间传输二进制数据。可编码解码。',
additional_info: 'Base64不是加密算法,而是一种编码方式,常用于在文本协议中传输二进制数据。'
}
},
// 界面元素
input_text: '原始文本',
encrypted_text: '加密文本',
base64_encoded: 'Base64编码',
secret_key: '密钥',
result: '结果',
hash_result: '哈希结果',
encrypted_result: '加密结果',
decoded_result: '解码结果',
// 按钮和操作
encode: '编码',
decode: '解码',
encrypt: '加密',
decrypt: '解密',
calculate: '计算',
copy_result: '复制结果',
copied: '已复制',
clear: '清空',
load_example: '加载示例',
// 占位符
input_placeholder: '请输入需要处理的文本...',
decrypt_placeholder: '请输入需要解密的密文...',
base64_decode_placeholder: '请输入需要解码的Base64编码...',
key_placeholder: '请输入密钥...',
result_placeholder: '结果将显示在这里...',
// 状态消息
operation_success: '操作成功完成',
encryption_success: '加密操作成功完成',
decryption_success: '解密操作成功完成',
copy_failed: '复制到剪贴板失败',
input_required: '请输入需要处理的文本',
key_required: '请输入密钥',
decryption_failed: '解密失败,请检查密文和密钥是否正确',
base64_decode_failed: 'Base64解码失败,请检查输入格式是否正确',
// 算法说明
algorithm_info: '算法说明'
};
export default cryptoToolsZh;
@@ -0,0 +1,39 @@
export const cssGradientGeneratorEn = {
title: 'CSS Gradient Generator',
description: 'Generate CSS gradient code online, supporting linear and radial gradients',
gradient_type: 'Gradient Type',
linear_gradient: 'Linear Gradient',
radial_gradient: 'Radial Gradient',
gradient_direction: 'Gradient Direction',
gradient_shape_position: 'Gradient Shape & Position',
custom_angle: 'Custom Angle',
apply: 'Apply',
gradient_shape: 'Gradient Shape',
circle: 'Circle',
ellipse: 'Ellipse',
gradient_position: 'Gradient Position',
gradient_colors: 'Gradient Colors',
add_color_stop: 'Add Color Stop',
random_gradient: 'Random Gradient',
preset_colors: 'Preset Color Combinations',
apply_preset: 'Apply Preset Colors',
gradient_preview: 'Gradient Preview',
preview_hint: 'Click the gradient preview above to view full-screen effect. Adjust parameters on the left to preview gradient changes in real-time.',
css_code: 'CSS Code',
css_comment: '/* CSS Gradient Code */',
copy_code: 'Copy Code',
copied: 'Copied',
copy_failed: 'Copy Failed',
direction_titles: {
'225deg': 'Top Left to Bottom Right',
'270deg': 'Top to Bottom',
'315deg': 'Top Right to Bottom Left',
'180deg': 'Left to Right',
'0deg': 'Right to Left',
'135deg': 'Bottom Left to Top Right',
'90deg': 'Bottom to Top',
'45deg': 'Bottom Right to Top Left'
}
};
export default cssGradientGeneratorEn;
@@ -0,0 +1,9 @@
import cssGradientGeneratorZh from './zh';
import cssGradientGeneratorEn from './en';
export const cssGradientGenerator = {
zh: cssGradientGeneratorZh,
en: cssGradientGeneratorEn
};
export default cssGradientGenerator;
@@ -0,0 +1,39 @@
export const cssGradientGeneratorZh = {
title: 'CSS渐变生成器',
description: '在线生成CSS渐变代码,支持线性和径向渐变',
gradient_type: '渐变类型',
linear_gradient: '线性渐变',
radial_gradient: '径向渐变',
gradient_direction: '渐变方向',
gradient_shape_position: '渐变形状与位置',
custom_angle: '自定义角度',
apply: '应用',
gradient_shape: '渐变形状',
circle: '圆形',
ellipse: '椭圆形',
gradient_position: '渐变位置',
gradient_colors: '渐变颜色',
add_color_stop: '添加颜色停止点',
random_gradient: '随机渐变',
preset_colors: '预设颜色组合',
apply_preset: '应用预设颜色',
gradient_preview: '渐变预览',
preview_hint: '点击上方的渐变预览可查看全屏效果。调整左侧的参数可实时预览渐变效果变化。',
css_code: 'CSS代码',
css_comment: '/* CSS渐变代码 */',
copy_code: '复制代码',
copied: '已复制',
copy_failed: '复制失败',
direction_titles: {
'225deg': '左上到右下',
'270deg': '上到下',
'315deg': '右上到左下',
'180deg': '左到右',
'0deg': '右到左',
'135deg': '左下到右上',
'90deg': '下到上',
'45deg': '右下到左上'
}
};
export default cssGradientGeneratorZh;
@@ -0,0 +1,56 @@
export const dateCalculatorEn = {
title: 'Date Calculator',
description: 'Calculate the difference between two dates or add/subtract time from a date',
mode: {
diff: 'Date Difference',
add: 'Date Addition/Subtraction'
},
diff_calculator: {
title: 'Calculate the difference between two dates',
start_date: 'Start Date:',
end_date: 'End Date:',
current: 'Current',
swap_dates: 'Swap Dates',
result_title: 'Calculation Result:',
years: 'Years:',
months: 'Months:',
weeks: 'Weeks:',
days: 'Days:',
hours: 'Hours:',
minutes: 'Minutes:',
seconds: 'Seconds:',
year_unit: 'years',
month_unit: 'months',
week_unit: 'weeks',
day_unit: 'days',
hour_unit: 'hours',
minute_unit: 'minutes',
second_unit: 'seconds',
no_valid_dates: 'Please select valid start and end dates'
},
add_calculator: {
title: 'Date Addition/Subtraction',
base_date: 'Base Date:',
operation: 'Select Operation:',
add: 'Add',
subtract: 'Subtract',
time_amount: 'Time Amount:',
time_unit: 'Time Unit:',
result_title: 'Calculation Result:',
add_result: 'Date after adding {amount} {unit}:',
subtract_result: 'Date after subtracting {amount} {unit}:',
copied: 'Copied',
copy_result: 'Copy Result',
no_valid_input: 'Please select a valid base date and time amount',
notes_title: 'Notes:',
note1: 'Adding/subtracting months and years may be affected by different month lengths',
note2: 'Calculations automatically handle leap years and leap months',
note3: 'Results account for daylight saving time (if applicable)'
},
error: {
calculation_error: 'Date calculation error',
copy_failed: 'Copy failed'
}
};
export default dateCalculatorEn;
@@ -0,0 +1,9 @@
import dateCalculatorZh from './zh';
import dateCalculatorEn from './en';
export const dateCalculator = {
zh: dateCalculatorZh,
en: dateCalculatorEn
};
export default dateCalculator;
@@ -0,0 +1,56 @@
export const dateCalculatorZh = {
title: '日期计算器',
description: '计算两个日期之间的差值或给日期添加/减去指定时间',
mode: {
diff: '日期差值计算',
add: '日期加减计算'
},
diff_calculator: {
title: '计算两个日期之间的差值',
start_date: '开始日期:',
end_date: '结束日期:',
current: '当前',
swap_dates: '交换日期',
result_title: '计算结果:',
years: '年份:',
months: '月份:',
weeks: '周数:',
days: '天数:',
hours: '小时:',
minutes: '分钟:',
seconds: '秒数:',
year_unit: '年',
month_unit: '月',
week_unit: '周',
day_unit: '天',
hour_unit: '小时',
minute_unit: '分钟',
second_unit: '秒',
no_valid_dates: '请选择有效的开始和结束日期'
},
add_calculator: {
title: '日期加减计算',
base_date: '基准日期:',
operation: '选择操作:',
add: '添加',
subtract: '减去',
time_amount: '时间量:',
time_unit: '时间单位:',
result_title: '计算结果:',
add_result: '添加 {amount} {unit} 后的日期是:',
subtract_result: '减去 {amount} {unit} 后的日期是:',
copied: '已复制',
copy_result: '复制结果',
no_valid_input: '请选择有效的基准日期和时间量',
notes_title: '注意事项:',
note1: '月份和年份的加减可能受到不同月份天数的影响',
note2: '计算会自动处理闰年和闰月',
note3: '结果已考虑夏令时影响(如果适用)'
},
error: {
calculation_error: '日期计算错误',
copy_failed: '复制失败'
}
};
export default dateCalculatorZh;
@@ -0,0 +1,33 @@
export const encodingConverterEn = {
title: 'Encoding Converter',
description: 'Base64/URL/Unicode encoding and decoding tool',
base64_desc: 'Encode text to Base64 string or decode Base64 string to text',
url_desc: 'Encode or decode special characters in URLs',
unicode_desc: 'Convert text to Unicode encoding or Unicode encoding to text',
html_desc: 'Encode or decode special characters in HTML',
encode: 'Encode',
decode: 'Decode',
load_example: 'Load Example',
clear_all: 'Clear All',
text_to_encode: 'Input Text',
text_to_decode: 'Input Encoded Text',
encoded_result: 'Encoded Result',
decoded_result: 'Decoded Result',
swap_operation: 'Swap Operation',
input_placeholder: 'Enter content to process...',
output_placeholder: 'Results will be displayed here...',
copy: 'Copy Result',
example_text: 'Hello, World!',
hello: 'Hello',
html_example: 'HTML Example & Special Characters',
general_error: 'An error occurred during conversion',
unsupported_type: 'Unsupported encoding type',
invalid_base64: 'Invalid Base64 encoded string',
invalid_url: 'Invalid URL encoded string',
invalid_unicode: 'Invalid Unicode encoded string',
invalid_html: 'Invalid HTML encoded string',
copy_failed: 'Copy failed:',
clipboard_error: 'Failed to copy to clipboard'
};
export default encodingConverterEn;
@@ -0,0 +1,9 @@
import encodingConverterZh from './zh';
import encodingConverterEn from './en';
export const encodingConverter = {
zh: encodingConverterZh,
en: encodingConverterEn
};
export default encodingConverter;
@@ -0,0 +1,33 @@
export const encodingConverterZh = {
title: '编码转换工具',
description: 'Base64/URL/Unicode编解码转换工具',
base64_desc: '将文本编码为Base64字符串或将Base64字符串解码为文本',
url_desc: '对URL中的特殊字符进行编码或解码',
unicode_desc: '将文本转换为Unicode编码或将Unicode编码转换为文本',
html_desc: '对HTML中的特殊字符进行编码或解码',
encode: '编码',
decode: '解码',
load_example: '加载示例',
clear_all: '清空',
text_to_encode: '输入文本',
text_to_decode: '输入编码',
encoded_result: '编码结果',
decoded_result: '解码结果',
swap_operation: '交换操作',
input_placeholder: '输入要处理的内容...',
output_placeholder: '结果将在这里显示...',
copy: '复制结果',
example_text: '你好,世界!',
hello: '你好',
html_example: 'HTML示例 & 特殊字符',
general_error: '转换过程中发生错误',
unsupported_type: '不支持的编码类型',
invalid_base64: '无效的Base64编码字符串',
invalid_url: '无效的URL编码字符串',
invalid_unicode: '无效的Unicode编码字符串',
invalid_html: '无效的HTML编码字符串',
copy_failed: '复制失败:',
clipboard_error: '复制到剪贴板失败'
};
export default encodingConverterZh;
@@ -0,0 +1,37 @@
export const htmlMarkdownConverterEn = {
title: 'HTML & Markdown Converter',
description: 'Convert between HTML and Markdown formats',
md2html: 'Markdown → HTML',
html2md: 'HTML → Markdown',
load_example: 'Load Example',
clear: 'Clear',
loading_modules: 'Loading conversion modules, please wait...',
md_input: 'Markdown Input',
html_input: 'HTML Input',
md_output: 'Markdown Output',
html_output: 'HTML Output',
exchange: 'Exchange Input/Output',
convert: 'Convert',
converting: 'Converting...',
md_placeholder: 'Enter Markdown content...',
html_placeholder: 'Enter HTML content...',
md_result_placeholder: 'Markdown conversion result will appear here...',
html_result_placeholder: 'HTML conversion result will appear here...',
copy_result: 'Copy Result',
copied: 'Copied',
error_empty: 'Please enter content to convert',
error_convert: 'Conversion failed: {error}',
error_unknown: 'Unknown error',
error_copy: 'Failed to copy to clipboard',
error_load: 'Failed to load conversion libraries, please refresh the page',
feature_title: 'Features',
feature_intro: 'This tool provides bidirectional conversion between HTML and Markdown, supporting the following features:',
feature_1: 'Complete Markdown syntax support, including headings, lists, code blocks, tables, etc.',
feature_2: 'Preserves HTML formatting and styles',
feature_3: 'Supports nested structures, such as nested lists and quotes',
feature_4: 'Maintains clear document structure after conversion',
md2html_description: 'Use this tool to convert Markdown documents into HTML code for web display.',
html2md_description: 'Use this tool to convert HTML code into more readable and writable Markdown format.'
};
export default htmlMarkdownConverterEn;
@@ -0,0 +1,9 @@
import htmlMarkdownConverterZh from './zh';
import htmlMarkdownConverterEn from './en';
export const htmlMarkdownConverter = {
zh: htmlMarkdownConverterZh,
en: htmlMarkdownConverterEn
};
export default htmlMarkdownConverter;
@@ -0,0 +1,37 @@
export const htmlMarkdownConverterZh = {
title: 'HTML与Markdown互转',
description: '在HTML和Markdown格式之间进行双向转换',
md2html: 'Markdown → HTML',
html2md: 'HTML → Markdown',
load_example: '加载示例',
clear: '清空',
loading_modules: '正在加载转换模块,请稍候...',
md_input: 'Markdown 输入',
html_input: 'HTML 输入',
md_output: 'Markdown 输出',
html_output: 'HTML 输出',
exchange: '交换输入输出',
convert: '转换',
converting: '转换中...',
md_placeholder: '请输入Markdown内容...',
html_placeholder: '请输入HTML内容...',
md_result_placeholder: 'Markdown 转换结果将显示在这里...',
html_result_placeholder: 'HTML 转换结果将显示在这里...',
copy_result: '复制结果',
copied: '已复制',
error_empty: '请输入需要转换的内容',
error_convert: '转换失败: {error}',
error_unknown: '未知错误',
error_copy: '复制到剪贴板失败',
error_load: '加载转换库失败,请刷新页面重试',
feature_title: '功能说明',
feature_intro: '此工具提供HTML与Markdown之间的双向转换功能,支持以下特性:',
feature_1: '完整的Markdown语法支持,包括标题、列表、代码块、表格等',
feature_2: '保留HTML格式和样式',
feature_3: '支持嵌套结构,如嵌套列表和引用',
feature_4: '转换后保持文档结构清晰',
md2html_description: '使用此工具可以将Markdown文档转换为可在网页中显示的HTML代码。',
html2md_description: '使用此工具可以将HTML代码转换为更易读写的Markdown格式。'
};
export default htmlMarkdownConverterZh;
+82
View File
@@ -0,0 +1,82 @@
export const httpTesterEn = {
title: 'HTTP Request Tester',
description: 'API interface debugging tool, supporting various request methods and parameter settings',
// Request form
http_request: 'HTTP Request',
clear: 'Clear',
enter_url: 'Enter URL',
send_request: 'Send Request',
public_network: 'Public',
local_network: 'Local/LAN',
// Request parameter tabs
request_headers: 'Request Headers',
request_body: 'Request Body',
// Request headers section
add_header: 'Add Header',
header_key: 'Name',
header_value: 'Value',
// Request body section
body_format: 'Body Format',
json_format: 'JSON',
text_format: 'Text',
form_format: 'Form',
enter_request_body: 'Enter request body content',
add_form_field: 'Add Form Field',
form_field_key: 'Field Name',
form_field_value: 'Field Value',
// History
history: 'History',
history_empty: 'No history records',
clear_history: 'Clear History',
clear_history_confirm: 'Are you sure you want to clear the history?',
// Response results
response_result: 'Response Result',
copied: 'Copied',
copy: 'Copy',
generate_doc: 'Generate Doc',
response_body: 'Response Body',
response_headers: 'Response Headers',
request_info: 'Request Info',
// Request information
request_method: 'Request Method',
request_url: 'Request URL',
request_body_sent: 'Request Body',
network_mode: 'Network Mode',
network_mode_public: 'Public Network Mode',
network_mode_local: 'Local/LAN Mode',
// Status and errors
loading: 'Loading...',
copy_failed: 'Copy failed',
// CORS settings modal
cors_settings: 'Local/LAN CORS Settings Guide',
close: 'Close',
cors_description: 'When sending requests to local or LAN services, the browser\'s same-origin policy blocks cross-origin requests. To allow these requests, you need to configure CORS (Cross-Origin Resource Sharing) response headers on the server side.',
cors_warning: 'In production environments, please configure CORS carefully. It is recommended to use stricter settings rather than allowing access from all sources. The following configurations are for local development and testing only.',
// HTTPS to HTTP issues
https_to_http_title: 'HTTPS to HTTP Service Access Issues',
https_to_http_description: 'When you access HTTP internal services through an HTTPS website, the browser\'s security policy blocks such "mixed content" requests. Here are the recommended solutions:',
solution_one: 'Solution 1: Configure HTTPS certificates for internal services',
solution_one_1: 'Use self-signed certificates (development environment)',
solution_one_2: 'Use intranet CA issued certificates (enterprise environment)',
solution_one_3: 'Modify internal service configuration, enable HTTPS',
solution_two: 'Solution 2: Adjust browser security settings (development environment only)',
solution_two_1: 'Chrome: Click the lock icon in the address bar → Site settings → Insecure content → Allow',
solution_two_2: 'Firefox: Enter about:config in the address bar → Search and disable security.mixed_content.block_active_content',
solution_two_3: 'Edge: Click the lock icon in the address bar → Site permissions → Insecure content → Allow',
security_note: 'Note: Adjusting browser security settings is only recommended for development environments and will reduce browser security. Production environments should use the HTTPS certificate solution.',
// Other tips
need_advanced: 'Need advanced testing features? Try RunAPI'
};
export default httpTesterEn;
@@ -0,0 +1,9 @@
import httpTesterZh from './zh';
import httpTesterEn from './en';
export const httpTester = {
zh: httpTesterZh,
en: httpTesterEn
};
export default httpTester;
+82
View File
@@ -0,0 +1,82 @@
export const httpTesterZh = {
title: 'HTTP请求测试',
description: 'API接口调试工具,支持多种请求方法和参数设置',
// 请求表单
http_request: 'HTTP请求',
clear: '清空',
enter_url: '输入URL',
send_request: '发送请求',
public_network: '公网',
local_network: '本地/局域网',
// 请求参数标签页
request_headers: '请求头',
request_body: '请求体',
// 请求头部分
add_header: '添加请求头',
header_key: '名称',
header_value: '值',
// 请求体部分
body_format: '请求体格式',
json_format: 'JSON',
text_format: '文本',
form_format: '表单',
enter_request_body: '输入请求体内容',
add_form_field: '添加表单字段',
form_field_key: '字段名',
form_field_value: '字段值',
// 历史记录
history: '历史记录',
history_empty: '暂无历史记录',
clear_history: '清空历史',
clear_history_confirm: '确定要清空历史记录吗?',
// 响应结果
response_result: '响应结果',
copied: '已复制',
copy: '复制',
generate_doc: '生成文档',
response_body: '响应体',
response_headers: '响应头',
request_info: '请求信息',
// 请求信息
request_method: '请求方法',
request_url: '请求URL',
request_body_sent: '请求体',
network_mode: '网络模式',
network_mode_public: '公网模式',
network_mode_local: '本地/局域网模式',
// 状态和错误
loading: '请求中...',
copy_failed: '复制失败',
// 跨域设置弹窗
cors_settings: '本地/局域网跨域设置指南',
close: '关闭',
cors_description: '当发送请求到本地或局域网服务时,浏览器的同源策略会阻止跨域请求。为了允许这些请求,你需要在服务端配置CORS(跨域资源共享)响应头。',
cors_warning: '在生产环境中,请谨慎配置CORS,建议使用更严格的设置,而不是允许所有源访问。以下配置仅用于本地开发和测试。',
// HTTPS访问HTTP问题
https_to_http_title: 'HTTPS访问HTTP服务问题',
https_to_http_description: '当您通过HTTPS网站访问HTTP内部服务时,浏览器的安全策略会阻止这种"混合内容"请求。以下是推荐的解决方案:',
solution_one: '方案一:为内部服务配置HTTPS证书',
solution_one_1: '使用自签名证书(开发环境)',
solution_one_2: '使用内网CA签发证书(企业环境)',
solution_one_3: '修改内部服务配置,启用HTTPS',
solution_two: '方案二:调整浏览器安全设置(仅开发环境)',
solution_two_1: 'Chrome: 点击地址栏锁图标 → 站点设置 → 不安全内容 → 允许',
solution_two_2: 'Firefox: 地址栏输入 about:config → 搜索并禁用 security.mixed_content.block_active_content',
solution_two_3: 'Edge: 点击地址栏锁图标 → 站点权限 → 不安全内容 → 允许',
security_note: '注意:调整浏览器安全设置仅建议在开发环境使用,会降低浏览器安全性。生产环境应使用HTTPS证书方案。',
// 其他提示
need_advanced: '需要高级测试功能? 试试RunAPI'
};
export default httpTesterZh;
@@ -0,0 +1,40 @@
export const imageCompressorEn = {
title: 'Image Compressor',
description: 'Pure frontend image compression tool supporting multiple formats',
select_image: 'Select Image File',
choose_file: 'Choose File',
compression_settings: 'Compression Settings',
quality: 'Quality',
max_width: 'Max Width (pixels)',
max_height: 'Max Height (pixels)',
output_format: 'Output Format',
auto_format: 'Auto (Keep Original Format)',
maintain_ratio: 'Maintain Aspect Ratio',
compression_info: 'Compression Info',
original_size: 'Original Size',
compressed_size: 'Compressed Size',
compression_ratio: 'Compression Ratio',
compress_image: 'Compress Image',
compressing: 'Compressing...',
download_compressed: 'Download Compressed Image',
reset: 'Reset',
original_image: 'Original Image',
compressed_image: 'Compressed Image',
no_image_selected: 'No Image Selected',
no_compressed_image: 'No Compressed Image',
compression_failed: 'Compression Failed',
bytes: 'Bytes',
kb: 'KB',
mb: 'MB',
gb: 'GB',
usage_guide: 'Usage Guide',
guide_1: 'Supports compression of multiple image formats, including JPEG, PNG, and WebP',
guide_2: 'Adjust compression quality and maximum dimensions to balance file size and image quality',
guide_3: 'Maintains original aspect ratio to prevent image distortion',
guide_4: 'Compression is performed in the browser, no images are uploaded to servers',
guide_5: 'Preview images before and after compression to see the effect and file size changes',
guide_6: 'Supports batch processing of multiple images',
guide_7: 'Compressed images can be downloaded directly for use'
};
export default imageCompressorEn;
@@ -0,0 +1,9 @@
import imageCompressorZh from './zh';
import imageCompressorEn from './en';
export const imageCompressor = {
zh: imageCompressorZh,
en: imageCompressorEn
};
export default imageCompressor;
@@ -0,0 +1,40 @@
export const imageCompressorZh = {
title: '图片压缩',
description: '纯前端实现的图片压缩工具,支持多种格式',
select_image: '选择图片文件',
choose_file: '选择文件',
compression_settings: '压缩设置',
quality: '质量',
max_width: '最大宽度 (像素)',
max_height: '最大高度 (像素)',
output_format: '输出格式',
auto_format: '自动 (保持原格式)',
maintain_ratio: '保持宽高比',
compression_info: '压缩信息',
original_size: '原始大小',
compressed_size: '压缩后大小',
compression_ratio: '压缩率',
compress_image: '压缩图片',
compressing: '压缩中...',
download_compressed: '下载压缩图片',
reset: '重置',
original_image: '原始图片',
compressed_image: '压缩后图片',
no_image_selected: '未选择图片',
no_compressed_image: '未压缩图片',
compression_failed: '压缩失败',
bytes: '字节',
kb: 'KB',
mb: 'MB',
gb: 'GB',
usage_guide: '使用说明',
guide_1: '支持多种图片格式的压缩,包括JPEG、PNG和WebP',
guide_2: '可以调整压缩质量和最大尺寸,平衡文件大小和图片质量',
guide_3: '支持保持原始宽高比,避免图片变形',
guide_4: '压缩过程在浏览器中完成,不会上传图片到服务器',
guide_5: '可以预览压缩前后的图片效果和文件大小变化',
guide_6: '支持批量处理多张图片',
guide_7: '压缩后的图片可以直接下载使用'
};
export default imageCompressorZh;
+47
View File
@@ -0,0 +1,47 @@
import jsonFormatter from './json_formatter';
import regexTester from './regex_tester';
import timestampConverter from './timestamp_converter';
import encodingConverter from './encoding_converter';
import codeFormatter from './code_formatter';
import colorTools from './color_tools';
import cryptoTools from './crypto_tools';
import cssGradientGenerator from './css_gradient_generator';
import dateCalculator from './date_calculator';
import htmlMarkdownConverter from './html_markdown_converter';
import httpTester from './http_tester';
import imageCompressor from './image_compressor';
import ipLookup from './ip_lookup';
import jsonConverter from './json_converter';
import jsonEditor from './json_editor';
import jwtDecoder from './jwt_decoder';
import qrcodeGenerator from './qrcode_generator';
import textCounter from './text_counter';
import timezoneConverter from './timezone_converter';
import unicodeConverter from './unicode_converter';
import urlEncoder from './url_encoder';
export const tools = {
json_formatter: jsonFormatter,
regex_tester: regexTester,
timestamp_converter: timestampConverter,
encoding_converter: encodingConverter,
code_formatter: codeFormatter,
color_tools: colorTools,
crypto_tools: cryptoTools,
css_gradient_generator: cssGradientGenerator,
date_calculator: dateCalculator,
html_markdown_converter: htmlMarkdownConverter,
http_tester: httpTester,
image_compressor: imageCompressor,
ip_lookup: ipLookup,
json_converter: jsonConverter,
json_editor: jsonEditor,
jwt_decoder: jwtDecoder,
qrcode_generator: qrcodeGenerator,
text_counter: textCounter,
timezone_converter: timezoneConverter,
unicode_converter: unicodeConverter,
url_encoder: urlEncoder
};
export default tools;
+68
View File
@@ -0,0 +1,68 @@
export const ipLookupEn = {
title: 'IP Address Lookup',
description: 'Query detailed information about IP addresses, including geolocation and ISP details',
input_placeholder: 'Enter IP address to query',
my_ip: 'My IP',
search: 'Search',
copy: 'Copy',
copied: 'Copied',
unknown: 'Unknown',
result_title: 'Query Results',
input_label: 'Enter IP Address:',
input_placeholder_example: 'Example: 8.8.8.8',
search_button: 'Search',
my_ip_button: 'Lookup My IP Address',
instruction_title: 'Instructions:',
instructions: {
line1: 'Enter an IP address and click the "Search" button',
line2: 'Supports both IPv4 and IPv6 addresses',
line3: 'Click "Lookup My IP Address" to get your current IP information',
line4: 'Results include geolocation, ISP and technical details'
},
results: {
title: 'Query Results',
empty_state: 'Enter an IP address and click search to get detailed information',
ip_address: 'IP Address'
},
ip_info: {
ip: 'IP Address',
country: 'Country/Region',
region: 'Province',
city: 'City',
isp: 'ISP',
timezone: 'Timezone',
coordinates: 'Coordinates',
source: 'Data Source',
ip_type: 'IP Type',
ip_class: 'IP Class',
binary: 'Binary',
hex: 'Hexadecimal'
},
technical_details: 'Technical Details',
ip_classes: {
private: 'Private IP',
public: 'Public IP',
loopback: 'Loopback Address',
link_local: 'Link-local Address'
},
errors: {
invalid_ip: 'Invalid IP address',
query_failed: 'Query failed, please try again later',
copy_failed: 'Copy failed'
},
api_sources: {
pconline: 'PConline',
ipcn: 'IP.CN',
ipapi: 'ip-api.com',
baidu: 'Baidu IP',
meitu: 'Meitu IP'
},
console_errors: {
pconline: 'PConline data parsing error',
ipcn: 'IP.CN data parsing error',
ipapi: 'ip-api.com data parsing error',
meitu: 'Meitu IP data parsing error'
}
};
export default ipLookupEn;
+9
View File
@@ -0,0 +1,9 @@
import ipLookupZh from './zh';
import ipLookupEn from './en';
export const ipLookup = {
zh: ipLookupZh,
en: ipLookupEn
};
export default ipLookup;
+68
View File
@@ -0,0 +1,68 @@
export const ipLookupZh = {
title: 'IP地址查询',
description: '查询IP地址的详细信息,包括地理位置、运营商等信息',
input_placeholder: '请输入要查询的IP地址',
my_ip: '我的IP',
search: '查询',
copy: '复制',
copied: '已复制',
unknown: '未知',
result_title: '查询结果',
input_label: '输入IP地址:',
input_placeholder_example: '例如: 8.8.8.8',
search_button: '查询',
my_ip_button: '查询我的IP地址',
instruction_title: '使用说明:',
instructions: {
line1: '输入IP地址后点击"查询"按钮',
line2: '支持IPv4和IPv6地址查询',
line3: '点击"查询我的IP地址"获取您当前的IP信息',
line4: '查询结果包含地理位置、运营商等信息'
},
results: {
title: '查询结果',
empty_state: '输入IP地址并点击查询按钮获取详细信息',
ip_address: 'IP地址'
},
ip_info: {
ip: 'IP地址',
country: '国家/地区',
region: '省份',
city: '城市',
isp: '运营商',
timezone: '时区',
coordinates: '经纬度',
source: '数据来源',
ip_type: 'IP类型',
ip_class: 'IP类别',
binary: '二进制',
hex: '十六进制'
},
technical_details: '技术详情',
ip_classes: {
private: '私有IP',
public: '公网IP',
loopback: '回环地址',
link_local: '链路本地地址'
},
errors: {
invalid_ip: '无效的IP地址',
query_failed: '查询失败,请稍后重试',
copy_failed: '复制失败'
},
api_sources: {
pconline: '太平洋电脑网',
ipcn: 'IP.CN',
ipapi: 'ip-api.com',
baidu: '百度IP',
meitu: '美图IP'
},
console_errors: {
pconline: '太平洋电脑网数据解析错误',
ipcn: 'IP.CN数据解析错误',
ipapi: 'ip-api.com数据解析错误',
meitu: '美图IP数据解析错误'
}
};
export default ipLookupZh;
+100
View File
@@ -0,0 +1,100 @@
export const jsonConverterEn = {
title: 'JSON Converter',
description: 'Convert between JSON and XML/CSV/YAML formats',
select_format_type: 'Select Conversion Format Type',
format_types: {
xml: {
name: 'XML',
description: 'Convert between JSON and XML'
},
csv: {
name: 'CSV',
description: 'Convert between JSON and CSV'
},
yaml: {
name: 'YAML',
description: 'Convert between JSON and YAML'
}
},
direction: {
json_to_format: 'JSON',
format_to_json: '{format}'
},
actions: {
load_example: 'Load Example',
clear: 'Clear',
convert: 'Convert',
converting: 'Converting...',
copy: 'Copy',
copied: 'Copied',
download: 'Download'
},
advanced_options: {
show: 'Show Advanced Options',
hide: 'Hide Advanced Options',
csv: {
delimiter: 'Delimiter',
delimiters: {
comma: 'Comma (,)',
semicolon: 'Semicolon (;)',
tab: 'Tab',
pipe: 'Pipe (|)'
},
include_header: 'Include Header',
generate_header: 'Generate CSV Header',
parse_header: 'Parse CSV Header as Field Names'
},
xml: {
root_element: 'XML Root Element Name',
root_placeholder: 'Root element name'
},
description: 'These options will affect the format and structure of the conversion result.'
},
input: {
json: 'JSON Input',
format: '{format} Input',
json_placeholder: 'Please enter JSON data...',
format_placeholder: 'Please enter {format} data...'
},
output: {
json: 'JSON Output',
format: '{format} Output',
json_placeholder: 'Converted JSON will appear here...',
format_placeholder: 'Converted {format} will appear here...'
},
notes: {
title: 'Conversion Notes',
xml: {
title: 'Notes for JSON and XML conversion:',
items: [
'JSON object keys will be converted to XML elements',
'XML attributes may be lost during conversion',
'Array elements will be converted to repeated elements with the same name'
]
},
csv: {
title: 'Notes for JSON and CSV conversion:',
items: [
'CSV format is best suited for flat data structures',
'Nested objects will be flattened or merged into a single field',
'Complex structures may lose information during conversion'
]
},
yaml: {
title: 'Notes for JSON and YAML conversion:',
items: [
'YAML supports richer data types and comments',
'JSON is a strict subset of YAML',
'Most data structures are preserved during conversion'
]
}
},
errors: {
invalid_json: 'Invalid JSON format',
conversion_error: 'An error occurred during conversion',
copy_failed: 'Copy failed:',
clipboard_error: 'Failed to copy to clipboard'
}
};
export default jsonConverterEn;
@@ -0,0 +1,9 @@
import jsonConverterZh from './zh';
import jsonConverterEn from './en';
export const jsonConverter = {
zh: jsonConverterZh,
en: jsonConverterEn
};
export default jsonConverter;
+100
View File
@@ -0,0 +1,100 @@
export const jsonConverterZh = {
title: 'JSON转换工具',
description: 'JSON与XML/CSV/YAML格式互相转换',
select_format_type: '选择转换格式类型',
format_types: {
xml: {
name: 'XML',
description: 'JSON与XML之间互相转换'
},
csv: {
name: 'CSV',
description: 'JSON与CSV之间互相转换'
},
yaml: {
name: 'YAML',
description: 'JSON与YAML之间互相转换'
}
},
direction: {
json_to_format: 'JSON',
format_to_json: '{format}'
},
actions: {
load_example: '加载示例',
clear: '清空',
convert: '转换',
converting: '转换中...',
copy: '复制',
copied: '已复制',
download: '下载'
},
advanced_options: {
show: '显示高级选项',
hide: '隐藏高级选项',
csv: {
delimiter: '分隔符',
delimiters: {
comma: '逗号 (,)',
semicolon: '分号 (;)',
tab: '制表符 (Tab)',
pipe: '竖线 (|)'
},
include_header: '包含表头',
generate_header: '生成CSV表头',
parse_header: '解析CSV表头作为字段名'
},
xml: {
root_element: 'XML根元素名称',
root_placeholder: '根元素名称'
},
description: '这些选项将影响转换结果的格式和结构。'
},
input: {
json: 'JSON 输入',
format: '{format} 输入',
json_placeholder: '请输入JSON数据...',
format_placeholder: '请输入{format}数据...'
},
output: {
json: 'JSON 输出',
format: '{format} 输出',
json_placeholder: '转换后的JSON将显示在这里...',
format_placeholder: '转换后的{format}将显示在这里...'
},
notes: {
title: '转换说明',
xml: {
title: 'JSON与XML之间的转换注意事项:',
items: [
'JSON对象的键将转换为XML元素',
'XML属性在转换后可能会丢失',
'数组元素会被转换为同名的重复元素'
]
},
csv: {
title: 'JSON与CSV之间的转换注意事项:',
items: [
'CSV格式最适合扁平的数据结构',
'嵌套对象会被展平或合并为一个字段',
'复杂结构可能会在转换过程中丢失信息'
]
},
yaml: {
title: 'JSON与YAML之间的转换注意事项:',
items: [
'YAML支持更丰富的数据类型和注释',
'JSON是YAML的严格子集',
'转换过程中保留大部分数据结构'
]
}
},
errors: {
invalid_json: '输入的JSON格式无效',
conversion_error: '转换过程中发生错误',
copy_failed: '复制失败:',
clipboard_error: '复制到剪贴板失败'
}
};
export default jsonConverterZh;
+23
View File
@@ -0,0 +1,23 @@
export const jsonEditorEn = {
title: 'JSON Editor',
description: 'Online JSON data editing and modification',
editor_loading: 'Editor loading...',
copy: 'Copy',
download: 'Download',
upload: 'Upload',
clear: 'Clear',
file_name: 'File name:',
copied_to_clipboard: 'Copied to clipboard',
copy_failed: 'Copy failed:',
download_success: 'Downloaded {fileName}',
download_failed: 'Download failed: Invalid JSON data',
loaded_file: 'Loaded file {fileName}',
loaded_as_text: 'Loaded {fileName} as text (invalid JSON format)',
read_file_failed: 'Failed to read file',
editor_cleared: 'Editor has been cleared',
confirm_clear: 'Are you sure you want to clear the editor content?',
invalid_json: 'Unable to get valid JSON content',
edit_json_here: 'Edit your JSON data here'
};
export default jsonEditorEn;
@@ -0,0 +1,9 @@
import jsonEditorZh from './zh';
import jsonEditorEn from './en';
export const jsonEditor = {
zh: jsonEditorZh,
en: jsonEditorEn
};
export default jsonEditor;
+23
View File
@@ -0,0 +1,23 @@
export const jsonEditorZh = {
title: 'JSON编辑器',
description: '在线编辑与修改JSON数据',
editor_loading: '编辑器加载中...',
copy: '复制',
download: '下载',
upload: '上传',
clear: '清空',
file_name: '文件名:',
copied_to_clipboard: '已复制到剪贴板',
copy_failed: '复制失败:',
download_success: '已下载 {fileName}',
download_failed: '下载失败:无效的JSON数据',
loaded_file: '已加载文件 {fileName}',
loaded_as_text: '已以文本形式加载 {fileName}JSON格式无效)',
read_file_failed: '读取文件失败',
editor_cleared: '编辑器已清空',
confirm_clear: '确定要清空编辑器内容吗?',
invalid_json: '无法获取有效的JSON内容',
edit_json_here: '在这里编辑您的JSON数据'
};
export default jsonEditorZh;
@@ -0,0 +1,63 @@
export const jsonFormatterEn = {
title: 'JSON Formatter',
description: 'JSON data beautify and validation tool',
beautify: 'Beautify',
compress: 'Compress',
expand_all: 'Expand All',
collapse_all: 'Collapse All',
clear: 'Clear',
load_example: 'Load Example',
history: 'History',
save: 'Save',
cancel: 'Cancel',
input_json: 'Input JSON',
paste_json_here: 'Paste JSON here',
jsonpath_query: 'JSONPath Query',
optional: '(Optional)',
enter_jsonpath: 'Enter JSONPath expression, e.g.: $.store.book[0].title',
query_result: 'Query Result',
output: 'Formatted Result',
copy: 'Copy',
large_json_warning: 'Large File Mode',
json_history: 'JSON History',
history_empty: 'No history records',
remove_favorite: 'Remove Favorite',
add_favorite: 'Add Favorite',
edit_title: 'Edit Title',
delete: 'Delete',
save_to_history: 'Save to History',
enter_title: 'Enter Title',
json_valid: '✓ JSON is valid',
json_invalid: '✗ Invalid JSON',
large_json_processed: '✓ Large JSON processed ({size} KB)',
load_history_error: 'Failed to load history:',
copy_failed: 'Copy failed:',
normal_mode: 'Normal Mode',
fold_mode: 'Fold Mode',
reformat: 'Reformat',
processing: 'Processing...',
cancel_processing: 'Cancel Processing',
processing_large_json: 'Processing large JSON...',
processing_large_json_message: 'Processing large JSON...\nThis may take a few seconds',
parsing_json: 'Parsing JSON...',
characters: 'characters',
jsonpath_placeholder: 'Enter JSONPath query, e.g.: author.name',
query_result_placeholder: 'Query results will be shown here',
favorites: 'Favorites',
all_history: 'All History',
no_saved_records: 'No saved JSON records',
save_first_record: 'Click the "Save" button to add your first record',
edit_saved_json: 'Edit Saved JSON',
update: 'Update',
paste_json_placeholder: 'Please enter or paste JSON data...',
usage_guide: 'Usage Guide',
guide_1: 'Supports formatting and validating standard JSON data',
guide_2: 'Can process common non-standard JSON formats (like JS objects)',
guide_3: 'Supports fold mode for easy viewing of complex JSON structures',
guide_4: 'JSONPath queries support dot notation (e.g., author.name) and array indices (e.g., features[0])',
guide_5: 'Compress/Beautify mode can be freely switched',
guide_6: 'Processing large JSON data may take a few seconds',
guide_7: 'Multiple JSON data can be saved to history for comparison'
};
export default jsonFormatterEn;
@@ -0,0 +1,9 @@
import jsonFormatterZh from './zh';
import jsonFormatterEn from './en';
export const jsonFormatter = {
zh: jsonFormatterZh,
en: jsonFormatterEn
};
export default jsonFormatter;
@@ -0,0 +1,63 @@
export const jsonFormatterZh = {
title: 'JSON格式化',
description: 'JSON数据美化与验证工具',
beautify: '美化',
compress: '压缩',
expand_all: '全部展开',
collapse_all: '全部折叠',
clear: '清空',
load_example: '加载示例',
history: '历史记录',
save: '保存',
cancel: '取消',
input_json: '输入JSON',
paste_json_here: '粘贴JSON到此处',
jsonpath_query: 'JSONPath查询',
optional: '(可选)',
enter_jsonpath: '输入JSONPath表达式,例如: $.store.book[0].title',
query_result: '查询结果',
output: '格式化结果',
copy: '复制',
large_json_warning: '大文件模式',
json_history: 'JSON历史记录',
history_empty: '暂无历史记录',
remove_favorite: '取消收藏',
add_favorite: '添加收藏',
edit_title: '编辑标题',
delete: '删除',
save_to_history: '保存到历史记录',
enter_title: '输入标题',
json_valid: '✓ JSON校验通过',
json_invalid: '✗ 无效的JSON',
large_json_processed: '✓ 大型JSON已处理 ({size} KB)',
load_history_error: '加载历史记录失败:',
copy_failed: '复制失败:',
normal_mode: '普通模式',
fold_mode: '折叠模式',
reformat: '重新格式化',
processing: '处理中...',
cancel_processing: '取消处理',
processing_large_json: '处理大型JSON中...',
processing_large_json_message: '正在处理大型JSON...\n这可能需要几秒钟',
parsing_json: '正在解析JSON...',
characters: '字符',
jsonpath_placeholder: '输入JSONPath查询,例如: author.name',
query_result_placeholder: '查询结果将显示在这里',
favorites: '收藏项目',
all_history: '全部历史',
no_saved_records: '暂无保存的JSON记录',
save_first_record: '点击"保存"按钮添加您的第一条记录',
edit_saved_json: '编辑保存的JSON',
update: '更新',
paste_json_placeholder: '请输入或粘贴JSON数据...',
usage_guide: '使用说明',
guide_1: '支持格式化和验证标准JSON数据',
guide_2: '可以处理常见的不规范JSON格式(如JS对象)',
guide_3: '支持折叠模式,方便查看复杂JSON结构',
guide_4: 'JSONPath查询支持点符号(如author.name)和数组索引(如features[0])',
guide_5: '展示压缩/美化模式可以自由切换',
guide_6: '处理大型JSON数据可能需要几秒钟时间',
guide_7: '可以保存多个JSON数据到历史记录中,方便比较查看'
};
export default jsonFormatterZh;
+62
View File
@@ -0,0 +1,62 @@
export const jwtDecoderEn = {
title: 'JWT Decoder & Validator',
description: 'Decode and verify JSON Web Tokens',
decode_jwt: 'Decode JWT Token',
jwt_token: 'JWT Token:',
hide_parts: 'Hide Parts',
show_parts: 'Show Parts',
paste_jwt: 'Paste JWT token here...',
token_structure: 'JWT Token Structure',
header: 'Header',
payload: 'Payload',
signature: 'Signature',
parsing_error: 'Error parsing JWT token',
token_empty: 'JWT token cannot be empty',
invalid_format: 'Invalid JWT format, should be a three-part structure (header.payload.signature)',
parsing_failed: 'JWT parsing failed, may contain invalid JSON data',
invalid_base64: 'Invalid Base64 encoding',
expired: 'Expired',
days: 'days',
hours: 'hours',
minutes: 'minutes',
seconds: 'seconds',
copied_content: 'Content copied to clipboard',
copy_failed: 'Copy failed:',
clipboard_error: 'Failed to copy to clipboard',
token_copied: 'JWT token copied to clipboard',
load_example: 'Load Example',
clear: 'Clear',
copy_token: 'Copy Token',
jwt_status: 'JWT Status:',
format_valid: 'Format Valid',
format_invalid: 'Format Invalid',
expiration_status: 'Expiration Status:',
issue_time: 'Issue Time:',
header_tab: 'Header',
payload_tab: 'Payload',
signature_tab: 'Signature',
copied: 'Copied',
copy: 'Copy',
// Info panel
what_is_jwt: 'What is JWT?',
jwt_full_name: 'JSON Web Token (JWT) Decoder & Validator',
jwt_intro: 'JWT (JSON Web Token) is an open standard used to securely transmit information between parties as a compact and self-contained object.',
jwt_parts: 'JWT consists of three parts separated by dots (.):',
header_desc: 'Contains the token type and signing algorithm',
payload_desc: 'Contains claims, such as user info, permissions, etc.',
signature_desc: 'Used to verify the message signature',
tool_purpose: 'This tool allows you to quickly decode JWT tokens, verify their format, and check expiration status, but does not verify signature validity.',
common_use_cases: 'Common JWT Use Cases',
use_case_1: 'Authentication',
use_case_2: 'Information Exchange',
use_case_3: 'Single Sign On',
use_case_4: 'Authorization',
use_case_5: 'API Access Control',
// Expiration status
valid_remaining: 'Valid, remaining {time}',
not_set: 'No expiration time set',
// Example JWT user
example_user: 'JiSuXiang User'
};
export default jwtDecoderEn;
@@ -0,0 +1,9 @@
import jwtDecoderZh from './zh';
import jwtDecoderEn from './en';
export const jwtDecoder = {
zh: jwtDecoderZh,
en: jwtDecoderEn
};
export default jwtDecoder;
+62
View File
@@ -0,0 +1,62 @@
export const jwtDecoderZh = {
title: 'JWT解析与验证',
description: '解码和验证JSON Web Token',
decode_jwt: '解码JWT令牌',
jwt_token: 'JWT令牌:',
hide_parts: '隐藏分段',
show_parts: '显示分段',
paste_jwt: '粘贴JWT令牌到这里...',
token_structure: 'JWT令牌结构',
header: '头部',
payload: '载荷',
signature: '签名',
parsing_error: '解析JWT令牌时发生错误',
token_empty: 'JWT令牌不能为空',
invalid_format: '无效的JWT格式,应为三段式结构(header.payload.signature',
parsing_failed: 'JWT解析失败,可能包含无效的JSON数据',
invalid_base64: '无效的Base64编码',
expired: '已过期',
days: '天',
hours: '小时',
minutes: '分钟',
seconds: '秒',
copied_content: '内容已复制到剪贴板',
copy_failed: '复制失败:',
clipboard_error: '复制到剪贴板失败',
token_copied: 'JWT令牌已复制到剪贴板',
load_example: '加载示例',
clear: '清空',
copy_token: '复制令牌',
jwt_status: 'JWT状态:',
format_valid: '格式有效',
format_invalid: '格式无效',
expiration_status: '过期状态:',
issue_time: '签发时间:',
header_tab: '头部 (Header)',
payload_tab: '载荷 (Payload)',
signature_tab: '签名 (Signature)',
copied: '已复制',
copy: '复制',
// 信息面板
what_is_jwt: '什么是JWT',
jwt_full_name: 'JSON Web TokenJWT)解码与验证',
jwt_intro: 'JWTJSON Web Token)是一种开放标准,用于在网络应用间传递声明(Claims)的紧凑且自包含的方式。',
jwt_parts: 'JWT由三部分组成,各部分用点(.)分隔:',
header_desc: '包含令牌类型和签名算法',
payload_desc: '包含声明(Claims),如用户信息、权限等',
signature_desc: '用于验证消息的签名',
tool_purpose: '使用此工具可以快速解码JWT令牌,验证其格式,并检查过期状态,但不会验证签名的有效性。',
common_use_cases: '常见的JWT应用场景',
use_case_1: '身份验证(Authentication',
use_case_2: '信息交换(Information Exchange',
use_case_3: '单点登录(Single Sign On',
use_case_4: '授权(Authorization',
use_case_5: 'API访问控制',
// 过期状态
valid_remaining: '有效,剩余 {time}',
not_set: '未设置过期时间',
// 示例JWT用户
example_user: '极速箱用户'
};
export default jwtDecoderZh;
@@ -0,0 +1,48 @@
export const qrcodeGeneratorEn = {
title: 'QR Code Generator',
description: 'Convert text/URL to QR code image',
basic_settings: 'Basic Settings',
qrcode_content: 'QR Code Content',
size_pixels: 'Size (pixels)',
margin_pixels: 'Margin (pixels)',
dot_style: 'Dot Style',
squares: 'Squares',
dots: 'Dots',
color_settings: 'Color Settings',
background_color: 'Background Color',
foreground_color: 'Foreground Color',
eye_color: 'Eye Color',
eye_radius: 'Eye Radius',
preset_colors: 'Preset Colors',
preset_classic_bw: 'Classic B&W',
preset_blue_white: 'Business Blue',
preset_vibrant_red: 'Vibrant Red',
preset_fresh_green: 'Fresh Green',
preset_tech_purple: 'Tech Purple',
preset_dark_mode: 'Dark Mode',
preset_orange_accent: 'Orange Accent',
preset_warm_brown: 'Warm Brown',
logo_settings: 'Logo Settings',
upload_logo: 'Upload Logo',
remove: 'Remove',
logo_width: 'Logo Width (pixels)',
logo_height: 'Logo Height (pixels)',
logo_opacity: 'Logo Opacity',
remove_code_behind_logo: 'Remove QR Code Behind Logo',
preview: 'Preview',
copy_content: 'Copy Content',
download_qrcode: 'Download QR Code',
reset_settings: 'Reset Settings',
please_input_content: 'Please input content to generate QR code',
instructions: 'Instructions',
instruction_1: '1. Enter the content you want to generate as a QR code (URL, text, etc.)',
instruction_2: '2. Adjust QR code style, color and other parameters in the control panel',
instruction_3: '3. You can upload a logo image and set its size and opacity',
instruction_4: '4. Click the download button to save the generated QR code image',
note: 'Note: Long content may affect QR code readability, consider using short links',
input_placeholder: 'Enter URL, text or other content...',
copy_failed: 'Copy failed',
download_failed: 'Download failed'
};
export default qrcodeGeneratorEn;
@@ -0,0 +1,9 @@
import qrcodeGeneratorZh from './zh';
import qrcodeGeneratorEn from './en';
export const qrcodeGenerator = {
zh: qrcodeGeneratorZh,
en: qrcodeGeneratorEn
};
export default qrcodeGenerator;
@@ -0,0 +1,48 @@
export const qrcodeGeneratorZh = {
title: '二维码生成',
description: '文本/URL转二维码图片工具',
basic_settings: '基本设置',
qrcode_content: '二维码内容',
size_pixels: '尺寸(像素)',
margin_pixels: '边距(像素)',
dot_style: '点样式',
squares: '方块',
dots: '圆点',
color_settings: '颜色设置',
background_color: '背景颜色',
foreground_color: '前景颜色',
eye_color: '眼睛颜色',
eye_radius: '眼睛圆角',
preset_colors: '预设颜色',
preset_classic_bw: '黑白经典',
preset_blue_white: '蓝白商务',
preset_vibrant_red: '活力红',
preset_fresh_green: '清新绿',
preset_tech_purple: '科技紫',
preset_dark_mode: '暗黑模式',
preset_orange_accent: '橙色点缀',
preset_warm_brown: '温暖棕',
logo_settings: 'Logo设置',
upload_logo: '上传Logo',
remove: '移除',
logo_width: 'Logo宽度(像素)',
logo_height: 'Logo高度(像素)',
logo_opacity: 'Logo透明度',
remove_code_behind_logo: '移除Logo后方的二维码',
preview: '预览',
copy_content: '复制内容',
download_qrcode: '下载二维码',
reset_settings: '重置设置',
please_input_content: '请输入内容生成二维码',
instructions: '使用说明',
instruction_1: '1. 输入需要生成二维码的内容,支持网址、文本等',
instruction_2: '2. 通过右侧控制面板调整二维码的样式、颜色等参数',
instruction_3: '3. 可以上传Logo图片并设置其大小和透明度',
instruction_4: '4. 点击下载按钮保存生成的二维码图片',
note: '注意:内容过长可能影响二维码识别率,建议使用简短链接',
input_placeholder: '输入网址、文本或其他内容...',
copy_failed: '复制失败',
download_failed: '下载失败'
};
export default qrcodeGeneratorZh;
+44
View File
@@ -0,0 +1,44 @@
export const regexTesterEn = {
title: 'Regex Tester',
description: 'Test regular expression matching in real-time',
examples: {
title: 'Common Examples',
email: 'Email',
phone: 'Phone Number',
url: 'URL',
ip: 'IP Address',
chinese: 'Chinese Characters'
},
example_texts: {
phone: 'Contact phone: 13812345678 or contact email: [email protected]',
url: 'Visit https://example.com or http://localhost:3000',
ip: 'Server IP: 192.168.1.1 and 10.0.0.1 and 256.256.256.256',
chinese: 'Hello 你好,世界!World'
},
options: 'Regex Options',
flags: 'Flags',
flag_descriptions: {
global: 'Global',
case_insensitive: 'Case Insensitive',
multiline: 'Multiline',
dotall: 'Dot Matches All'
},
show_capture_groups: 'Show Capture Groups',
regex_expression: 'Regular Expression',
copy: 'Copy',
enter_regex: 'Enter regular expression...',
test_text: 'Test Text',
character_count: 'Character Count',
enter_test_text: 'Enter text to test...',
match_results: 'Match Results',
match_count: 'Match Count',
found: 'Found',
matches: 'matches',
original_text_length: 'Original Text Length',
result_characters: 'characters',
no_matches: 'No Matches Found',
capture_groups: 'Capture Group Details',
match: 'Match'
};
export default regexTesterEn;
@@ -0,0 +1,9 @@
import regexTesterZh from './zh';
import regexTesterEn from './en';
export const regexTester = {
zh: regexTesterZh,
en: regexTesterEn
};
export default regexTester;
+44
View File
@@ -0,0 +1,44 @@
export const regexTesterZh = {
title: '正则表达式测试',
description: '实时验证正则表达式匹配效果',
examples: {
title: '常用示例',
email: '电子邮箱',
phone: '手机号码',
url: 'URL地址',
ip: 'IP地址',
chinese: '中文字符'
},
example_texts: {
phone: '联系电话:13812345678 或 联系邮箱:[email protected]',
url: '访问 https://example.com 或 http://localhost:3000',
ip: '服务器IP: 192.168.1.1 和 10.0.0.1 以及 256.256.256.256',
chinese: 'Hello 你好,世界!World'
},
options: '正则选项',
flags: '标志位',
flag_descriptions: {
global: '全局',
case_insensitive: '忽略大小写',
multiline: '多行',
dotall: '点匹配所有'
},
show_capture_groups: '显示捕获组',
regex_expression: '正则表达式',
copy: '复制',
enter_regex: '输入正则表达式...',
test_text: '测试文本',
character_count: '字符数',
enter_test_text: '输入待测试的文本...',
match_results: '匹配结果',
match_count: '匹配数量',
found: '找到',
matches: '个匹配项',
original_text_length: '原文本长度',
result_characters: '字符',
no_matches: '无匹配结果',
capture_groups: '捕获组详情',
match: '匹配'
};
export default regexTesterZh;
+29
View File
@@ -0,0 +1,29 @@
export const textCounterEn = {
title: 'Text Counter',
description: 'Count characters, words, sentences, and paragraphs in text',
input_text: 'Input Text',
input_placeholder: 'Enter or paste text here...',
empty_notice: 'Statistics will be displayed on the left once text is entered',
statistics_results: 'Statistics Results',
copy_results: 'Copy Results',
copied: 'Copied',
tool_options: 'Tool Options',
load_chinese_example: 'Load Chinese Example',
load_english_example: 'Load English Example',
clear: 'Clear',
copy_failed: 'Copy failed',
statistics: {
total_characters: 'Total Characters',
characters_no_spaces: 'Characters (No Spaces)',
chinese_characters: 'Chinese Characters',
total_words: 'Total Words',
chinese_words: 'Chinese Words',
english_words: 'English Words',
sentences: 'Sentences',
paragraphs: 'Paragraphs',
lines: 'Lines'
},
copy_result_text: 'Text Statistics Results:\nTotal Characters: {characters}\nCharacters (No Spaces): {charactersNoSpaces}\nChinese Characters: {chineseCharacters}\nTotal Words: {words}\nChinese Words: {chineseWords}\nEnglish Words: {englishWords}\nSentences: {sentences}\nParagraphs: {paragraphs}\nLines: {lines}'
};
export default textCounterEn;
@@ -0,0 +1,9 @@
import textCounterZh from './zh';
import textCounterEn from './en';
export const textCounter = {
zh: textCounterZh,
en: textCounterEn
};
export default textCounter;
+29
View File
@@ -0,0 +1,29 @@
export const textCounterZh = {
title: '文本字数统计',
description: '统计文本的字符数、词数、句子数和段落数',
input_text: '输入文本',
input_placeholder: '在此输入或粘贴文本...',
empty_notice: '输入文本后将在左侧显示统计结果',
statistics_results: '统计结果',
copy_results: '复制结果',
copied: '已复制',
tool_options: '工具选项',
load_chinese_example: '加载中文示例',
load_english_example: '加载英文示例',
clear: '清空',
copy_failed: '复制失败',
statistics: {
total_characters: '总字符数',
characters_no_spaces: '不含空格字符数',
chinese_characters: '中文字符数',
total_words: '总词数',
chinese_words: '中文词数',
english_words: '英文词数',
sentences: '句子数',
paragraphs: '段落数',
lines: '行数'
},
copy_result_text: '字符统计结果:\n总字符数: {characters}\n不含空格字符数: {charactersNoSpaces}\n中文字符数: {chineseCharacters}\n总词数: {words}\n中文词数: {chineseWords}\n英文词数: {englishWords}\n句子数: {sentences}\n段落数: {paragraphs}\n行数: {lines}'
};
export default textCounterZh;
@@ -0,0 +1,24 @@
export const timestampConverterEn = {
title: 'Timestamp Converter',
description: 'Unix timestamp and date/time conversion tool',
timestamp: 'Timestamp',
datetime: 'Date & Time',
current_time: 'Current Time',
current_time_colon: 'Current Time:',
today_zero: 'Today at 00:00',
this_monday: 'This Monday',
this_month_start: 'This Month Start',
this_year_start: 'This Year Start',
enter_unix_timestamp: 'Enter Unix timestamp',
use_current_time: 'Use Current Time',
common_timestamps: 'Common Timestamps',
copy_timestamp: 'Copy Timestamp',
copy_datetime: 'Copy Date & Time',
swap_positions: 'Swap Positions',
timestamp_conversion_error: 'Timestamp conversion error',
datetime_conversion_error: 'Date & time conversion error',
datetime_format_error: 'Date & time format error',
copy_failed: 'Copy failed'
};
export default timestampConverterEn;
@@ -0,0 +1,9 @@
import timestampConverterZh from './zh';
import timestampConverterEn from './en';
export const timestampConverter = {
zh: timestampConverterZh,
en: timestampConverterEn
};
export default timestampConverter;
@@ -0,0 +1,24 @@
export const timestampConverterZh = {
title: '时间戳转换',
description: 'Unix时间戳与日期时间的互相转换工具',
timestamp: '时间戳',
datetime: '日期时间',
current_time: '当前时间',
current_time_colon: '当前时间:',
today_zero: '今日零点',
this_monday: '本周一',
this_month_start: '本月初',
this_year_start: '今年初',
enter_unix_timestamp: '请输入Unix时间戳',
use_current_time: '使用当前时间',
common_timestamps: '常用时间戳',
copy_timestamp: '复制时间戳',
copy_datetime: '复制日期时间',
swap_positions: '交换位置',
timestamp_conversion_error: '时间戳转换错误',
datetime_conversion_error: '日期时间转换错误',
datetime_format_error: '日期时间格式化错误',
copy_failed: '复制失败'
};
export default timestampConverterZh;
@@ -0,0 +1,49 @@
export const timezoneConverterEn = {
title: 'Timezone Converter',
description: 'Convert time between different timezones',
date_time: 'Date & Time',
source_timezone: 'Source Timezone',
target_timezone: 'Target Timezone',
conversion_result: 'Conversion Result',
converted_time: 'Converted Time',
detailed_result: 'Detailed Result',
current_time: 'Current Time',
use_current_time: 'Use Current Time',
copy: 'Copy',
copied: 'Copied',
copy_failed: 'Copy failed',
timezone_conversion_error: 'Timezone conversion error',
input_date_time_select_timezone: 'Please enter date & time and select timezone to convert',
timezone_note: 'Note: Timezone conversion results have taken daylight saving time into account, different regions may implement DST on different dates',
timezone_display_note: 'Timezone names may vary based on your operating system and browser language settings',
common_timezone_info: 'Common Timezone Information',
asia_pacific: 'Asia Pacific',
europe: 'Europe',
americas: 'Americas',
china: 'China',
japan: 'Japan',
korea: 'Korea',
singapore: 'Singapore',
india: 'India',
australia: 'Australia',
uk: 'UK',
france: 'France',
germany: 'Germany',
russia: 'Russia',
us_eastern: 'US Eastern',
us_central: 'US Central',
us_western: 'US Western',
brazil: 'Brazil',
about_timezone: 'About Timezone Notation:',
timezone_offset_info: 'Offset like "+08:00" means 8 hours ahead of UTC',
timezone_dst_info: 'Format like "+01:00/+02:00" indicates standard time/daylight saving time',
dst_implementation: 'DST is typically implemented from March to November in the Northern Hemisphere',
source_time: 'Source Time',
target_time: 'Target Time',
timestamp: 'Timestamp',
iso_format: 'ISO Format',
invalid_date_time: 'Invalid date time',
please_enter_valid_date_time: 'Please enter a valid date time'
};
export default timezoneConverterEn;
@@ -0,0 +1,9 @@
import timezoneConverterZh from './zh';
import timezoneConverterEn from './en';
export const timezoneConverter = {
zh: timezoneConverterZh,
en: timezoneConverterEn
};
export default timezoneConverter;
@@ -0,0 +1,49 @@
export const timezoneConverterZh = {
title: '时区转换',
description: '不同时区之间的时间转换工具',
date_time: '日期时间',
source_timezone: '源时区',
target_timezone: '目标时区',
conversion_result: '转换结果',
converted_time: '转换后时间',
detailed_result: '详细结果',
current_time: '当前时间',
use_current_time: '使用当前时间',
copy: '复制',
copied: '已复制',
copy_failed: '复制失败',
timezone_conversion_error: '时区转换错误',
input_date_time_select_timezone: '请输入日期时间并选择时区进行转换',
timezone_note: '注意:时区转换结果已考虑了夏令时的影响,不同地区的夏令时实施日期可能不同',
timezone_display_note: '时区名称根据操作系统和浏览器语言设置可能有所不同',
common_timezone_info: '常见时区信息',
asia_pacific: '亚太地区',
europe: '欧洲地区',
americas: '美洲地区',
china: '中国',
japan: '日本',
korea: '韩国',
singapore: '新加坡',
india: '印度',
australia: '澳大利亚',
uk: '英国',
france: '法国',
germany: '德国',
russia: '俄罗斯',
us_eastern: '美国东部',
us_central: '美国中部',
us_western: '美国西部',
brazil: '巴西',
about_timezone: '关于时区表示:',
timezone_offset_info: '偏移量如 "+08:00" 表示比 UTC 快8小时',
timezone_dst_info: '形如 "+01:00/+02:00" 表示标准时间/夏令时',
dst_implementation: '夏令时在北半球通常从3月到11月实施',
source_time: '源时间',
target_time: '目标时间',
timestamp: '时间戳',
iso_format: 'ISO 格式',
invalid_date_time: '无效的日期时间',
please_enter_valid_date_time: '请输入有效的日期时间'
};
export default timezoneConverterZh;
@@ -0,0 +1,30 @@
export const unicodeConverterEn = {
title: 'Unicode Converter',
description: 'Convert between Unicode and characters',
text_to_unicode: 'Text to Unicode',
unicode_to_text: 'Unicode to Text',
text_to_unicode_description: 'Convert text to Unicode encoding format',
unicode_to_text_description: 'Convert Unicode encoding to readable text',
load_example: 'Load Example',
clear: 'Clear',
original_text: 'Original Text',
unicode_encoding: 'Unicode Encoding',
converted_text: 'Converted Text',
swap_input_output: 'Swap Input and Output',
copy: 'Copy',
copied: 'Copied',
text_to_unicode_placeholder: 'Enter text to convert to Unicode...',
unicode_to_text_placeholder: 'Enter Unicode encoding to convert to text...',
unicode_result_placeholder: 'Unicode encoding result will be displayed here...',
text_result_placeholder: 'Text conversion result will be displayed here...',
conversion_error: 'Conversion failed: ',
copy_failed: 'Copy failed',
feature_intro: 'Feature Introduction',
unicode_description: 'Unicode is a character encoding standard that can represent all characters in the world. In JavaScript and many programming languages, Unicode characters can be represented in the form of \\uXXXX, where XXXX is the hexadecimal code point of the character.',
supported_operations: 'This tool supports two conversion operations:',
operation_text_to_unicode: 'Text to Unicode: Convert non-ASCII characters (such as Chinese, Japanese, etc.) to \\uXXXX format',
operation_unicode_to_text: 'Unicode to Text: Convert \\uXXXX format encoding back to readable text',
note: 'Note: This tool only converts non-ASCII characters (characters with Unicode code points greater than 127), while ASCII characters remain unchanged.'
};
export default unicodeConverterEn;
@@ -0,0 +1,9 @@
import unicodeConverterZh from './zh';
import unicodeConverterEn from './en';
export const unicodeConverter = {
zh: unicodeConverterZh,
en: unicodeConverterEn
};
export default unicodeConverter;
@@ -0,0 +1,30 @@
export const unicodeConverterZh = {
title: 'Unicode中文互转',
description: 'Unicode编码与中文字符的互相转换',
text_to_unicode: '文本转Unicode',
unicode_to_text: 'Unicode转文本',
text_to_unicode_description: '将文本转换为Unicode编码格式',
unicode_to_text_description: '将Unicode编码转换为可读文本',
load_example: '加载示例',
clear: '清空',
original_text: '原始文本',
unicode_encoding: 'Unicode编码',
converted_text: '转换后文本',
swap_input_output: '交换输入和输出',
copy: '复制',
copied: '已复制',
text_to_unicode_placeholder: '输入需要转换为Unicode的文本...',
unicode_to_text_placeholder: '输入需要转换为文本的Unicode编码...',
unicode_result_placeholder: 'Unicode编码结果将显示在这里...',
text_result_placeholder: '文本转换结果将显示在这里...',
conversion_error: '转换失败: ',
copy_failed: '复制失败',
feature_intro: '功能介绍',
unicode_description: 'Unicode是一种字符编码标准,可以表示世界上所有字符。在JavaScript和很多编程语言中,Unicode字符可以用\\uXXXX的形式表示,其中XXXX是字符的十六进制码点。',
supported_operations: '本工具支持两种转换操作:',
operation_text_to_unicode: '文本转Unicode: 将非ASCII字符(如中文、日文等)转换为\\uXXXX格式',
operation_unicode_to_text: 'Unicode转文本: 将\\uXXXX格式的编码转换回可读文本',
note: '注意:此工具只会转换非ASCII字符(Unicode码点大于127的字符),ASCII字符保持不变。'
};
export default unicodeConverterZh;
+44
View File
@@ -0,0 +1,44 @@
export const urlEncoderEn = {
title: 'URL Encoder/Decoder',
description: 'Encode and decode URL special characters',
original_text: 'Original Text',
url_encoded_text: 'URL Encoded Text',
input_original_text: 'Input Original Text',
input_encoded_text: 'Input Encoded Text',
enter_text_to_encode: 'Enter text to encode here...',
enter_text_to_decode: 'Enter URL encoded text to decode here...',
encoding_result: 'Encoding Result',
decoding_result: 'Decoding Result',
encoded_result_placeholder: 'Encoding result will appear here...',
decoded_result_placeholder: 'Decoding result will appear here...',
clear: 'Clear',
show_history: 'Show History',
hide_history: 'Hide History',
copied: 'Copied',
copy_result: 'Copy Result',
error_processing: 'Error processing text',
copy_failed: 'Failed to copy to clipboard',
copied_to_clipboard: 'Copied to clipboard',
history: 'History',
clear_history: 'Clear History',
no_history: 'No history records',
encode: 'Encode',
decode: 'Decode',
uri_encoding: 'URI Encoding',
uri_component_encoding: 'URI Component Encoding',
uri_encoding_description: 'Uses encodeURI and decodeURI functions - does not encode URL necessary characters like / ? : @ & = + $ #',
uri_component_description: 'Uses encodeURIComponent and decodeURIComponent functions - encodes all special characters including / ? : @ & = + $ #',
url_encoding_explanation: 'URL Encoding Explanation',
uri_vs_component: 'URI Encoding vs URI Component Encoding',
encode_uri_description: 'Used to encode entire URLs, preserves URL special characters',
encode_uri_component_description: 'Used to encode URL parameter values, encodes all special characters',
usage_scenarios: 'Usage Scenarios',
scenario_1: 'Pass non-ASCII characters, such as Chinese or special characters, in URLs',
scenario_2: 'Prevent URL injection and XSS attacks',
scenario_3: 'Ensure correct URL format, avoiding parsing errors caused by special characters',
encoding_rules: 'Encoding Rules',
rules_1: 'Only letters, numbers and the following special characters remain unchanged: - _ . ! ~ * \' ( )',
rules_2: 'Other characters are encoded as % followed by two hexadecimal digits'
};
export default urlEncoderEn;
@@ -0,0 +1,9 @@
import urlEncoderZh from './zh';
import urlEncoderEn from './en';
export const urlEncoder = {
zh: urlEncoderZh,
en: urlEncoderEn
};
export default urlEncoder;
+44
View File
@@ -0,0 +1,44 @@
export const urlEncoderZh = {
title: 'URL编解码',
description: 'URL特殊字符的编码与解码转换',
original_text: '原始文本',
url_encoded_text: 'URL编码文本',
input_original_text: '输入原始文本',
input_encoded_text: '输入编码文本',
enter_text_to_encode: '在此输入要编码的文本...',
enter_text_to_decode: '在此输入要解码的URL编码...',
encoding_result: '编码结果',
decoding_result: '解码结果',
encoded_result_placeholder: '编码结果将显示在这里...',
decoded_result_placeholder: '解码结果将显示在这里...',
clear: '清空',
show_history: '显示历史记录',
hide_history: '隐藏历史记录',
copied: '已复制',
copy_result: '复制结果',
error_processing: '处理文本时发生错误',
copy_failed: '复制到剪贴板失败',
copied_to_clipboard: '已复制到剪贴板',
history: '历史记录',
clear_history: '清空历史',
no_history: '暂无历史记录',
encode: '编码',
decode: '解码',
uri_encoding: 'URI编码',
uri_component_encoding: 'URI组件编码',
uri_encoding_description: '使用encodeURI和decodeURI函数 - 不编码URL必要字符如 / ? : @ & = + $ #',
uri_component_description: '使用encodeURIComponent和decodeURIComponent函数 - 编码所有特殊字符包括 / ? : @ & = + $ #',
url_encoding_explanation: 'URL编码说明',
uri_vs_component: 'URI编码 vs URI组件编码',
encode_uri_description: '用于编码整个URL,保留URL特殊字符',
encode_uri_component_description: '用于编码URL参数值,会编码所有特殊字符',
usage_scenarios: '应用场景',
scenario_1: '在URL中传递中文、特殊字符或其他非ASCII字符',
scenario_2: '防止URL注入和XSS攻击',
scenario_3: '确保URL格式正确,避免因特殊字符导致的解析错误',
encoding_rules: '编码规则',
rules_1: '仅字母、数字和以下特殊符号保持不变: - _ . ! ~ * \' ( )',
rules_2: '其他字符会被编码为 % 后跟两位十六进制数字'
};
export default urlEncoderZh;
+31
View File
@@ -0,0 +1,31 @@
import commonZh from './common/zh';
import tools from './tools';
export const zh = {
...commonZh,
tools: {
json_formatter: tools.json_formatter.zh,
regex_tester: tools.regex_tester.zh,
timestamp_converter: tools.timestamp_converter.zh,
encoding_converter: tools.encoding_converter.zh,
code_formatter: tools.code_formatter.zh,
color_tools: tools.color_tools.zh,
crypto_tools: tools.crypto_tools.zh,
css_gradient_generator: tools.css_gradient_generator.zh,
date_calculator: tools.date_calculator.zh,
html_markdown_converter: tools.html_markdown_converter.zh,
http_tester: tools.http_tester.zh,
image_compressor: tools.image_compressor.zh,
ip_lookup: tools.ip_lookup.zh,
json_converter: tools.json_converter.zh,
json_editor: tools.json_editor.zh,
jwt_decoder: tools.jwt_decoder.zh,
qrcode_generator: tools.qrcode_generator.zh,
text_counter: tools.text_counter.zh,
timezone_converter: tools.timezone_converter.zh,
unicode_converter: tools.unicode_converter.zh,
url_encoder: tools.url_encoder.zh
}
};
export default zh;
+140
View File
@@ -0,0 +1,140 @@
import {
faCode, faExchangeAlt, faClock, faGlobe, faLink, faLock,
faImage, faCogs, faFileCode, faKey, faFont,
faCalendarAlt, faPalette, faEdit, faRuler, faNetworkWired
} from '@fortawesome/free-solid-svg-icons';
import { Tool } from '@/types/tools';
// 定义工具列表
const tools: Tool[] = [
{
code: 'json_formatter',
icon: faCode,
category: ['common', 'json'],
keywords: ['json', 'json格式化', '格式化', '美化', '压缩', '校验', 'formatter', 'validator', 'gshjson', 'gshjson', 'jsonxg', 'jxg']
},
{
code: 'http_tester',
icon: faGlobe,
category: ['common','network'],
keywords: ['http测试', 'api测试', '接口测试', '请求测试', 'http', 'api', 'request', 'jiekou', 'qingqiu', 'ceshi', 'postman']
},
{
code: 'timestamp_converter',
icon: faClock,
category: ['common', 'datetime'],
keywords: ['时间戳', '时间', '日期', 'timestamp', 'time', 'date', 'unix时间', 'datetime', 'shijian', 'sjc', 'sj', 'riqi', 'rq']
},
{
code: 'encoding_converter',
icon: faExchangeAlt,
category: ['common', 'encoding'],
keywords: ['编码', '解码', 'base64', 'url编码', 'unicode', '编码转换', 'encoding', 'decoding', 'bianma', 'jiema', 'bm', 'jm']
},
{
code: 'regex_tester',
icon: faKey,
category: ['text'],
keywords: ['正则', '正则表达式', 'regex', '表达式', '正则测试', 'regular expression', 'zhengze', 'zz']
},
{
code: 'crypto_tools',
icon: faLock,
category: ['encoding'],
keywords: ['加密', '解密', '哈希', 'md5', 'sha', 'aes', 'des', '加密工具', '解密工具', 'encrypt', 'decrypt', 'hash', 'jiami', 'jiemi', 'jm']
},
{
code: 'color_tools',
icon: faPalette,
category: ['frontend'],
keywords: ['颜色', '调色板', '颜色转换', '颜色选择', 'color', 'rgb', 'hex', 'hsv', 'hsl', 'yanse', 'ys', 'colors']
},
{
code: 'code_formatter',
icon: faFileCode,
category: ['code'],
keywords: ['代码格式化', '代码美化', '格式化', '美化', 'code', 'formatter', 'beautify', 'html', 'css', 'js', 'sql', 'daima', 'dm', 'geshi']
},
{
code: 'json_editor',
icon: faEdit,
category: ['common', 'json'],
keywords: ['json编辑器', 'json修改', '编辑器', 'json', 'editor', 'jsonbj', 'json编辑', 'bianji', 'bj']
},
{
code: 'json_converter',
icon: faExchangeAlt,
category: ['json'],
keywords: ['json转换', 'json2xml', 'json2csv', 'json2yaml', 'xml转json', 'csv转json', 'yaml转json', 'jsonzh', 'json互转', 'zhuanhuan', 'zh']
},
{
code: 'url_encoder',
icon: faLink,
category: ['encoding'],
keywords: ['url编码', 'url解码', 'url转换', '网址编码', 'urlencode', 'urldecode', 'url编解码', 'wangzhi', 'wz']
},
{
code: 'unicode_converter',
icon: faFont,
category: ['encoding'],
keywords: ['unicode', '中文', '编码转换', 'unicode转中文', '中文转unicode', 'zhongwen', 'zw', 'unicode编码', 'unicode解码']
},
{
code: 'jwt_decoder',
icon: faLock,
category: ['encoding'],
keywords: ['jwt', 'token', 'jwt解析', 'jwt验证', 'json web token', '令牌', 'token解析', 'jwtjx', 'lingpai']
},
{
code: 'ip_lookup',
icon: faNetworkWired,
category: ['common','network'],
keywords: ['ip查询', 'ip地址', 'ip归属地', '地址查询', 'ip', 'ip归属', 'dizhi', 'dz', 'ip地址查询', 'ipdz']
},
{
code: 'date_calculator',
icon: faCalendarAlt,
category: ['datetime'],
keywords: ['日期计算', '日期差值', '计算器', '日期', 'date', 'calculator', 'date差值', '天数计算', 'riqi', 'rq', 'jsq']
},
{
code: 'timezone_converter',
icon: faClock,
category: ['datetime'],
keywords: ['时区', '时区转换', '时间转换', '时区计算', 'timezone', 'time zone', 'shiqu', 'sq', 'GMT', 'UTC']
},
{
code: 'text_counter',
icon: faRuler,
category: ['text'],
keywords: ['字数统计', '字符统计', '字数', '词数', '行数', '计数', 'word count', 'character count', 'zishu', 'zs', 'tongji', 'tj']
},
{
code: 'html_markdown_converter',
icon: faFileCode,
category: ['code', 'text'],
keywords: ['html', 'markdown', 'md', 'html转markdown', 'markdown转html', '文档转换', 'html2md', 'md2html', 'wenzhang']
},
{
code: 'image_compressor',
icon: faImage,
category: ['common', 'image'],
keywords: ['图片压缩', '压缩图片', '图片', '压缩', 'image', 'compress', 'compressor', 'tupian', 'tp', 'yasuo', 'ys']
},
{
code: 'qrcode_generator',
icon: faImage,
category: ['common','image'],
keywords: ['二维码', '二维码生成', 'qrcode', 'qr码', '扫码', '二维码生成器', 'erweima', 'ewm', 'saoma', 'sm']
},
{
code: 'css_gradient_generator',
icon: faCogs,
category: ['frontend'],
keywords: ['css渐变', '渐变', '渐变背景', 'css', 'gradient', '背景', 'css生成器', 'jianbian', 'jb', 'beijing', 'bj']
}
] as Tool[];
export default tools;
+91
View File
@@ -0,0 +1,91 @@
'use client';
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import languages, { Language, LanguageData } from '@/config/i18n';
interface LanguageContextType {
language: Language;
t: (key: string) => string;
changeLanguage: (lang: Language) => void;
languageData: LanguageData;
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
export const LanguageProvider = ({ children }: { children: ReactNode }) => {
const [language, setLanguage] = useState<Language>('zh');
const [languageData, setLanguageData] = useState<LanguageData>(languages.zh);
// 根据浏览器语言和本地存储初始化语言设置
useEffect(() => {
// 检查localStorage中是否有语言设置
const savedLanguage = localStorage.getItem('language') as Language;
if (savedLanguage && languages[savedLanguage]) {
setLanguage(savedLanguage);
setLanguageData(languages[savedLanguage]);
// 同步更新HTML的lang属性
document.documentElement.lang = savedLanguage === 'zh' ? 'zh-CN' : 'en';
} else {
// 根据浏览器语言判断
const browserLang = navigator.language.toLowerCase().split('-')[0];
const defaultLang = browserLang === 'zh' ? 'zh' : 'en';
setLanguage(defaultLang);
setLanguageData(languages[defaultLang]);
localStorage.setItem('language', defaultLang);
// 同步更新HTML的lang属性
document.documentElement.lang = defaultLang === 'zh' ? 'zh-CN' : 'en';
}
}, []);
// 翻译函数
const t = (key: string): string => {
const keys = key.split('.');
let value: Record<string, unknown> | string | unknown = languageData;
for (const k of keys) {
if (value && typeof value === 'object' && k in (value as Record<string, unknown>)) {
value = (value as Record<string, unknown>)[k];
} else {
// 如果找不到翻译,输出警告并返回键名
console.warn(`[i18n] Missing translation for key: ${key} in language: ${language}`);
return key;
}
}
if (typeof value !== 'string') {
console.warn(`[i18n] Translation for key: ${key} is not a string in language: ${language}`);
return key;
}
return value;
};
// 切换语言
const changeLanguage = (lang: Language) => {
if (languages[lang]) {
setLanguage(lang);
setLanguageData(languages[lang]);
localStorage.setItem('language', lang);
// 更新HTML的lang属性
document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en';
}
};
return (
<LanguageContext.Provider value={{ language, t, changeLanguage, languageData }}>
{children}
</LanguageContext.Provider>
);
};
// 自定义hook,方便使用语言上下文
export const useLanguage = () => {
const context = useContext(LanguageContext);
if (context === undefined) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
};
+335
View File
@@ -0,0 +1,335 @@
/**
* API客户端工具
* 用于处理与后端API的所有交互,集成安全验证和令牌管理
*/
import { addAntiCrawlingHeaders, generateSecurityHeaders } from '@/utils/security';
// API客户端配置选项接口
interface ApiClientOptions {
// 是否自动处理令牌无效的情况(默认开启)
autoHandleTokenRefresh?: boolean;
// 特定的API端点是否需要令牌验证
endpointsRequiringToken?: string[];
// 预处理请求的钩子函数
requestInterceptor?: (request: RequestInit) => RequestInit;
// 预处理响应的钩子函数
responseInterceptor?: (response: Response) => Promise<Response>;
}
// API请求选项接口,继承自标准的RequestInit接口
interface ApiRequestOptions extends RequestInit {
// 是否绕过安全检查(仅用于特殊情况)
bypassSecurity?: boolean;
}
// 令牌管理相关配置
const TOKEN_CONFIG = {
// 本地存储中保存令牌的键名
storageKey: 'jisuxiang_client_token',
// 令牌有效期,默认2小时
tokenLifetime: 2 * 60 * 60 * 1000,
};
// 所有API端点默认都需要令牌验证
const DEFAULT_PROTECTED_ENDPOINTS = [
'/api/',
// 以下是特别指定的API端点,方便单独配置
'/api/markdown-convert',
'/api/ip',
// 添加其他需要保护的API端点
];
/**
* API客户端类
*/
class ApiClient {
private baseUrl: string;
private options: ApiClientOptions;
private token: string | null = null;
private tokenLoadedFromStorage = false;
/**
* 构造函数
*/
constructor(baseUrl: string = '', options: ApiClientOptions = {}) {
this.baseUrl = baseUrl;
this.options = {
autoHandleTokenRefresh: true,
endpointsRequiringToken: DEFAULT_PROTECTED_ENDPOINTS,
...options
};
// 尝试从localStorage中加载令牌
if (typeof window !== 'undefined') {
this.loadTokenFromStorage();
}
}
/**
* 从本地存储加载令牌
*/
private loadTokenFromStorage(): void {
if (this.tokenLoadedFromStorage) return;
try {
const storedToken = localStorage.getItem(TOKEN_CONFIG.storageKey);
if (storedToken) {
this.token = storedToken;
this.tokenLoadedFromStorage = true;
}
} catch (error) {
console.error('从本地存储加载令牌失败:', error);
}
}
/**
* 保存令牌到本地存储
*/
private saveTokenToStorage(token: string): void {
try {
localStorage.setItem(TOKEN_CONFIG.storageKey, token);
this.tokenLoadedFromStorage = true;
} catch (error) {
console.error('保存令牌到本地存储失败:', error);
}
}
/**
* 清除令牌
*/
public clearToken(): void {
this.token = null;
try {
localStorage.removeItem(TOKEN_CONFIG.storageKey);
} catch (error) {
console.error('清除令牌失败:', error);
}
}
/**
* 设置新令牌
*/
public setToken(token: string): void {
this.token = token;
this.saveTokenToStorage(token);
}
/**
* 获取当前的令牌
*/
public getToken(): string | null {
return this.token;
}
/**
* 检查端点是否需要令牌认证
*/
private endpointRequiresToken(url: string): boolean {
// 默认所有API接口都需要令牌验证
if (url.startsWith('/api/')) {
return true;
}
const { endpointsRequiringToken } = this.options;
if (!endpointsRequiringToken || endpointsRequiringToken.length === 0) {
return false;
}
return endpointsRequiringToken.some(endpoint => url.startsWith(endpoint));
}
/**
* 通用请求方法
*/
public async request<T>(
url: string,
options: ApiRequestOptions = {}
): Promise<T> {
// 构建完整URL
const fullUrl = url.startsWith('http') ? url : `${this.baseUrl}${url}`;
// 检查是否是FormData
const isFormData = options.body instanceof FormData;
// 准备请求头
let headers: Record<string, string> = {
// 只在非FormData请求中默认设置Content-Type
...(!isFormData ? { 'Content-Type': 'application/json' } : {}),
...(options.headers as Record<string, string> || {})
};
// 除非明确绕过安全检查,否则添加安全请求头
if (!options.bypassSecurity) {
// 如果需要令牌,添加令牌到请求头
if (this.endpointRequiresToken(url) && this.token) {
headers['X-Client-Token'] = this.token;
}
// 添加安全请求头和反爬虫头
// 注意:对于FormData请求,不应该尝试序列化body
headers = {
...headers,
...generateSecurityHeaders(url, options.method || 'GET',
(!isFormData && options.body) ? JSON.stringify(options.body) : null),
...addAntiCrawlingHeaders()
};
}
// 合并请求选项
const requestOptions: RequestInit = {
...options,
headers
};
// 应用请求拦截器
const finalRequestOptions = this.options.requestInterceptor
? this.options.requestInterceptor(requestOptions)
: requestOptions;
// 发送请求
let response = await fetch(fullUrl, finalRequestOptions);
// 应用响应拦截器
if (this.options.responseInterceptor) {
response = await this.options.responseInterceptor(response);
}
// 处理401错误(令牌无效或过期)
if (response.status === 401 && this.options.autoHandleTokenRefresh) {
const data = await response.json();
// 如果收到新令牌,保存并重试请求
if (data.token) {
this.setToken(data.token);
// 更新请求头中的令牌
headers['X-Client-Token'] = data.token;
// 重新发送请求
const retryOptions: RequestInit = {
...finalRequestOptions,
headers
};
const retryResponse = await fetch(fullUrl, retryOptions);
// 解析并返回重试响应
if (retryResponse.ok) {
return await retryResponse.json();
} else {
throw new Error(`请求失败 (${retryResponse.status}): ${await retryResponse.text()}`);
}
}
}
// 处理非成功响应
if (!response.ok) {
let errorMessage;
try {
const errorData = await response.json();
errorMessage = errorData.error || `请求失败 (${response.status})`;
} catch (/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
_error
) {
errorMessage = `请求失败 (${response.status}): ${await response.text()}`;
}
throw new Error(errorMessage);
}
// 解析并返回响应数据
return await response.json();
}
/**
* GET请求快捷方法
*/
public async get<T>(url: string, options: ApiRequestOptions = {}): Promise<T> {
return this.request<T>(url, { ...options, method: 'GET' });
}
/**
* POST请求快捷方法
*/
public async post<T>(
url: string,
data?: unknown,
options: ApiRequestOptions = {}
): Promise<T> {
const isFormData = data instanceof FormData;
// 创建新的选项对象,避免修改原始对象
const newOptions = { ...options };
// 如果是FormData,不设置Content-Type,让浏览器自动设置
if (isFormData) {
// 创建新的headers对象
newOptions.headers = { ...(options.headers || {}) };
// 确保删除任何Content-Type (使用类型断言解决TypeScript报错)
const headers = newOptions.headers as Record<string, string>;
delete headers['Content-Type'];
} else {
// 非FormData请求使用application/json
newOptions.headers = {
'Content-Type': 'application/json',
...(options.headers || {})
};
}
// 设置请求方法和主体
return this.request<T>(url, {
...newOptions,
method: 'POST',
body: isFormData ? data : data ? JSON.stringify(data) : undefined
});
}
/**
* PUT请求快捷方法
*/
public async put<T>(
url: string,
data?: unknown,
options: ApiRequestOptions = {}
): Promise<T> {
return this.request<T>(url, {
...options,
method: 'PUT',
body: data ? JSON.stringify(data) : undefined
});
}
/**
* DELETE请求快捷方法
*/
public async delete<T>(url: string, options: ApiRequestOptions = {}): Promise<T> {
return this.request<T>(url, { ...options, method: 'DELETE' });
}
/**
* 文件上传方法
*/
public async uploadFile<T>(
url: string,
file: File,
additionalData?: Record<string, string>,
options: ApiRequestOptions = {}
): Promise<T> {
const formData = new FormData();
formData.append('file', file);
// 添加额外数据
if (additionalData) {
Object.entries(additionalData).forEach(([key, value]) => {
formData.append(key, value);
});
}
return this.post<T>(url, formData, options);
}
}
// 创建一个默认的API客户端实例
const apiClient = new ApiClient();
export { apiClient, ApiClient, type ApiClientOptions, type ApiRequestOptions };
+361
View File
@@ -0,0 +1,361 @@
import { NextRequest, NextResponse } from 'next/server';
import { nanoid } from 'nanoid';
// 安全验证配置
const SECURITY_CONFIG = {
// 允许的最大请求体大小(50MB)
maxBodySize: 50 * 1024 * 1024,
// 请求频率限制时间窗口(毫秒)
rateWindowMs: 60 * 1000,
// 时间窗口内允许的最大请求数(普通API)
apiRateLimit: 100,
// 文件上传API的请求限制(更严格)
fileUploadRateLimit: 10,
// API调用需要的请求头
requiredHeaders: {
// 'X-API-Client': 'jisuxiang-web',
},
};
// 需要验证令牌的API路径
const PROTECTED_API_PATHS = [
// 所有API路径都需要验证
'/api/',
// 以下是特别指定的API路径,方便单独配置
'/api/markdown-convert',
'/api/ip',
// 这里可以添加更多需要保护的API路径
];
// 文件上传类API路径
const FILE_UPLOAD_API_PATHS = [
'/api/markdown-convert',
// 这里可以添加更多文件上传类API
];
// 频率限制较高的API路径
const HIGH_RATE_LIMIT_API_PATHS = [
'/api/ip',
'/api/proxy',
// 其他可以有较高频率限制的API
];
// 请求频率限制存储(使用IP作为键)
const rateLimits = new Map<string, {count: number, timestamp: number}>();
// 客户端令牌存储(简单的内存存储,生产环境可以使用Redis等)
const clientTokens = new Map<string, {timestamp: number}>();
// IP黑名单(恶意IP会被临时加入黑名单)
const ipBlacklist = new Map<string, {timestamp: number, reason: string}>();
// 黑名单时长(24小时)
const BLACKLIST_DURATION = 24 * 60 * 60 * 1000;
// 可疑行为记录
const suspiciousActivities = new Map<string, {count: number, timestamp: number}>();
// 可疑行为阈值(超过这个值会被加入黑名单)
const SUSPICIOUS_THRESHOLD = 5;
/**
* 生成客户端令牌
*/
function generateClientToken(): string {
return nanoid(32);
}
/**
* 验证客户端令牌是否有效
*/
function validateClientToken(token: string): boolean {
if (!token) return false;
const tokenData = clientTokens.get(token);
if (!tokenData) return false;
// 令牌有效期检查(2小时)
const now = Date.now();
if (now - tokenData.timestamp > 2 * 60 * 60 * 1000) {
clientTokens.delete(token);
return false;
}
return true;
}
/**
* 清理过期的令牌和限制记录
*/
function cleanupExpiredData() {
const now = Date.now();
// 清理过期的令牌
clientTokens.forEach((data, token) => {
if (now - data.timestamp > 2 * 60 * 60 * 1000) {
clientTokens.delete(token);
}
});
// 清理过期的频率限制记录
rateLimits.forEach((data, ip) => {
if (now - data.timestamp > SECURITY_CONFIG.rateWindowMs) {
rateLimits.delete(ip);
}
});
// 清理过期的IP黑名单
ipBlacklist.forEach((data, ip) => {
if (now - data.timestamp > BLACKLIST_DURATION) {
ipBlacklist.delete(ip);
}
});
// 清理过期的可疑行为记录
suspiciousActivities.forEach((data, ip) => {
if (now - data.timestamp > SECURITY_CONFIG.rateWindowMs) {
suspiciousActivities.delete(ip);
}
});
}
/**
* 检查请求频率限制
*/
function checkRateLimit(ip: string, path: string): boolean {
const now = Date.now();
const userLimit = rateLimits.get(ip);
// 确定当前路径的请求限制
const isFileUpload = FILE_UPLOAD_API_PATHS.some(apiPath => path.startsWith(apiPath));
const isHighRateApi = HIGH_RATE_LIMIT_API_PATHS.some(apiPath => path.startsWith(apiPath));
// 确定适用的限制
let limit;
if (isFileUpload) {
limit = SECURITY_CONFIG.fileUploadRateLimit;
} else if (isHighRateApi) {
limit = SECURITY_CONFIG.apiRateLimit * 3; // 对于IP查询等API,允许更高的访问频率
} else {
limit = SECURITY_CONFIG.apiRateLimit;
}
if (!userLimit) {
rateLimits.set(ip, { count: 1, timestamp: now });
return true;
}
// 如果时间窗口已过,重置计数
if (now - userLimit.timestamp > SECURITY_CONFIG.rateWindowMs) {
rateLimits.set(ip, { count: 1, timestamp: now });
return true;
}
// 增加计数并检查是否超过限制
userLimit.count++;
if (userLimit.count > limit) {
recordSuspiciousActivity(ip, '请求频率过高');
return false;
}
return true;
}
/**
* 记录可疑行为
*/
function recordSuspiciousActivity(ip: string, reason: string): void {
const now = Date.now();
const record = suspiciousActivities.get(ip);
if (!record) {
suspiciousActivities.set(ip, { count: 1, timestamp: now });
return;
}
// 如果时间窗口已过,重置计数
if (now - record.timestamp > SECURITY_CONFIG.rateWindowMs) {
suspiciousActivities.set(ip, { count: 1, timestamp: now });
return;
}
// 增加计数并检查是否超过阈值
record.count++;
if (record.count >= SUSPICIOUS_THRESHOLD) {
// 加入黑名单
ipBlacklist.set(ip, { timestamp: now, reason });
suspiciousActivities.delete(ip);
}
}
/**
* 验证安全请求头
*/
function validateSecurityHeaders(request: NextRequest, ip: string): boolean {
// 检查必需的安全请求头
const timestamp = request.headers.get('X-Timestamp');
const nonce = request.headers.get('X-Nonce');
const signature = request.headers.get('X-Signature');
// const client = request.headers.get('X-Client');
// 实际项目中应该进行更严格的验证,这里仅作示例
if (!timestamp || !nonce || !signature) {
// 记录可疑活动但不直接拒绝请求,因为可能是其他正常客户端
if (PROTECTED_API_PATHS.some(path => request.nextUrl.pathname.startsWith(path))) {
recordSuspiciousActivity(ip, '缺少安全请求头');
}
return true; // 不强制要求
}
// 检查时间戳是否合理(允许5分钟的时间差)
const currentTime = Math.floor(Date.now() / 1000);
const requestTime = parseInt(timestamp);
if (isNaN(requestTime) || Math.abs(currentTime - requestTime) > 300) {
recordSuspiciousActivity(ip, '请求时间戳异常');
return false;
}
// 实际项目中应该验证签名
// 这里简化处理,仅检查格式
if (!/^[a-f0-9]{8,}$/.test(signature)) {
recordSuspiciousActivity(ip, '安全签名格式异常');
return false;
}
return true;
}
/**
* 检查请求是否来自爬虫
*/
function checkBotHeaders(request: NextRequest, ip: string): boolean {
// const fingerprint = request.headers.get('X-Browser-Fingerprint');
const clientType = request.headers.get('X-Client-Type');
// 如果明确标记为爬虫
if (clientType === 'potential-bot') {
recordSuspiciousActivity(ip, '客户端自识别为爬虫');
return false;
}
// 检查可疑特征
const userAgent = request.headers.get('user-agent') || '';
const botPatterns = [
'bot', 'spider', 'crawl', 'slurp', 'scrape',
'phantomjs', 'headless', 'selenium', 'webdriver', 'puppeteer',
];
if (botPatterns.some(pattern => userAgent.toLowerCase().includes(pattern))) {
recordSuspiciousActivity(ip, '爬虫特征的User-Agent');
return false;
}
return true;
}
/**
* 中间件处理函数
*/
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 定期清理过期数据
if (Math.random() < 0.01) { // 1%的请求触发清理
cleanupExpiredData();
}
// 仅对API请求进行检查
if (pathname.startsWith('/api/')) {
// 获取客户端IP
const ip = request.headers.get('x-forwarded-for') ||
request.headers.get('x-real-ip') ||
'unknown';
// 检查IP是否在黑名单中
if (ipBlacklist.has(ip)) {
// const blacklistInfo = ipBlacklist.get(ip);
return NextResponse.json(
{ error: '您的请求已被暂时限制,请稍后再试' },
{ status: 403 }
);
}
// 检查请求频率限制
if (!checkRateLimit(ip, pathname)) {
return NextResponse.json(
{ error: '请求频率过高,请稍后再试' },
{ status: 429 }
);
}
// 检查安全请求头
if (!validateSecurityHeaders(request, ip)) {
return NextResponse.json(
{ error: '请求安全验证失败' },
{ status: 400 }
);
}
// 检查是否可能是爬虫
if (!checkBotHeaders(request, ip)) {
// 对于爬虫,不直接拒绝,但会加入可疑活动计数
// 如果达到阈值会自动加入黑名单
}
// 检查所需的请求头
for (const [headerName, headerValue] of Object.entries(SECURITY_CONFIG.requiredHeaders)) {
if (request.headers.get(headerName) !== headerValue) {
recordSuspiciousActivity(ip, '缺少必要的请求头');
return NextResponse.json(
{ error: '缺少必要的请求头' },
{ status: 403 }
);
}
}
// 验证客户端令牌
const clientToken = request.headers.get('X-Client-Token');
// 对于首次请求,不需要令牌,将返回一个新令牌
if (!clientToken) {
const newToken = generateClientToken();
clientTokens.set(newToken, { timestamp: Date.now() });
const response = NextResponse.json(
{ error: '需要客户端令牌', token: newToken },
{ status: 401 }
);
return response;
}
// 验证现有令牌
if (!validateClientToken(clientToken)) {
const newToken = generateClientToken();
clientTokens.set(newToken, { timestamp: Date.now() });
const response = NextResponse.json(
{ error: '令牌无效或已过期', token: newToken },
{ status: 401 }
);
return response;
}
}
return NextResponse.next();
}
/**
* 配置中间件只在API路径上运行
*/
export const config = {
matcher: [
'/api/:path*',
],
};
+31
View File
@@ -0,0 +1,31 @@
import { IconDefinition } from '@fortawesome/fontawesome-svg-core';
/**
* 工具类型接口
*/
export interface Tool {
/** 工具唯一代码 */
code: string;
/** 工具图标 */
icon: IconDefinition;
/** 工具标题 - 可选,现在使用多语言文件 */
title?: string;
/** 工具所属分类 */
category: string[];
/** 工具描述 - 可选,现在使用多语言文件 */
description?: string;
/** 搜索关键词,包括各种可能的中英文搜索词 */
keywords: string[];
}
/**
* 分类类型接口
*/
export interface Category {
/** 分类唯一代码 */
code: string;
/** 分类名称 - 可选,现在使用多语言文件 */
name?: string;
/** 是否激活 */
active?: boolean;
}
+194
View File
@@ -0,0 +1,194 @@
/**
* 安全工具函数
*/
interface MsCrypto {
getRandomValues: (array: Uint32Array) => Uint32Array;
}
// 生成随机字符串
export function generateRandomString(length: number = 32): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
const crypto = window.crypto || (window as unknown as { msCrypto: MsCrypto }).msCrypto; // 兼容性处理
if (crypto && crypto.getRandomValues) {
// 使用更安全的随机数生成方式
const values = new Uint32Array(length);
crypto.getRandomValues(values);
for (let i = 0; i < length; i++) {
result += chars[values[i] % chars.length];
}
} else {
// 降级方案,不太安全但保证兼容性
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
}
return result;
}
// 计算API请求签名
export function calculateRequestSignature(
url: string,
method: string,
timestamp: number,
nonce: string,
body?: string | null,
secret: string = 'jisuxiang-web-client'
): string {
// 如果在浏览器环境
if (typeof window !== 'undefined') {
// 创建签名字符串
const parts = [
method.toUpperCase(),
encodeURIComponent(url),
timestamp,
nonce,
];
// 如果有请求体,添加请求体的摘要
if (body) {
// 在浏览器中计算简单摘要
let bodyDigest = 0;
for (let i = 0; i < body.length; i++) {
bodyDigest = ((bodyDigest << 5) - bodyDigest) + body.charCodeAt(i);
bodyDigest |= 0; // 转为32位整数
}
parts.push(bodyDigest.toString(16));
}
// 添加密钥
parts.push(secret);
// 计算签名(简化版,实际应该使用更强的哈希算法)
const signatureString = parts.join('&');
let signature = 0;
for (let i = 0; i < signatureString.length; i++) {
signature = ((signature << 5) - signature) + signatureString.charCodeAt(i);
signature |= 0; // 转为32位整数
}
return (signature >>> 0).toString(16).padStart(8, '0');
}
// 服务器端环境
return 'server-side-signature';
}
// 生成安全请求头
export function generateSecurityHeaders(
url: string,
method: string = 'GET',
body?: string | null
): Record<string, string> {
const timestamp = Math.floor(Date.now() / 1000);
const nonce = generateRandomString(16);
const signature = calculateRequestSignature(url, method, timestamp, nonce, body);
return {
'X-Timestamp': timestamp.toString(),
'X-Nonce': nonce,
'X-Signature': signature,
'X-Client': 'jisuxiang-web',
};
}
// 检测可能的爬虫
export function isPotentialBot(): boolean {
if (typeof window === 'undefined') return false;
// 检查navigator对象
const nav = window.navigator;
const ua = nav.userAgent.toLowerCase();
// 检测已知爬虫或自动化工具的痕迹
const botPatterns = [
'bot', 'spider', 'crawl', 'slurp', 'scrape', 'fetch',
'phantomjs', 'headless', 'selenium', 'webdriver', 'puppeteer',
'electron', 'browsix', 'curl', 'wget', 'axios', 'http-client',
];
// 检查用户代理
if (botPatterns.some(pattern => ua.includes(pattern))) {
return true;
}
// 检查特定浏览器功能
if (
!('localStorage' in window) ||
!('sessionStorage' in window) ||
!('indexedDB' in window) ||
!('document' in window) ||
!('history' in window)
) {
return true;
}
// 检查屏幕和窗口属性
if (
window.screen.height === 0 ||
window.screen.width === 0 ||
window.outerHeight === 0 ||
window.outerWidth === 0
) {
return true;
}
// 检查WebDriver属性(自动化工具)
// 使用类型断言访问可能存在的属性
if (
(nav as unknown as {webdriver?: boolean}).webdriver ||
(nav as unknown as {domAutomation?: boolean}).domAutomation ||
nav.hasOwnProperty('__webdriver_script_fn')
) {
return true;
}
return false;
}
// 获取浏览器指纹
export function getBrowserFingerprint(): string {
if (typeof window === 'undefined') return 'server-side';
const components = [
navigator.userAgent,
navigator.language,
navigator.platform,
new Date().getTimezoneOffset(),
screen.colorDepth,
navigator.cookieEnabled,
screen.width + 'x' + screen.height,
('localStorage' in window ? 'yes' : 'no'),
('sessionStorage' in window ? 'yes' : 'no'),
('indexedDB' in window ? 'yes' : 'no'),
];
// 计算简单的指纹
const fingerprint = components.join('###');
let hash = 0;
for (let i = 0; i < fingerprint.length; i++) {
hash = ((hash << 5) - hash) + fingerprint.charCodeAt(i);
hash |= 0;
}
return (hash >>> 0).toString(16);
}
// 添加请求反爬虫头
export function addAntiCrawlingHeaders(headers: Record<string, string> = {}): Record<string, string> {
const fingerprint = getBrowserFingerprint();
const isBot = isPotentialBot();
return {
...headers,
'X-Browser-Fingerprint': fingerprint,
'X-Client-Type': isBot ? 'potential-bot' : 'browser',
'X-Requested-With': 'XMLHttpRequest',
};
}