diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..1fdda00 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,106 @@ +# Project Architecture + +## Overview + +This is a React-based developer tools web application built with TypeScript. The application provides various utility tools including timestamp conversion, timers, text comparison, JSON formatting, encoding/decoding, encryption, QR code generation, and IP address lookup. + +## Technology Stack + +- **Frontend Framework**: React with TypeScript +- **Routing**: React Router v6 +- **UI Components**: Ant Design (antd) +- **Build Tool**: Create React App +- **Deployment**: GitHub Pages + +## Project Structure + +``` +. +├── public/ # Static assets +├── src/ # Source code +│ ├── components/ # Reusable UI components +│ │ └── tools/ # Individual tool components +│ ├── pages/ # Page-level components +│ ├── router/ # Routing configuration +│ └── App.tsx # Main application component +├── docs/ # Documentation +└── package.json # Project dependencies and scripts +``` + +## Key Components + +### 1. Routing (src/router/Router.tsx) + +The application uses React Router v6 for client-side routing. The main routes include: + +- `/` and `/home` - Homepage +- `/tools/*` - Tools layout with nested routes for individual tools: + - `/tools/timestamp` - Timestamp converter + - `/tools/timer` - Timer utility + - `/tools/textdiff` - Text comparison tool + - `/tools/json` - JSON formatting tool + - `/tools/encoder_decoder` - Encoding/decoding utilities + - `/tools/encryption` - Encryption/decryption utilities + - `/tools/qrcode` - QR code generator + - `/tools/ipaddress` - IP address lookup + - `/tools/cron` - Cron expression parser and scheduler + +### 2. Tools Layout (src/pages/ToolsLayout.tsx) + +The ToolsLayout component serves as the main layout for all tool pages with: + +- A horizontal navigation menu with tab items for each tool +- Dynamic tab selection based on the current URL path +- Content area that renders the active tool component via `` + +Key features: +- Uses `useLocation` hook to track URL changes +- Implements `useState` and `useEffect` to dynamically update the selected tab +- Automatically activates the corresponding tab based on the URL path + +### 3. Individual Tool Components + +Each tool is implemented as a standalone component in the `src/components/tools/` directory: + +- **TimestampPanel** - Time conversion utilities +- **TimerPanel** - Countdown and stopwatch functionality +- **TextDiffTool** - Text comparison with visual highlighting +- **JsonTools** - JSON formatting and validation +- **EncoderDecoderTabs** - Multiple encoding/decoding formats +- **EncryptionTabs** - Various encryption/decryption algorithms +- **QRCodeGenerator** - QR code generation from text input +- **IPAddressView** - IP address information lookup +- **CronTool** - Cron expression parsing and schedule prediction + +## Data Flow + +1. User navigates to a URL (e.g., `/tools/json`) +2. React Router matches the route and renders the `ToolsLayout` component +3. `ToolsLayout` determines the active tab based on the URL path +4. The corresponding tool component is rendered in the content area via `` +5. User interacts with the tool, which manages its own state and UI + +## GitHub Pages Deployment + +The application is configured for deployment to GitHub Pages with special handling in `public/index.html` to support client-side routing: + +- Includes a script to handle redirects for single-page applications +- Uses the `spa-github-pages` approach to ensure proper routing + +## State Management + +- Each tool component manages its own local state using React hooks +- The ToolsLayout component manages the active tab state +- No external state management library is used (e.g., Redux, Zustand) + +## Styling + +- Primarily uses Ant Design components for UI +- Custom CSS is minimal and located in `src/App.css` +- Responsive design through Ant Design's built-in responsive features + +## Build Process + +- Uses Create React App's default build process +- Optimizes assets for production deployment +- Outputs to the `build/` directory \ No newline at end of file diff --git a/src/App.css b/src/App.css index 74b5e05..748319e 100644 --- a/src/App.css +++ b/src/App.css @@ -32,7 +32,207 @@ from { transform: rotate(0deg); } + to { transform: rotate(360deg); } } + +/* Bright Homepage Styles */ +.bright-homepage { + min-height: 100vh; + background-color: #f0f2f5; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; +} + +.bright-header { + display: flex; + align-items: center; + padding: 0 24px; + background-color: #fff; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + position: sticky; + top: 0; + z-index: 100; +} + +.logo { + margin-right: 24px; +} + +.nav-icons { + display: flex; + align-items: center; +} + +.nav-icon-button { + font-size: 18px; + margin-left: 12px; + color: #1890ff; +} + +.nav-icon-button:hover { + color: #40a9ff; +} + +.bright-content { + padding: 24px; + max-width: 1200px; + margin: 0 auto; + width: 100%; +} + +/* Terminal Styles */ +.terminal-container { + background-color: #1e1e1e; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + margin: 24px 0; + overflow: hidden; + font-family: 'Courier New', monospace; +} + +.terminal-header { + background-color: #333; + padding: 8px 12px; + display: flex; + align-items: center; +} + +.terminal-dots { + display: flex; + margin-right: 12px; +} + +.dot { + width: 12px; + height: 12px; + border-radius: 50%; + margin-right: 6px; +} + +.dot.red { + background-color: #ff5f56; +} + +.dot.yellow { + background-color: #ffbd2e; +} + +.dot.green { + background-color: #27c93f; +} + +.terminal-title { + color: #aaa; + font-size: 14px; + flex: 1; + text-align: center; +} + +.terminal-body { + padding: 20px; + color: #ccc; + line-height: 1.5; + font-size: 16px; + text-align: left; + min-height: 400px; +} + +.terminal-line { + margin-bottom: 12px; + display: flex; + align-items: baseline; + justify-content: flex-start; +} + +.prompt { + color: #4caf50; + font-size: 16px; + margin-right: 12px; + white-space: nowrap; + flex-shrink: 0; +} + +.command { + color: #fff; + font-size: 16px; +} + +.terminal-response { + color: #ccc; + margin: 0 0 16px 0; + text-align: left; +} + +.terminal-response p { + margin: 8px 0; + font-size: 16px; +} + +.cursor { + color: #fff; + animation: blink 1s infinite; + display: inline-block; + width: 8px; + height: 16px; + background-color: #fff; + vertical-align: middle; + margin-left: 2px; +} + +@keyframes blink { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0; + } +} + +/* Contact Section */ +.contact-section { + margin: 48px 0; +} + +.contact-buttons { + display: flex; + justify-content: center; + margin-top: 24px; +} + +.contact-button { + background-color: #fff; + border: 1px solid #d9d9d9; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transition: all 0.3s; +} + +.contact-button:hover { + transform: translateY(-4px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); + border-color: #1890ff; +} + +.contact-button .anticon { + margin-right: 8px; +} + +/* Footer */ +.bright-footer { + background-color: #fff; + border-top: 1px solid #d9d9d9; + padding: 24px 0; + text-align: center; + margin-top: 48px; +} + +.footer-content { + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; +} \ No newline at end of file diff --git a/src/components/tools/CronTool.tsx b/src/components/tools/CronTool.tsx new file mode 100644 index 0000000..c6e659d --- /dev/null +++ b/src/components/tools/CronTool.tsx @@ -0,0 +1,122 @@ +import { Card, Form, Input, Button, List, Typography, Flex } from 'antd'; +import React, { useState } from 'react'; +import { CronExpressionParser } from '../../utils/cronParser'; +import { CronSchedulePredictor } from '../../utils/cronScheduler'; + +const { Title, Text } = Typography; + +const CronTool: React.FC = () => { + const [parsedResult, setParsedResult] = useState<{ + minute: string; + hour: string; + dayOfMonth: string; + month: string; + dayOfWeek: string; + description: string; + } | null>(null); + + const [schedules, setSchedules] = useState([]); + const [error, setError] = useState(null); + + const handleParse = (values: { expression: string }) => { + try { + const result = CronExpressionParser.parse(values.expression); + setParsedResult(result); + setError(null); + + // 预测接下来的5个调度时间 + const nextSchedules = CronSchedulePredictor.predictNextSchedules(values.expression, 5); + setSchedules(nextSchedules); + } catch (err) { + setError(err instanceof Error ? err.message : '解析失败'); + setParsedResult(null); + setSchedules([]); + } + }; + + return ( + + +
+ + + + + + +
+ + {error && ( + + {error} + + )} + + {parsedResult && ( + + 解析结果 + 分钟: {parsedResult.minute}
+ 小时: {parsedResult.hour}
+ 日期: {parsedResult.dayOfMonth}
+ 月份: {parsedResult.month}
+ 星期: {parsedResult.dayOfWeek}
+ 描述: {parsedResult.description} +
+ )} + + {schedules.length > 0 && ( + + 预测未来调度时间 + ( + + {date.toLocaleString('zh-CN')} + + )} + /> + + )} +
+ + + +

Cron表达式由5个字段组成,用空格分隔:

+
    +
  • 分钟 (0-59)
  • +
  • 小时 (0-23)
  • +
  • 日期 (1-31)
  • +
  • 月份 (1-12)
  • +
  • 星期 (0-7, 0和7都表示周日)
  • +
+

特殊字符说明:

+
    +
  • * 表示匹配该字段的所有值
  • +
  • - 表示范围,如1-5表示1到5
  • +
  • , 表示列表,如1,3,5表示1、3、5
  • +
  • / 表示步长,如*/5表示每隔5个单位
  • +
+

示例:

+
    +
  • * * * * * - 每分钟执行
  • +
  • 0 9 * * * - 每天上午9点执行
  • +
  • 0 9 * * 1 - 每周一上午9点执行
  • +
  • 0 */2 * * * - 每隔2小时执行
  • +
+
+
+
+ ); +}; + +export default CronTool; \ No newline at end of file diff --git a/src/components/tools/EncoderDecoder.tsx b/src/components/tools/EncoderDecoder.tsx new file mode 100644 index 0000000..67202a6 --- /dev/null +++ b/src/components/tools/EncoderDecoder.tsx @@ -0,0 +1,95 @@ +import { Button, Form, message, Space, Tabs } from 'antd'; +import React, { useState } from 'react'; +import CopyableTextArea from '../CopyableTextArea'; + +const CaseConvertForm: React.FC = () => { + const [output, setOutput] = useState(''); + const [form] = Form.useForm(); + + function doEncode() { + form + .validateFields() + .then(values => { + setOutput(values.input.toUpperCase()); + }) + .catch(err => { + console.log('encode fail', err); + message.error('编码失败!'); + }); + } + + function doDecode() { + form + .validateFields() + .then(values => { + setOutput(values.input.toLowerCase()); + }) + .catch(err => { + console.log('decode fail', err); + message.error('解码失败!'); + }); + } + + function doExchange() { + const input = form.getFieldsValue()['input']; + form.setFieldValue('input', output); + setOutput(input); + } + + return ( + <> +
+ + + + + + + + + + + + + + + ); +}; + +export default function EncoderDecoderTabs() { + return ( + <> + , + }, + ]} + /> + + ); +} diff --git a/src/components/tools/IPAddressView.tsx b/src/components/tools/IPAddressView.tsx new file mode 100644 index 0000000..03c3d3f --- /dev/null +++ b/src/components/tools/IPAddressView.tsx @@ -0,0 +1,162 @@ +import React, { useEffect, useState } from 'react'; +import { Button, Flex, Input, message, Space, Table } from 'antd'; +import Title from 'antd/es/typography/Title'; +import CopyButton from '../CopyButton'; +import CopyableTextArea from '../CopyableTextArea'; + +const IPv4_REGEX = + /^((?!00)\d{1,3}|0{0,2}\d{1,2}|1\d{2}|2[0-4]\d|25[0-5])(\.((?!00)\d{1,3}|0{0,2}\d{1,2}|1\d{2}|2[0-4]\d|25[0-5])){3}$/; +const IPv6_REGEX = + /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/; + +export default function IPAddressView() { + const columns = [ + { + key: 'ip', + title: 'IP地址', + dataIndex: 'query', + }, + { + key: 'country', + title: '国家', + dataIndex: 'country', + }, + { + key: 'countryCode', + title: '国家码', + dataIndex: 'countryCode', + }, + { + key: 'region', + title: '地区', + dataIndex: 'region', + }, + { + key: 'regionName', + title: '地区名', + dataIndex: 'regionName', + }, + { + key: 'city', + title: '城市', + dataIndex: 'city', + }, + { + key: 'timezone', + title: '时区', + dataIndex: 'timezone', + }, + { + key: 'latitude', + title: '纬度', + dataIndex: 'lat', + }, + { + key: 'longitude', + title: '经度', + dataIndex: 'lon', + }, + { + key: 'isp', + title: 'ISP', + dataIndex: 'isp', + }, + { + key: 'org', + title: '组织', + dataIndex: 'org', + }, + { + key: 'as', + title: 'AS', + dataIndex: 'as', + }, + ]; + + const [ip, setIP] = useState(''); + const [ipInfo, setIPInfo] = useState>({}); + + useEffect(() => { + queryIPInfo(''); + }, []); + + async function queryIPInfo(ip: string) { + ip = (ip || '').trim(); + if (ip && !IPv4_REGEX.test(ip) && !IPv6_REGEX.test(ip)) { + message.error('IP地址格式错误'); + return; + } + fetch('http://ip-api.com/json/' + ip, { + method: 'GET', + }) + .then(res => res.json()) + .then(data => { + setIPInfo(data); + }) + .catch(err => { + console.error(err); + message.error('IP地址查询失败'); + }); + } + + return ( + <> + + + IP地址信息 + + setIP(e.target.value)} + /> + + + > + bordered={true} + size="small" + columns={[ + { + key: 'key', + title: '属性', + dataIndex: 'key', + }, + { + key: 'value', + title: '属性值', + dataIndex: 'value', + }, + ]} + dataSource={columns.map(col => ({ + key: col.title, + value: ipInfo[col.dataIndex], + }))} + pagination={false} + /> +

+ 以上IP信息来源于 + + http://ip-api.com + + 网站。 +

+
+ + CURL命令 + } + /> + JSON格式结果 + + +
+ + ); +} diff --git a/src/components/tools/JsonTools.tsx b/src/components/tools/JsonTools.tsx index bee9cec..63314b2 100644 --- a/src/components/tools/JsonTools.tsx +++ b/src/components/tools/JsonTools.tsx @@ -1,7 +1,11 @@ -import React, { useState } from 'react'; -import { Button, Flex, Input, message, Select, Tabs } from 'antd'; +import React, { useState, useRef, useEffect } from 'react'; +import { Button, Flex, Input, message, Select, Switch, Tabs, InputRef } from 'antd'; import { Splitter } from 'antd'; -import ReactJson from 'react-json-view'; +import ReactJson, { InteractionProps, OnSelectProps, ThemeKeys } from 'react-json-view'; + +interface JsonViewerProps { + initialData?: object; +} const jsonThemes = [ 'apathy', @@ -32,37 +36,190 @@ const jsonThemes = [ 'pop', 'railscasts', 'rjv-default', - 'shapershift', - 'shapershift:inverted', + 'shapeshifter', + 'shapeshifter:inverted', 'solarized', - 'sumerfruit', - 'sumerfruit:inverted', + 'summerfruit', + 'summerfruit:inverted', 'threezerotwofour', 'tomorrow', 'tube', 'twilight', ]; -const JsonView = () => { +const JsonViewer: React.FC = ({ initialData }) => { const [textValue, setTextValue] = useState(''); - const [jsonValue, setJsonValue] = useState({}); - const [indentWidth, setIndentWith] = useState(4); - const [sortKeys, setSortKeys] = useState(false); - const [theme, setTheme] = useState('bright:inverted'); + const [jsonValue, setJsonValue] = useState(initialData || {}); + const [cachedJsonValue, setCachedJsonValue] = useState(undefined); + const [indentWidth, setIndentWidth] = useState(2); + const [isSortKeys, setIsSortKeys] = useState(false); + const [theme, setTheme] = useState('rjv-default'); + const [collapsed, setCollapsed] = useState(false); + const [enableEditing, setEnableEditing] = useState(true); + const jsonViewRef = useRef(null); + const textAreaRef = useRef(null); + const lineNumbersRef = useRef(null); + + // Initialize with sample data if no initialData provided + useEffect(() => { + if (!initialData) { + const sampleData = { + name: "Vyckey", + email: "vyckey@qq.com", + avatar: "https://vyckey.github.io/avatar.jpg", + website: "https://vyckey.github.io", + address: { + city: "Shanghai", + country: "China" + }, + hobbies: ["reading", "coding"], + isActive: true + }; + setJsonValue(sampleData); + setTextValue(JSON.stringify(sampleData, null, indentWidth)); + } else { + setTextValue(JSON.stringify(initialData, null, indentWidth)); + } + }, [initialData, indentWidth]); + + // Synchronize scrolling between textarea and line numbers + useEffect(() => { + const textAreaElement = textAreaRef.current?.input; + const lineNumbersElement = lineNumbersRef.current; + + if (textAreaElement && lineNumbersElement) { + // Get computed styles to match line heights + const computedStyle = window.getComputedStyle(textAreaElement); + const lineHeight = computedStyle.lineHeight; + const fontSize = computedStyle.fontSize; + const fontFamily = computedStyle.fontFamily; + const paddingTop = computedStyle.paddingTop; + + // Apply the same styles to line numbers + if (lineNumbersElement) { + lineNumbersElement.style.lineHeight = lineHeight; + lineNumbersElement.style.fontSize = fontSize; + lineNumbersElement.style.fontFamily = fontFamily; + lineNumbersElement.style.paddingTop = paddingTop; + } + + const handleScroll = () => { + lineNumbersElement.scrollTop = textAreaElement.scrollTop; + }; + + textAreaElement.addEventListener('scroll', handleScroll); + + return () => { + textAreaElement.removeEventListener('scroll', handleScroll); + }; + } + }, [textValue]); function parseJson(text: string) { try { return JSON.parse(text); } catch (e) { console.log('parse json fail', e); - message.error('JSON解析失败:' + e); + message.error('JSON解析失败: ' + e); return null; } } + const handleFormat = (indent: number) => { + const jsonObj = parseJson(textValue); + if (jsonObj) { + setTextValue(JSON.stringify(jsonObj, null, indent)); + } + }; + + const handleCompress = () => { + const jsonObj = parseJson(textValue); + if (jsonObj) { + setTextValue(JSON.stringify(jsonObj)); + } + }; + + const handleValidate = () => { + if (parseJson(textValue)) { + message.success('JSON格式正确!'); + } + }; + + const handleParse = () => { + const jsonObj = parseJson(textValue); + if (jsonObj) { + setJsonValue(jsonObj); + } + }; + + const handleStringify = () => { + setTextValue(JSON.stringify(jsonValue, null, indentWidth)); + }; + + const handleEdit = (edit: InteractionProps) => { + if (enableEditing) { + setJsonValue(edit.updated_src); + return true; + } + return false; + }; + + const handleAdd = (add: InteractionProps) => { + if (enableEditing) { + setJsonValue(add.updated_src); + return true; + } + return false; + }; + + const handleDelete = (del: InteractionProps) => { + if (enableEditing) { + setJsonValue(del.updated_src); + return true; + } + return false; + }; + + function sortKeys(obj: unknown): unknown { + if (Array.isArray(obj)) { + return obj.map(item => sortKeys(item)); + } else if (obj !== null && typeof obj === 'object') { + const o = obj as Record; + const sortedObj: Record = {}; + Object.keys(o).sort().forEach(key => { + sortedObj[key] = sortKeys(o[key]); + }); + return sortedObj; + } + return obj; + } + + const handleSortKeys = (isSortKeys: boolean) => { + setIsSortKeys(isSortKeys); + if (isSortKeys) { + setJsonValue((preObj: object) => { + setCachedJsonValue(preObj); + + const sortedObj = sortKeys(preObj); + setTextValue(JSON.stringify(sortedObj, null, indentWidth)); + return sortedObj as object; + }); + } else if (cachedJsonValue) { + console.log('Restoring cached JSON value'); + setJsonValue(cachedJsonValue); + setTextValue(JSON.stringify(cachedJsonValue, null, indentWidth)); + setCachedJsonValue(undefined); + } + }; + + const handleSelect = (select: OnSelectProps) => { + console.log('Selected:', select); + // Handle selection if needed + }; + return ( <> - + ({ + label: width + '空格美化', + value: width, + }))} + onChange={(val: number) => handleFormat(val)} /> + + + ({ + label: `折叠到 ${i + 1} 层`, + value: i + 1, + })), + ] as { label: string; value: boolean | number }[] + } + onChange={(val: boolean | number) => setCollapsed(val)} + style={{ width: 120 }} + /> +
+ 编辑 + +
-

+ style={{ minHeight: 600, boxShadow: '0 0 10px rgba(0, 0, 0, 0.1)', marginTop: 16 }}> - { - setTextValue(e.target.value); - }} - /> +
+ {/* Line numbers */} +
+ {textValue ? textValue.split('\n').map((_, index) => ( +
+ {index + 1} +
+ )) :
1
} +
+ {/* Text area */} + { + setTextValue(e.target.value); + }} + /> +
- +
+ +
@@ -163,7 +352,7 @@ export default function JsonTools() { { key: 'json_view', label: 'JSON解析', - children: , + children: , }, ]} /> diff --git a/src/components/tools/Timestamp.tsx b/src/components/tools/Timestamp.tsx index 2492e98..6a40c5d 100644 --- a/src/components/tools/Timestamp.tsx +++ b/src/components/tools/Timestamp.tsx @@ -16,26 +16,29 @@ const Timestamp: React.FC = () => { const [nowTime, setNowTime] = useState(moment()); const [isRunning, setIsRunning] = useState(true); const [isSeconds, setIsSeconds] = useState(false); - const timerRef = useRef(null); + const timerRef = useRef(null); useEffect(() => { - const timer = setInterval(() => { - if (isRunning) { + if (isRunning) { + const timer = setInterval(() => { setNowTime(moment()); - } - }, 1000); - timerRef.current = timer; + }, 1000); + timerRef.current = timer; + } return () => { - clearInterval(timer); + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } }; }, [isRunning]); return ( - + @@ -157,7 +158,7 @@ const TimeConverter: React.FC = () => { 日期时间转时间戳 - layout="inline" + layout='inline' initialValues={{ date: moment.tz('Asia/Shanghai').format('YYYY-MM-DD HH:mm:ss'), type: 'millis', @@ -165,7 +166,7 @@ const TimeConverter: React.FC = () => { }} onFinish={convertDateToTime}> { ]}> - + ({ @@ -187,7 +188,7 @@ const TimeConverter: React.FC = () => { /> - @@ -202,7 +203,7 @@ const TimeConverter: React.FC = () => { const TimestampPanel: React.FC = () => { return ( - + diff --git a/src/pages/Homepage.tsx b/src/pages/Homepage.tsx index 2c5bdac..ec5f7f8 100644 --- a/src/pages/Homepage.tsx +++ b/src/pages/Homepage.tsx @@ -1,24 +1,131 @@ -import logo from '../logo.svg'; +import React, { useState, useEffect } from 'react'; +import { Button, Typography, Layout, Menu } from 'antd'; +import { + GithubOutlined, + MailOutlined, + HomeOutlined, + ToolOutlined, +} from '@ant-design/icons'; import '../App.css'; +import { useNavigate } from 'react-router-dom'; + +const { Title, Paragraph } = Typography; +const { Header, Footer, Content } = Layout; function Homepage() { + const navigate = useNavigate(); + const [currentTime, setCurrentTime] = useState(new Date()); + + useEffect(() => { + // Update time every second + const timer = setInterval(() => { + setCurrentTime(new Date()); + }, 1000); + + return () => { + clearInterval(timer); + }; + }, []); + + const menuItems = [ + { + key: 'home', + label: 'HomePage', + icon: , + }, + { + key: 'tools', + label: 'Developer Tools', + icon: , + }, + ]; + + const onClickMenu: import('antd').MenuProps['onClick'] = (e) => { + if (e.key === 'home') { + navigate('/'); + } else { + navigate(`/tools/timestamp`); + } + }; + return ( -
-
- logo -

- Edit src/App.js and save to reload. -

- - Learn React - -
-
+ + {/* Navigation Bar */} +
+
+ VYCKEY +
+ +
+
+
+ + {/* Main Content */} + +
+
+
+ + + +
+
vyckey@profile: ~
+
+
+
+ vyckey@developer:~$ + cat about.txt +
+
+

Hello! I'm a passionate Full Stack Developer and Cyber Security Enthusiast.

+

I specialize in creating efficient, secure, and innovative web applications using modern technologies.

+

My expertise includes React, TypeScript, Node.js, and various security practices.

+
+
+ vyckey@developer:~$ + cat mission.txt +
+
+

Building secure, efficient, and innovative applications that make a positive impact on the world.

+
+
+ vyckey@developer:~$ + _ +
+
+
+
+ + {/* Footer */} +
+
+ + © {new Date().getFullYear()} Vyckey. Building the future with code | {currentTime.toLocaleString()} + +
+
+
); } -export default Homepage; +export default Homepage; \ No newline at end of file diff --git a/src/pages/ToolsLayout.tsx b/src/pages/ToolsLayout.tsx index 8a5a668..b6f6911 100644 --- a/src/pages/ToolsLayout.tsx +++ b/src/pages/ToolsLayout.tsx @@ -1,14 +1,15 @@ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { ClockCircleOutlined, DiffOutlined, CodeOutlined, LockOutlined, QrcodeOutlined, + HomeOutlined, + ScheduleOutlined, } from '@ant-design/icons'; import { Layout, Menu, MenuProps, theme } from 'antd'; -// import '../layout.css'; -import { Outlet, useNavigate } from 'react-router-dom'; +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; const { Header, Content, Footer } = Layout; @@ -30,6 +31,11 @@ const topItems: MenuItem[] = [ label: '文本对比', icon: , }, + { + key: 'encoder_decoder', + label: '编码/解码', + icon: , + }, { key: 'encryption', label: '加密/解密', @@ -45,6 +51,16 @@ const topItems: MenuItem[] = [ label: '二维码生成', icon: , }, + { + key: 'ipaddress', + label: 'IP地址查询', + icon: , + }, + { + key: 'cron', + label: 'Cron表达式', + icon: , + }, ]; const ToolsLayout: React.FC = () => { @@ -53,10 +69,22 @@ const ToolsLayout: React.FC = () => { } = theme.useToken(); const navigate = useNavigate(); + const location = useLocation(); + + // get tab key from url path + const getCurrentTabKey = useCallback(() => { + const pathParts = location.pathname.split('/'); + const lastPart = pathParts[pathParts.length - 1]; + // check if lastPart is in topItems + const validKeys = topItems.map(item => item!.key); + return validKeys.includes(lastPart) ? lastPart : 'timestamp'; + }, [location]); + + const [selectedKey, setSelectedKey] = useState(getCurrentTabKey()); useEffect(() => { - navigate('timestamp'); - }, [navigate]); + setSelectedKey(getCurrentTabKey()); + }, [location, getCurrentTabKey]); const onClickMenu: MenuProps['onClick'] = e => { console.log('click ', e); @@ -66,11 +94,11 @@ const ToolsLayout: React.FC = () => { return ( -
+
, }, + { + path: 'encoder_decoder', + element: , + }, { path: 'encryption', element: , @@ -51,6 +58,14 @@ const router = createBrowserRouter([ path: 'qrcode', element: , }, + { + path: 'ipaddress', + element: , + }, + { + path: 'cron', + element: , + }, ], }, ]); diff --git a/src/utils/cronParser.ts b/src/utils/cronParser.ts new file mode 100644 index 0000000..b2fb1d6 --- /dev/null +++ b/src/utils/cronParser.ts @@ -0,0 +1,132 @@ +export class CronExpressionParser { + private static readonly cronParts = [ + 'minute', + 'hour', + 'dayOfMonth', + 'month', + 'dayOfWeek', + ]; + + /** + * Parse a cron expression + * @param expression cron expression + * @returns parse result + */ + static parse(expression: string): { + minute: string; + hour: string; + dayOfMonth: string; + month: string; + dayOfWeek: string; + description: string; + } { + // Remove whitespace and split the expression + const parts = expression.trim().split(/\s+/); + + // Standard cron expression should have 5 parts + if (parts.length !== 5) { + throw new Error('Invalid cron expression: must have 5 parts'); + } + + const [minute, hour, dayOfMonth, month, dayOfWeek] = parts; + + return { + minute, + hour, + dayOfMonth, + month, + dayOfWeek, + description: this.describeCron(expression), + }; + } + + /** + * Describe a cron expression in human-readable form + * @param expression cron expression + * @returns human-readable description + */ + private static describeCron(expression: string): string { + try { + const { minute, hour, dayOfMonth, month, dayOfWeek } = this.parse(expression); + + let description = '在'; + + // 解析分钟 + if (minute === '*') { + description += '每分钟'; + } else if (minute.includes('/')) { + const [, interval] = minute.split('/'); + description += `每隔${interval}分钟`; + } else if (minute.includes('-')) { + description += `${minute}分钟`; + } else { + description += `${minute}分钟`; + } + + // 解析小时 + if (hour === '*') { + description += '的每小时'; + } else if (hour.includes('/')) { + const [, interval] = hour.split('/'); + description += `的每隔${interval}小时`; + } else if (hour.includes('-')) { + description += `的${hour}点`; + } else { + description += `的${hour}点`; + } + + // 解析日期 + if (dayOfMonth !== '*' && dayOfWeek !== '*') { + description += `,每月${dayOfMonth}日或每周${this.getDayOfWeekName(dayOfWeek)}`; + } else if (dayOfMonth !== '*') { + description += `,每月${dayOfMonth}日`; + } else if (dayOfWeek !== '*') { + description += `,每周${this.getDayOfWeekName(dayOfWeek)}`; + } else { + description += ',每天'; + } + + // 解析月份 + if (month !== '*') { + if (month.includes('-')) { + description += `,${month}月`; + } else { + description += `,${month}月`; + } + } + + return description; + } catch (error) { + console.error('Error describing cron expression:', error); + return '无法解析的表达式'; + } + } + + /** + * Get the name of the day of the week + * @param dayOfWeek day of week expression + * @returns day of week name + */ + private static getDayOfWeekName(dayOfWeek: string): string { + const dayNames = ['日', '一', '二', '三', '四', '五', '六', '日']; + + if (dayOfWeek === '*') { + return '每天'; + } + + if (dayOfWeek.includes('-')) { + return `周${dayOfWeek}`; + } + + if (dayOfWeek.includes('/')) { + return `每隔${dayOfWeek.split('/')[1]}天`; + } + + const dayIndex = parseInt(dayOfWeek, 10); + if (!isNaN(dayIndex) && dayIndex >= 0 && dayIndex <= 7) { + return `周${dayNames[dayIndex]}`; + } + + return dayOfWeek; + } +} \ No newline at end of file diff --git a/src/utils/cronScheduler.ts b/src/utils/cronScheduler.ts new file mode 100644 index 0000000..9011c3c --- /dev/null +++ b/src/utils/cronScheduler.ts @@ -0,0 +1,111 @@ +export class CronSchedulePredictor { + /** + * Forecast the next N schedule times based on a cron expression + * @param expression cron expression + * @param count number of predictions + * @param startDate start date (default is current time) + * @returns array of scheduled times + */ + static predictNextSchedules( + expression: string, + count: number = 5, + startDate: Date = new Date() + ): Date[] { + try { + const parts = expression.trim().split(/\s+/); + if (parts.length !== 5) { + throw new Error('Invalid cron expression'); + } + + const [minute, hour, dayOfMonth, month, dayOfWeek] = parts; + const schedules: Date[] = []; + const currentDate = new Date(startDate.getTime()); + + while (schedules.length < count) { + currentDate.setMinutes(currentDate.getMinutes() + 1); + + // check if current date matches the cron expression + if (this.matchesCron(currentDate, minute, hour, dayOfMonth, month, dayOfWeek)) { + schedules.push(new Date(currentDate.getTime())); + } + + // prevent infinite loop + if (currentDate.getTime() - startDate.getTime() > 365 * 24 * 60 * 60 * 1000) { + break; + } + } + + return schedules; + } catch (error) { + console.error('Error predicting schedules:', error); + return []; + } + } + + /** + * Check if a date matches the cron expression + * @param date date to check + * @param minute minute expression + * @param hour hour expression + * @param dayOfMonth day of month expression + * @param month month expression + * @param dayOfWeek day of week expression + * @returns whether it matches + */ + private static matchesCron( + date: Date, + minute: string, + hour: string, + dayOfMonth: string, + month: string, + dayOfWeek: string + ): boolean { + return ( + this.matchesField(date.getMinutes(), minute) && + this.matchesField(date.getHours(), hour) && + this.matchesField(date.getDate(), dayOfMonth) && + this.matchesField(date.getMonth() + 1, month) && + this.matchesField(date.getDay(), dayOfWeek === '0' || dayOfWeek === '7' ? '0,7' : dayOfWeek) + ); + } + + /** + * Check if a field value matches the cron field expression + * @param value field value + * @param expression field expression + * @returns whether it matches + */ + private static matchesField(value: number, expression: string): boolean { + // Handle wildcard + if (expression === '*') { + return true; + } + + // handle list (e.g., 1,2,3) + if (expression.includes(',')) { + return expression.split(',').some(part => this.matchesField(value, part)); + } + + // handle range (e.g., 1-5) + if (expression.includes('-') && !expression.includes('/')) { + const [start, end] = expression.split('-').map(Number); + return value >= start && value <= end; + } + + // handle step (e.g., */5, 1-5/2) + if (expression.includes('/')) { + const [range, step] = expression.split('/'); + const stepNum = parseInt(step, 10); + + if (range === '*') { + return value % stepNum === 0; + } else if (range.includes('-')) { + const [start, end] = range.split('-').map(Number); + return value >= start && value <= end && (value - start) % stepNum === 0; + } + } + + // handle single value + return value === parseInt(expression, 10); + } +} \ No newline at end of file