This commit is contained in:
2026-01-17 15:57:06 +08:00
parent cae4d9eb05
commit 07cac667ae
17 changed files with 7317 additions and 88 deletions

110
.gitignore vendored
View File

@@ -1,90 +1,26 @@
# ---> Vue # Logs
# gitignore template for Vue.js projects logs
# *.log
# Recommended template: Node.gitignore npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# TODO: where does this rule come from? node_modules
docs/_book dist
dist-ssr
*.local
# TODO: where does this rule come from? # Editor directories and files
test/ .vscode/*
!.vscode/extensions.json
# ---> JetBrains .idea
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider .DS_Store
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 *.suo
*.ntvs*
# User-specific stuff *.njsproj
.idea/**/workspace.xml *.sln
.idea/**/tasks.xml *.sw?
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
.cursor

View File

@@ -1,2 +1,98 @@
# Toolbox # 🛠️ 工具箱 - Toolbox
## 📦 安装
### 前置要求
- Node.js >= 16.0.0
- npm 或 yarn 或 pnpm
### 安装步骤
1. 克隆项目或下载源码
```bash
git clone <repository-url>
cd Toolbox
```
2. 安装依赖
```bash
npm install
# 或
yarn install
# 或
pnpm install
```
## 🚀 运行
### 开发模式
```bash
npm run dev
# 或
yarn dev
# 或
pnpm dev
```
开发服务器将在 `http://localhost:3000` 启动,并自动在浏览器中打开。
### 构建生产版本
```bash
npm run build
# 或
yarn build
# 或
pnpm build
```
构建产物将输出到 `dist` 目录。
### 预览生产构建
```bash
npm run preview
# 或
yarn preview
# 或
pnpm preview
```
## 📁 项目结构
```
Toolbox/
├── src/
│ ├── views/ # 页面组件
│ ├── router/ # 路由配置
│ │ └── index.js
│ ├── App.vue # 根组件
│ ├── main.js # 应用入口
│ └── style.css # 全局样式
├── index.html # HTML 模板
├── vite.config.js # Vite 配置
├── package.json # 项目配置
└── README.md # 项目说明
```
## 🔧 添加新工具
要添加新的工具页面,只需:
1.`src/views/` 目录下创建新的 Vue 组件
2.`src/router/index.js` 中添加路由配置:
```javascript
{
path: '/your-tool',
name: 'YourTool',
component: () => import('../views/YourTool.vue')
}
```
3.`src/App.vue` 的导航栏中添加链接
4.`src/views/Home.vue``tools` 数组中添加工具信息

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RC707的工具箱</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1231
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "toolbox",
"version": "1.0.0",
"description": "A Vue-based toolbox application",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0"
}
}

136
src/App.vue Normal file
View File

@@ -0,0 +1,136 @@
<template>
<div class="app-container">
<nav class="navbar">
<div class="nav-content">
<router-link to="/" class="logo">
<h1>RC707的工具箱</h1>
</router-link>
<div class="nav-links">
<router-link to="/" class="nav-link">首页</router-link>
<router-link to="/json-formatter" class="nav-link">JSON</router-link>
<router-link to="/encoder-decoder" class="nav-link">编解码</router-link>
<router-link to="/timestamp-converter" class="nav-link">时间戳</router-link>
<router-link to="/color-converter" class="nav-link">颜色</router-link>
</div>
</div>
</nav>
<main class="main-content">
<router-view />
</main>
</div>
</template>
<script setup>
// Vue 3 Composition API
</script>
<style scoped>
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.navbar {
background: #ffffff;
border-bottom: 1px solid #e5e5e5;
padding: 0;
position: sticky;
top: 0;
z-index: 100;
min-height: 40px;
}
.nav-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
display: flex;
justify-content: space-between;
align-items: center;
min-height: 40px;
}
.logo {
text-decoration: none;
color: #1a1a1a;
padding-left: 0.5rem;
}
.logo h1 {
font-size: 1.25rem;
font-weight: 700;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
letter-spacing: -0.02em;
}
.nav-links {
display: flex;
gap: 0;
}
.nav-link {
text-decoration: none;
color: #666666;
font-weight: 500;
font-size: 0.875rem;
padding: 0.5rem 1rem;
border: none;
background: transparent;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: all 0.2s;
cursor: pointer;
}
.nav-link:hover {
color: #1a1a1a;
background: #f5f5f5;
}
.nav-link.router-link-active {
color: #ffffff;
background: #1a1a1a;
border-bottom-color: #1a1a1a;
}
.main-content {
flex: 1;
width: 100%;
padding: 1rem;
}
@media (max-width: 768px) {
.nav-content {
flex-direction: column;
gap: 0;
padding: 0.5rem 0.75rem;
}
.logo {
padding-left: 0;
margin-bottom: 0.5rem;
}
.logo h1 {
font-size: 1.125rem;
}
.nav-links {
flex-wrap: wrap;
justify-content: flex-start;
width: 100%;
}
.nav-link {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
}
.main-content {
padding: 1rem;
}
}
</style>

View File

@@ -0,0 +1,818 @@
<template>
<div class="datetime-picker-wrapper">
<!-- 自定义日期时间选择器面板 -->
<Transition name="picker">
<div v-if="show" class="datetime-picker-panel" @click.stop>
<div class="picker-container">
<!-- 左侧日期选择区域 -->
<div class="date-picker-section">
<!-- 年月导航 -->
<div class="date-header">
<div class="nav-buttons">
<button @click="prevYear" class="nav-btn" title="上一年">«</button>
<button @click="prevMonth" class="nav-btn" title="上一月"></button>
</div>
<div
v-if="!isEditingMonthYear"
@click="startEditingMonthYear"
class="current-month-year editable"
title="点击输入年月"
>
{{ currentViewDate.getFullYear() }} - {{ String(currentViewDate.getMonth() + 1).padStart(2, '0') }}
</div>
<input
v-else
v-model="monthYearInput"
@blur="confirmMonthYear"
@keyup.enter="confirmMonthYear"
@keyup.esc="cancelEditingMonthYear"
class="month-year-input"
placeholder="YYYY-MM"
ref="monthYearInputRef"
/>
<div class="nav-buttons">
<button @click="nextMonth" class="nav-btn" title="下一月"></button>
<button @click="nextYear" class="nav-btn" title="下一年">»</button>
</div>
</div>
<!-- 星期标题 -->
<div class="weekdays">
<div class="weekday" v-for="day in ['日', '一', '二', '三', '四', '五', '六']" :key="day">
{{ day }}
</div>
</div>
<!-- 日期网格 -->
<div class="calendar-grid">
<div
v-for="(day, index) in getCalendarDays()"
:key="index"
@click="selectDate(day)"
:class="[
'calendar-day',
{ 'other-month': !day.isCurrentMonth },
{ 'today': isToday(day) },
{ 'selected': isSelected(day) }
]"
>
{{ day.date.getDate() }}
<span v-if="isToday(day) && !isSelected(day)" class="today-dot"></span>
</div>
</div>
<!-- 此刻按钮 -->
<button @click="selectNow" class="now-btn">此刻</button>
</div>
<!-- 右侧时间选择区域 -->
<div class="time-picker-section">
<div class="time-header">选择时间</div>
<div class="time-selectors">
<!-- 小时选择 -->
<div class="time-column">
<div class="time-list" ref="hourListRef">
<div
v-for="hour in generateTimeOptions('hour')"
:key="hour"
:data-value="hour"
@click="selectTime('hour', hour)"
:class="['time-item', { 'selected': selectedTime.hour === parseInt(hour) }]"
>
{{ hour }}
</div>
</div>
</div>
<!-- 分钟选择 -->
<div class="time-column">
<div class="time-list" ref="minuteListRef">
<div
v-for="minute in generateTimeOptions('minute')"
:key="minute"
:data-value="minute"
@click="selectTime('minute', minute)"
:class="['time-item', { 'selected': selectedTime.minute === parseInt(minute) }]"
>
{{ minute }}
</div>
</div>
</div>
<!-- 秒选择 -->
<div class="time-column">
<div class="time-list" ref="secondListRef">
<div
v-for="second in generateTimeOptions('second')"
:key="second"
:data-value="second"
@click="selectTime('second', second)"
:class="['time-item', { 'selected': selectedTime.second === parseInt(second) }]"
>
{{ second }}
</div>
</div>
</div>
</div>
<!-- 确定按钮 -->
<button @click="confirmSelection" class="confirm-btn">确定</button>
</div>
</div>
</div>
</Transition>
<!-- 遮罩层 -->
<Transition name="mask">
<div v-if="show" class="picker-mask" @click="close"></div>
</Transition>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
precisionType: {
type: String,
default: 'milliseconds', // seconds, milliseconds, nanoseconds
validator: (value) => ['seconds', 'milliseconds', 'nanoseconds'].includes(value)
},
show: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'update:show', 'confirm'])
// 日期时间选择器状态
const currentViewDate = ref(new Date()) // 当前查看的年月
const selectedDate = ref(null) // 选中的日期
const selectedTime = ref({ hour: 0, minute: 0, second: 0 }) // 选中的时间
const hourListRef = ref(null)
const minuteListRef = ref(null)
const secondListRef = ref(null)
const isEditingMonthYear = ref(false) // 是否正在编辑年月
const monthYearInput = ref('') // 年月输入值
const monthYearInputRef = ref(null) // 年月输入框引用
// 日期选择功能
const getCalendarDays = () => {
const year = currentViewDate.value.getFullYear()
const month = currentViewDate.value.getMonth()
// 获取当月第一天和最后一天
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
// 获取第一天是星期几0=周日)
const firstDayWeek = firstDay.getDay()
// 获取上个月的最后几天
const prevMonthLastDay = new Date(year, month, 0).getDate()
const days = []
// 添加上个月的日期
for (let i = firstDayWeek - 1; i >= 0; i--) {
days.push({
date: new Date(year, month - 1, prevMonthLastDay - i),
isCurrentMonth: false
})
}
// 添加当月的日期
for (let i = 1; i <= lastDay.getDate(); i++) {
days.push({
date: new Date(year, month, i),
isCurrentMonth: true
})
}
// 添加下个月的日期补齐到42个6行7列
const remainingDays = 42 - days.length
for (let i = 1; i <= remainingDays; i++) {
days.push({
date: new Date(year, month + 1, i),
isCurrentMonth: false
})
}
return days
}
const prevYear = () => {
const date = new Date(currentViewDate.value)
date.setFullYear(date.getFullYear() - 1)
currentViewDate.value = date
}
const nextYear = () => {
const date = new Date(currentViewDate.value)
date.setFullYear(date.getFullYear() + 1)
currentViewDate.value = date
}
const prevMonth = () => {
const date = new Date(currentViewDate.value)
date.setMonth(date.getMonth() - 1)
currentViewDate.value = date
}
const nextMonth = () => {
const date = new Date(currentViewDate.value)
date.setMonth(date.getMonth() + 1)
currentViewDate.value = date
}
const selectDate = (day) => {
selectedDate.value = new Date(day.date)
// 滚动到选中的时间项
nextTick(() => {
scrollToSelected()
})
}
const isToday = (day) => {
const today = new Date()
return day.date.getDate() === today.getDate() &&
day.date.getMonth() === today.getMonth() &&
day.date.getFullYear() === today.getFullYear()
}
const isSelected = (day) => {
if (!selectedDate.value) return false
return day.date.getDate() === selectedDate.value.getDate() &&
day.date.getMonth() === selectedDate.value.getMonth() &&
day.date.getFullYear() === selectedDate.value.getFullYear()
}
// 时间选择功能
const generateTimeOptions = (type) => {
if (type === 'hour') {
return Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
} else {
return Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))
}
}
const selectTime = (type, value) => {
selectedTime.value[type] = parseInt(value)
// 滚动到选中的项
nextTick(() => {
scrollToSelected()
})
}
const scrollToSelected = () => {
// 滚动小时列表
if (hourListRef.value) {
const hourItem = hourListRef.value.querySelector(`[data-value="${String(selectedTime.value.hour).padStart(2, '0')}"]`)
if (hourItem) {
hourItem.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
// 滚动分钟列表
if (minuteListRef.value) {
const minuteItem = minuteListRef.value.querySelector(`[data-value="${String(selectedTime.value.minute).padStart(2, '0')}"]`)
if (minuteItem) {
minuteItem.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
// 滚动秒列表
if (secondListRef.value) {
const secondItem = secondListRef.value.querySelector(`[data-value="${String(selectedTime.value.second).padStart(2, '0')}"]`)
if (secondItem) {
secondItem.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
}
// 面板控制功能
const close = () => {
emit('update:show', false)
isEditingMonthYear.value = false
monthYearInput.value = ''
}
const confirmSelection = () => {
if (!selectedDate.value) {
selectedDate.value = new Date()
}
const date = new Date(selectedDate.value)
date.setHours(selectedTime.value.hour)
date.setMinutes(selectedTime.value.minute)
date.setSeconds(selectedTime.value.second)
date.setMilliseconds(0)
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')
const milliseconds = String(date.getMilliseconds()).padStart(3, '0')
// 根据精度类型设置格式
let formattedDate = ''
if (props.precisionType === 'seconds') {
formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
} else if (props.precisionType === 'milliseconds') {
formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`
} else {
// 纳秒级
const nanosecondsStr = '000000'
formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}${nanosecondsStr}`
}
emit('update:modelValue', formattedDate)
emit('confirm', formattedDate)
close()
}
const selectNow = () => {
const now = new Date()
selectedDate.value = new Date(now)
currentViewDate.value = new Date(now)
selectedTime.value = {
hour: now.getHours(),
minute: now.getMinutes(),
second: now.getSeconds()
}
nextTick(() => {
scrollToSelected()
})
}
// 开始编辑年月
const startEditingMonthYear = () => {
isEditingMonthYear.value = true
const year = currentViewDate.value.getFullYear()
const month = String(currentViewDate.value.getMonth() + 1).padStart(2, '0')
monthYearInput.value = `${year}-${month}`
nextTick(() => {
if (monthYearInputRef.value) {
monthYearInputRef.value.focus()
monthYearInputRef.value.select()
}
})
}
// 确认年月输入
const confirmMonthYear = () => {
const input = monthYearInput.value.trim()
// 验证格式YYYY-MM 或 YYYY-M
const match = input.match(/^(\d{4})-(\d{1,2})$/)
if (match) {
const year = parseInt(match[1])
const month = parseInt(match[2])
// 验证年份和月份范围
if (year >= 1000 && year <= 9999 && month >= 1 && month <= 12) {
const date = new Date(year, month - 1, 1)
currentViewDate.value = date
isEditingMonthYear.value = false
monthYearInput.value = ''
} else {
// 这里可以添加错误提示,但组件中不依赖父组件的 showToast
if (monthYearInputRef.value) {
monthYearInputRef.value.focus()
monthYearInputRef.value.select()
}
}
} else {
if (monthYearInputRef.value) {
monthYearInputRef.value.focus()
monthYearInputRef.value.select()
}
}
}
// 取消编辑年月
const cancelEditingMonthYear = () => {
isEditingMonthYear.value = false
monthYearInput.value = ''
}
// 监听 show 变化,初始化选择器
watch(() => props.show, (newVal) => {
if (newVal) {
// 如果输入框有值,尝试解析并设置到选择器
if (props.modelValue) {
try {
const dateStr = props.modelValue.trim()
let date = null
if (dateStr.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d{1,3})?$/)) {
date = new Date(dateStr.replace(' ', 'T'))
} else if (dateStr.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?/)) {
date = new Date(dateStr)
} else {
date = new Date(dateStr)
}
if (!isNaN(date.getTime())) {
selectedDate.value = new Date(date)
currentViewDate.value = new Date(date)
selectedTime.value = {
hour: date.getHours(),
minute: date.getMinutes(),
second: date.getSeconds()
}
}
} catch (error) {
// 如果解析失败,使用当前时间
const now = new Date()
selectedDate.value = new Date(now)
currentViewDate.value = new Date(now)
selectedTime.value = {
hour: now.getHours(),
minute: now.getMinutes(),
second: now.getSeconds()
}
}
} else {
// 如果输入框为空,使用当前时间
const now = new Date()
selectedDate.value = new Date(now)
currentViewDate.value = new Date(now)
selectedTime.value = {
hour: now.getHours(),
minute: now.getMinutes(),
second: now.getSeconds()
}
}
// 等待DOM更新后滚动到选中项
nextTick(() => {
scrollToSelected()
})
} else {
isEditingMonthYear.value = false
monthYearInput.value = ''
}
})
</script>
<style scoped>
.datetime-picker-wrapper {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
width: 100%;
}
/* 日期时间选择器面板 */
.picker-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 998;
}
.datetime-picker-panel {
position: relative;
z-index: 999;
width: 500px;
min-width: 500px;
background: #ffffff;
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
border: 1px solid #e5e5e5;
overflow: hidden;
}
.picker-container {
display: flex;
min-height: 320px;
width: 100%;
}
/* 左侧日期选择区域 */
.date-picker-section {
flex: 1;
min-width: 0;
padding: 0.75rem;
display: flex;
flex-direction: column;
border-right: 1px solid #e5e5e5;
}
.date-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.nav-buttons {
display: flex;
gap: 0.125rem;
}
.nav-btn {
padding: 0.125rem 0.375rem;
background: transparent;
border: 1px solid #d0d0d0;
border-radius: 3px;
color: #333333;
cursor: pointer;
font-size: 0.75rem;
transition: all 0.2s;
min-width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.nav-btn:hover {
background: #f5f5f5;
border-color: #1a1a1a;
}
.current-month-year {
font-size: 0.8125rem;
font-weight: 500;
color: #333333;
}
.current-month-year.editable {
cursor: pointer;
padding: 0.125rem 0.25rem;
border-radius: 3px;
transition: all 0.2s;
user-select: none;
}
.current-month-year.editable:hover {
background: #f5f5f5;
}
.month-year-input {
font-size: 0.8125rem;
font-weight: 500;
color: #333333;
border: 1px solid #1a1a1a;
border-radius: 3px;
padding: 0.125rem 0.25rem;
text-align: center;
width: 80px;
outline: none;
background: #ffffff;
}
.month-year-input:focus {
border-color: #1a1a1a;
box-shadow: 0 0 0 2px rgba(26, 26, 26, 0.1);
}
.weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0;
margin-bottom: 0.375rem;
}
.weekday {
width: 28px;
text-align: center;
font-size: 0.6875rem;
color: #666666;
font-weight: 500;
padding: 0.25rem 0;
margin: 0 auto;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0;
flex: 1;
}
.calendar-day {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: #333333;
cursor: pointer;
border-radius: 3px;
transition: all 0.2s;
position: relative;
padding: 0;
margin: 0 auto;
}
.calendar-day:hover {
background: #f5f5f5;
}
.calendar-day.other-month {
color: #999999;
}
.calendar-day.today {
font-weight: 500;
}
.calendar-day.selected {
background: #1a1a1a;
color: #ffffff;
}
.today-dot {
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
background: #1a1a1a;
border-radius: 50%;
}
.calendar-day.selected .today-dot {
background: #ffffff;
}
.now-btn {
margin-top: 0.5rem;
padding: 0.375rem 0.75rem;
background: #ffffff;
border: 1px solid #d0d0d0;
border-radius: 4px;
color: #666666;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
align-self: flex-start;
}
.now-btn:hover {
background: #f5f5f5;
border-color: #1a1a1a;
color: #1a1a1a;
}
/* 右侧时间选择区域 */
.time-picker-section {
flex: 0 0 160px;
min-width: 160px;
padding: 0.75rem;
display: flex;
flex-direction: column;
background: #fafafa;
}
.time-header {
font-size: 0.75rem;
font-weight: 500;
color: #333333;
margin-bottom: 0.5rem;
text-align: center;
}
.time-selectors {
display: flex;
gap: 0.25rem;
flex: 1;
overflow: hidden;
}
.time-column {
flex: 1;
display: flex;
flex-direction: column;
}
.time-list {
flex: 1;
overflow-y: auto;
scroll-behavior: smooth;
max-height: 240px;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.time-list::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.time-item {
padding: 0.25rem 0.375rem;
text-align: center;
font-size: 0.75rem;
color: #333333;
cursor: pointer;
transition: all 0.2s;
border-radius: 3px;
margin: 0.0625rem 0;
}
.time-item:hover {
background: #f0f0f0;
}
.time-item.selected {
background: #1a1a1a;
color: #ffffff;
font-weight: 500;
}
.confirm-btn {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: #1a1a1a;
border: none;
border-radius: 4px;
color: #ffffff;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
width: 100%;
}
.confirm-btn:hover {
background: #333333;
}
.confirm-btn:active {
transform: scale(0.98);
}
/* 过渡动画 */
.picker-enter-active,
.picker-leave-active {
transition: all 0.3s ease;
}
.picker-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.picker-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.mask-enter-active,
.mask-leave-active {
transition: opacity 0.3s ease;
}
.mask-enter-from,
.mask-leave-to {
opacity: 0;
}
@media (max-width: 768px) {
.datetime-picker-panel {
width: calc(100vw - 2rem);
min-width: calc(100vw - 2rem);
max-width: calc(100vw - 2rem);
left: 50%;
transform: translateX(-50%);
}
.picker-container {
flex-direction: column;
min-height: 280px;
}
.date-picker-section {
border-right: none;
border-bottom: 1px solid #e5e5e5;
}
.time-picker-section {
flex: 1;
min-width: 140px;
min-height: 250px;
}
.time-selectors {
max-height: 200px;
}
}
</style>

View File

@@ -0,0 +1,224 @@
<template>
<div class="json-tree-node">
<div
class="node-line"
:class="{ 'has-children': hasChildren, 'is-expanded': isExpanded }"
@click="toggle"
>
<span v-if="hasChildren" class="expand-icon">
{{ isExpanded ? '' : '' }}
</span>
<span v-else class="expand-placeholder"></span>
<span class="node-key" v-if="key !== null">
<span class="key-name">{{ key }}</span>:
</span>
<span class="node-value" :class="valueType">
<span v-if="valueType === 'string'">"{{ displayValue }}"</span>
<span v-else>{{ displayValue }}</span>
</span>
</div>
<div v-if="hasChildren && isExpanded" class="node-children">
<JsonTreeNode
v-for="(child, index) in children"
:key="index"
:data="child.value"
:key-name="child.key"
:path="child.path"
:expanded="expanded"
@toggle="$emit('toggle', $event)"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
data: {
type: [Object, Array, String, Number, Boolean, null],
required: true
},
keyName: {
type: [String, Number],
default: null
},
path: {
type: String,
default: ''
},
expanded: {
type: Set,
required: true
}
})
const emit = defineEmits(['toggle'])
const key = computed(() => props.keyName)
const currentPath = computed(() => {
if (props.path === '' && props.keyName === null) {
return 'root'
}
if (props.path === '') {
return String(props.keyName)
}
if (props.path === 'root') {
if (typeof props.keyName === 'number') {
return `root[${props.keyName}]`
}
return `root.${props.keyName}`
}
if (typeof props.keyName === 'number') {
return `${props.path}[${props.keyName}]`
}
return `${props.path}.${props.keyName}`
})
const hasChildren = computed(() => {
return (
(typeof props.data === 'object' && props.data !== null) ||
Array.isArray(props.data)
)
})
const isExpanded = computed(() => {
return props.expanded.has(currentPath.value)
})
const valueType = computed(() => {
if (props.data === null) return 'null'
if (Array.isArray(props.data)) return 'array'
if (typeof props.data === 'object') return 'object'
return typeof props.data
})
const displayValue = computed(() => {
if (props.data === null) return 'null'
if (Array.isArray(props.data)) {
return `Array(${props.data.length})`
}
if (typeof props.data === 'object') {
const keys = Object.keys(props.data)
return `Object(${keys.length})`
}
if (typeof props.data === 'string') {
return props.data
}
return String(props.data)
})
const children = computed(() => {
if (!hasChildren.value) return []
const basePath = props.path === '' && props.keyName === null ? 'root' : currentPath.value
if (Array.isArray(props.data)) {
return props.data.map((item, index) => ({
key: index,
value: item,
path: basePath
}))
}
if (typeof props.data === 'object' && props.data !== null) {
return Object.keys(props.data).map(key => ({
key: key,
value: props.data[key],
path: basePath
}))
}
return []
})
const toggle = () => {
if (hasChildren.value) {
emit('toggle', currentPath.value)
}
}
</script>
<style scoped>
.json-tree-node {
font-family: 'Courier New', monospace;
font-size: 14px;
line-height: 1.8;
}
.node-line {
display: flex;
align-items: center;
padding: 2px 0;
cursor: default;
user-select: text;
}
.node-line.has-children {
cursor: pointer;
}
.node-line.has-children:hover {
background: #f0f0f0;
}
.expand-icon {
display: inline-block;
width: 16px;
text-align: center;
color: #666;
font-size: 10px;
margin-right: 4px;
}
.expand-placeholder {
display: inline-block;
width: 16px;
margin-right: 4px;
}
.node-key {
margin-right: 6px;
}
.key-name {
color: #881391;
font-weight: 500;
}
.node-value {
color: #1a1aa6;
}
.node-value.string {
color: #0b7500;
}
.node-value.number {
color: #1a1aa6;
}
.node-value.boolean {
color: #1a1aa6;
}
.node-value.null {
color: #808080;
}
.node-value.object {
color: #1a1aa6;
font-style: italic;
}
.node-value.array {
color: #1a1aa6;
font-style: italic;
}
.node-children {
margin-left: 20px;
border-left: 1px solid #e0e0e0;
padding-left: 8px;
}
</style>

7
src/main.js Normal file
View File

@@ -0,0 +1,7 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './style.css'
createApp(App).use(router).mount('#app')

38
src/router/index.js Normal file
View File

@@ -0,0 +1,38 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/json-formatter',
name: 'JsonFormatter',
component: () => import('../views/JsonFormatter.vue')
},
{
path: '/encoder-decoder',
name: 'EncoderDecoder',
component: () => import('../views/EncoderDecoder.vue')
},
{
path: '/timestamp-converter',
name: 'TimestampConverter',
component: () => import('../views/TimestampConverter.vue')
},
{
path: '/color-converter',
name: 'ColorConverter',
component: () => import('../views/ColorConverter.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

17
src/style.css Normal file
View File

@@ -0,0 +1,17 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #ffffff;
min-height: 100vh;
color: #000000;
}
#app {
min-height: 100vh;
}

1329
src/views/ColorConverter.vue Normal file

File diff suppressed because it is too large Load Diff

1183
src/views/EncoderDecoder.vue Normal file

File diff suppressed because it is too large Load Diff

159
src/views/Home.vue Normal file
View File

@@ -0,0 +1,159 @@
<template>
<div class="home-container">
<div class="hero-section">
<h2 class="hero-title">今天是{{`${new Date().getFullYear()}${new Date().getMonth()+1}${new Date().getDate()}`}}</h2>
<p class="hero-subtitle">凭君莫话封侯事一将功成万骨枯</p>
</div>
<div class="tools-grid">
<router-link
v-for="tool in tools"
:key="tool.path"
:to="tool.path"
class="tool-card"
>
<h3 class="tool-title">{{ tool.title }}</h3>
<p class="tool-description">{{ tool.description }}</p>
</router-link>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const tools = ref([
{
path: '/json-formatter',
title: 'JSON',
description: '格式化、验证和美化JSON数据'
},
{
path: '/encoder-decoder',
title: '编解码',
description: '编码/解码工具'
},
{
path: '/timestamp-converter',
title: '时间戳',
description: '时间戳与时间字符串相互转换'
},
{
path: '/color-converter',
title: '颜色',
description: '颜色格式转换'
}
])
</script>
<style scoped>
.home-container {
width: 100%;
background: #ffffff;
}
.hero-section {
text-align: center;
padding: 3rem 0;
color: #1a1a1a;
}
.hero-title {
font-size: 3rem;
font-weight: 500;
margin-bottom: 1rem;
color: #1a1a1a;
}
.hero-subtitle {
font-size: 1.25rem;
color: #666666;
}
.tools-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1.5rem;
margin: 2rem auto 0;
max-width: 1200px;
padding: 0 1rem;
}
.tool-card {
background: #ffffff;
border-radius: 6px;
padding: 2rem;
text-decoration: none;
color: #1a1a1a;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
border: 1px solid #e5e5e5;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
flex: 0 0 calc(25% - 1.125rem);
max-width: calc(25% - 1.125rem);
min-width: 250px;
}
.tool-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-color: #d0d0d0;
background: #ffffff;
}
.tool-title {
font-size: 1.5rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: #1a1a1a;
}
.tool-description {
color: #666666;
line-height: 1.6;
font-size: 0.875rem;
}
@media (max-width: 1200px) {
.tool-card {
flex: 0 0 calc(33.333% - 1rem);
max-width: calc(33.333% - 1rem);
}
}
@media (max-width: 900px) {
.tool-card {
flex: 0 0 calc(50% - 0.75rem);
max-width: calc(50% - 0.75rem);
}
}
@media (max-width: 768px) {
.hero-title {
font-size: 2rem;
}
.hero-subtitle {
font-size: 1rem;
}
.tools-grid {
gap: 1rem;
max-width: 100%;
padding: 0;
}
.tool-card {
flex: 0 0 100%;
max-width: 100%;
}
.tool-card {
padding: 1.5rem;
}
}
</style>

1108
src/views/JsonFormatter.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,897 @@
<template>
<div class="timestamp-converter">
<div class="container">
<div class="conversion-card">
<!-- 精度选择器和时区选择器 -->
<div class="controls-row">
<div class="precision-selector">
<button
v-for="option in precisionOptions"
:key="option.value"
@click="timestampType = option.value; outputTimestampType = option.value"
:class="['precision-btn', { active: timestampType === option.value }]"
>
{{ option.label }}
</button>
</div>
<div class="timezone-selector">
<select v-model="timezone" class="timezone-select">
<option v-for="tz in timezoneOptions" :key="tz.value" :value="tz.value">
{{ tz.label }}
</option>
</select>
</div>
</div>
<!-- 日期转换为时间戳 -->
<div class="conversion-row">
<div class="conversion-label">日期 ({{ timezoneLabel }}) 时间戳:</div>
<div class="conversion-inputs">
<div class="input-with-calendar">
<input
v-model="dateStringInput"
@input="convertDateToTimestamp"
type="text"
:placeholder="getDatePlaceholder()"
class="input-field"
/>
<button @click="showDateTimePicker = true" class="calendar-btn" title="选择日期时间">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor">
<rect x="2" y="3" width="12" height="11" rx="1" stroke-width="1.5"/>
<path d="M5 1v4M11 1v4M2 7h12" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<!-- 日期时间选择器组件 -->
<DateTimePicker
v-model="dateStringInput"
v-model:show="showDateTimePicker"
:precision-type="outputTimestampType"
@confirm="handleDateTimeConfirm"
/>
</div>
<span class="arrow"></span>
<input
v-model="timestampOutput"
type="text"
readonly
class="input-field readonly"
/>
<button @click="copyToClipboard(timestampOutput)" class="copy-btn" title="复制">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor">
<rect x="5" y="5" width="8" height="8" rx="1" stroke-width="1.5"/>
<path d="M3 11V5a2 2 0 0 1 2-2h6" stroke-width="1.5"/>
</svg>
</button>
</div>
</div>
<!-- 时间戳转换为日期 -->
<div class="conversion-row">
<div class="conversion-label">时间戳 ({{ timezoneLabel }}) 日期</div>
<div class="conversion-inputs">
<input
v-model="timestampInput"
@input="convertTimestampToDate"
type="text"
placeholder="请输入时间戳"
class="input-field"
/>
<span class="arrow"></span>
<input
v-model="dateStringOutput"
type="text"
readonly
class="input-field readonly"
/>
<button @click="copyToClipboard(dateStringOutput)" class="copy-btn" title="复制">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor">
<rect x="5" y="5" width="8" height="8" rx="1" stroke-width="1.5"/>
<path d="M3 11V5a2 2 0 0 1 2-2h6" stroke-width="1.5"/>
</svg>
</button>
</div>
</div>
<!-- 当前时间戳显示与控制 -->
<div class="current-timestamp-row">
<div class="conversion-label">当前时间戳:</div>
<div class="current-timestamp-controls">
<span class="current-timestamp-value">{{ currentTimestampDisplay }}</span>
<button @click="togglePause" class="control-btn" :title="isPaused ? '继续' : '暂停'">
{{ isPaused ? '▶' : 'II' }} {{ isPaused ? '继续' : '暂停' }}
</button>
<button @click="resetData" class="control-btn" title="重置数据">C 重置数据</button>
</div>
</div>
</div>
</div>
<!-- 遮罩层 -->
<Transition name="mask">
<div v-if="showDateTimePicker" class="picker-mask" @click="closeDateTimePicker"></div>
</Transition>
<!-- 提示消息 -->
<Transition name="toast">
<div v-if="toastMessage" class="toast-notification" :class="toastType">
<div class="toast-content">
<svg v-if="toastType === 'error'" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor">
<circle cx="8" cy="8" r="6" stroke-width="1.5"/>
<path d="M8 5v3M8 11h.01" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor">
<path d="M13 4L6 11L3 8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>{{ toastMessage }}</span>
</div>
<button @click="closeToast" class="toast-close-btn" title="关闭">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor">
<path d="M3 3l8 8M11 3l-8 8" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
</Transition>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import DateTimePicker from '@/components/DateTimePicker.vue'
// 精度选项
const precisionOptions = [
{ value: 'seconds', label: '秒' },
{ value: 'milliseconds', label: '毫秒' },
{ value: 'nanoseconds', label: '纳秒' }
]
// 所有时区选项
const timezoneOptions = [
{ value: 'UTC-12:00', label: 'UTC-12:00 | 贝克岛' },
{ value: 'UTC-11:00', label: 'UTC-11:00 | 萨摩亚' },
{ value: 'UTC-10:00', label: 'UTC-10:00 | 夏威夷' },
{ value: 'UTC-09:30', label: 'UTC-09:30 | 马克萨斯群岛' },
{ value: 'UTC-09:00', label: 'UTC-09:00 | 阿拉斯加' },
{ value: 'UTC-08:00', label: 'UTC-08:00 | 洛杉矶' },
{ value: 'UTC-07:00', label: 'UTC-07:00 | 丹佛' },
{ value: 'UTC-06:00', label: 'UTC-06:00 | 芝加哥' },
{ value: 'UTC-05:00', label: 'UTC-05:00 | 纽约' },
{ value: 'UTC-04:00', label: 'UTC-04:00 | 加拉加斯' },
{ value: 'UTC-03:30', label: 'UTC-03:30 | 纽芬兰' },
{ value: 'UTC-03:00', label: 'UTC-03:00 | 布宜诺斯艾利斯' },
{ value: 'UTC-02:00', label: 'UTC-02:00 | 大西洋中部' },
{ value: 'UTC-01:00', label: 'UTC-01:00 | 亚速尔群岛' },
{ value: 'UTC+00:00', label: 'UTC+00:00 | 伦敦' },
{ value: 'UTC+01:00', label: 'UTC+01:00 | 巴黎' },
{ value: 'UTC+02:00', label: 'UTC+02:00 | 开罗' },
{ value: 'UTC+03:00', label: 'UTC+03:00 | 莫斯科' },
{ value: 'UTC+03:30', label: 'UTC+03:30 | 德黑兰' },
{ value: 'UTC+04:00', label: 'UTC+04:00 | 迪拜' },
{ value: 'UTC+04:30', label: 'UTC+04:30 | 喀布尔' },
{ value: 'UTC+05:00', label: 'UTC+05:00 | 伊斯兰堡' },
{ value: 'UTC+05:30', label: 'UTC+05:30 | 新德里' },
{ value: 'UTC+05:45', label: 'UTC+05:45 | 加德满都' },
{ value: 'UTC+06:00', label: 'UTC+06:00 | 达卡' },
{ value: 'UTC+06:30', label: 'UTC+06:30 | 仰光' },
{ value: 'UTC+07:00', label: 'UTC+07:00 | 曼谷' },
{ value: 'UTC+08:00', label: 'UTC+08:00 | 北京' },
{ value: 'UTC+08:45', label: 'UTC+08:45 | 尤克拉' },
{ value: 'UTC+09:00', label: 'UTC+09:00 | 东京' },
{ value: 'UTC+09:30', label: 'UTC+09:30 | 阿德莱德' },
{ value: 'UTC+10:00', label: 'UTC+10:00 | 悉尼' },
{ value: 'UTC+10:30', label: 'UTC+10:30 | 豪勋爵岛' },
{ value: 'UTC+11:00', label: 'UTC+11:00 | 新喀里多尼亚' },
{ value: 'UTC+12:00', label: 'UTC+12:00 | 奥克兰' },
{ value: 'UTC+12:45', label: 'UTC+12:45 | 查塔姆群岛' },
{ value: 'UTC+13:00', label: 'UTC+13:00 | 萨摩亚' },
{ value: 'UTC+14:00', label: 'UTC+14:00 | 基里巴斯' }
]
// 当前时间相关
const currentTime = ref(new Date())
let timeInterval = null
const isPaused = ref(false)
// 时区相关
const timezone = ref('UTC+08:00')
const timezoneLabel = computed(() => {
const tz = timezoneOptions.find(opt => opt.value === timezone.value)
return tz ? tz.label.split('|')[1].trim() : '北京'
})
// 时间戳转时间
const timestampType = ref('milliseconds')
const timestampInput = ref('')
const dateStringOutput = ref('')
// 时间转时间戳
const dateStringInput = ref('')
const outputTimestampType = ref('milliseconds')
const timestampOutput = ref('')
// 日期时间选择器状态
const showDateTimePicker = ref(false)
// 提示消息
const toastMessage = ref('')
const toastType = ref('success')
// 计算当前秒级时间戳
const currentTimestampSeconds = computed(() => {
return Math.floor(currentTime.value.getTime() / 1000)
})
// 计算当前毫秒级时间戳
const currentTimestampMilliseconds = computed(() => {
return currentTime.value.getTime()
})
// 计算当前纳秒级时间戳
const currentTimestampNanoseconds = computed(() => {
// JavaScript Date 只能精确到毫秒所以纳秒部分设为0
// 纳秒 = 毫秒 * 1000000
const ms = currentTime.value.getTime()
return BigInt(ms) * BigInt(1000000)
})
// 当前时间戳显示
const currentTimestampDisplay = computed(() => {
if (timestampType.value === 'seconds') {
return currentTimestampSeconds.value.toString()
} else if (timestampType.value === 'milliseconds') {
return currentTimestampMilliseconds.value.toString()
} else {
return currentTimestampNanoseconds.value.toString()
}
})
// 获取日期输入框的placeholder
const getDatePlaceholder = () => {
if (timestampType.value === 'seconds') {
return '格式yyyy-MM-dd HH:mm:ss'
} else if (timestampType.value === 'milliseconds') {
return '格式yyyy-MM-dd HH:mm:ss.SSS'
} else {
return '格式yyyy-MM-dd HH:mm:ss.SSSSSSSSS'
}
}
// 更新时间
const updateCurrentTime = () => {
if (!isPaused.value) {
currentTime.value = new Date()
}
}
// 切换暂停/继续
const togglePause = () => {
isPaused.value = !isPaused.value
if (!isPaused.value) {
updateCurrentTime()
}
}
// 重置数据
const resetData = () => {
timestampInput.value = ''
dateStringOutput.value = ''
dateStringInput.value = ''
timestampOutput.value = ''
currentTime.value = new Date()
showToast('数据已重置', 'success')
}
// 时间戳转时间字符串
const convertTimestampToDate = () => {
if (!timestampInput.value.trim()) {
dateStringOutput.value = ''
return
}
try {
let timestampStr = timestampInput.value.trim()
if (!timestampStr) {
dateStringOutput.value = ''
return
}
let timestampMs = 0
let nanoseconds = 0
if (timestampType.value === 'nanoseconds') {
// 纳秒级时间戳,使用 BigInt 处理
try {
const timestampNs = BigInt(timestampStr)
// 转换为毫秒(纳秒 / 1000000
timestampMs = Number(timestampNs / BigInt(1000000))
// 获取纳秒部分(纳秒 % 1000000
nanoseconds = Number(timestampNs % BigInt(1000000))
} catch (error) {
dateStringOutput.value = ''
showToast('请输入有效的纳秒级时间戳', 'error')
return
}
} else {
const timestamp = parseInt(timestampStr)
if (isNaN(timestamp)) {
dateStringOutput.value = ''
showToast('请输入有效的数字', 'error')
return
}
// 如果是秒级时间戳,转换为毫秒
if (timestampType.value === 'seconds') {
// 判断是否是秒级时间戳通常小于13位数字
if (timestamp.toString().length <= 10) {
timestampMs = timestamp * 1000
} else {
timestampMs = timestamp
}
} else {
timestampMs = timestamp
}
}
const date = new Date(timestampMs)
if (isNaN(date.getTime())) {
dateStringOutput.value = ''
showToast('无效的时间戳', 'error')
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')
const milliseconds = String(date.getMilliseconds()).padStart(3, '0')
if (timestampType.value === 'seconds') {
dateStringOutput.value = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
} else if (timestampType.value === 'milliseconds') {
dateStringOutput.value = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`
} else {
// 纳秒级:显示毫秒 + 纳秒部分
const nanosecondsStr = String(nanoseconds).padStart(6, '0')
dateStringOutput.value = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}${nanosecondsStr}`
}
} catch (error) {
dateStringOutput.value = ''
showToast('转换失败:' + error.message, 'error')
}
}
// 时间字符串转时间戳
const convertDateToTimestamp = () => {
if (!dateStringInput.value.trim()) {
timestampOutput.value = ''
return
}
try {
let dateStr = dateStringInput.value.trim()
// 尝试解析不同的时间格式
let date = null
// 处理纳秒级格式yyyy-MM-dd HH:mm:ss.SSSSSSSSS9位纳秒
if (dateStr.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{9}$/)) {
// 提取毫秒部分前3位和纳秒部分后6位
const parts = dateStr.split('.')
const baseTime = parts[0].replace(' ', 'T')
const nanoseconds = parts[1]
const milliseconds = nanoseconds.substring(0, 3)
date = new Date(baseTime + '.' + milliseconds)
}
// 格式1: yyyy-MM-dd HH:mm:ss 或 yyyy-MM-dd HH:mm:ss.SSS
else if (dateStr.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d{1,3})?$/)) {
date = new Date(dateStr.replace(' ', 'T'))
}
// 格式2: yyyy-MM-ddTHH:mm:ss 或 yyyy-MM-ddTHH:mm:ss.SSS
else if (dateStr.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?/)) {
date = new Date(dateStr)
}
// 格式3: yyyy/MM/dd HH:mm:ss
else if (dateStr.match(/^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}/)) {
date = new Date(dateStr.replace(' ', 'T').replace(/\//g, '-'))
}
// 其他格式,直接尝试解析
else {
date = new Date(dateStr)
}
if (isNaN(date.getTime())) {
timestampOutput.value = ''
showToast('无效的时间格式', 'error')
return
}
if (outputTimestampType.value === 'seconds') {
timestampOutput.value = Math.floor(date.getTime() / 1000).toString()
} else if (outputTimestampType.value === 'milliseconds') {
timestampOutput.value = date.getTime().toString()
} else {
// 纳秒级:毫秒 * 1000000
const ms = date.getTime()
timestampOutput.value = (BigInt(ms) * BigInt(1000000)).toString()
}
} catch (error) {
timestampOutput.value = ''
showToast('转换失败:' + error.message, 'error')
}
}
// 处理日期时间选择器确认
const handleDateTimeConfirm = (value) => {
dateStringInput.value = value
convertDateToTimestamp()
}
// 复制到剪贴板
const copyToClipboard = async (text) => {
if (!text) {
showToast('没有可复制的内容', 'error')
return
}
try {
await navigator.clipboard.writeText(text)
showToast('已复制到剪贴板', 'success')
} catch (error) {
// 降级方案
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.opacity = '0'
document.body.appendChild(textArea)
textArea.select()
try {
document.execCommand('copy')
showToast('已复制到剪贴板', 'success')
} catch (err) {
showToast('复制失败', 'error')
}
document.body.removeChild(textArea)
}
}
// 显示提示消息
const showToast = (message, type = 'success') => {
toastMessage.value = message
toastType.value = type
setTimeout(() => {
closeToast()
}, 3000)
}
// 关闭提示消息
const closeToast = () => {
toastMessage.value = ''
}
// 监听时间戳类型变化
const watchTimestampType = () => {
if (timestampInput.value) {
convertTimestampToDate()
}
}
// 监听输出时间戳类型变化
const watchOutputTimestampType = () => {
if (dateStringInput.value) {
convertDateToTimestamp()
}
}
// 监听时间戳类型变化
watch(timestampType, watchTimestampType)
watch(outputTimestampType, watchOutputTimestampType)
onMounted(() => {
updateCurrentTime()
timeInterval = setInterval(updateCurrentTime, 1000)
})
onUnmounted(() => {
if (timeInterval) {
clearInterval(timeInterval)
}
})
</script>
<style scoped>
.timestamp-converter {
width: 100%;
min-height: 100vh;
background: #f5f5f5;
padding: 2rem 1rem;
}
.container {
max-width: 900px;
margin: 0 auto;
}
.conversion-card {
background: #ffffff;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid #e5e5e5;
}
.controls-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
gap: 1rem;
}
.precision-selector {
display: flex;
gap: 0.5rem;
}
.precision-btn {
padding: 0.5rem 1rem;
background: #f5f5f5;
color: #333333;
border: 1px solid #d0d0d0;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.precision-btn:hover {
background: #e5e5e5;
}
.precision-btn.active {
background: #1a1a1a;
color: #ffffff;
border-color: #1a1a1a;
}
.timezone-selector {
display: flex;
align-items: center;
gap: 0.5rem;
}
.timezone-select {
padding: 0.5rem 0.75rem;
border: 1px solid #d0d0d0;
border-radius: 6px;
font-size: 0.875rem;
background: #ffffff;
cursor: pointer;
transition: all 0.2s;
}
.timezone-select:focus {
outline: none;
border-color: #1a1a1a;
}
.conversion-row {
margin-bottom: 1.5rem;
}
.conversion-label {
font-size: 0.875rem;
font-weight: 500;
color: #333333;
margin-bottom: 0.5rem;
}
.conversion-inputs {
display: flex;
gap: 0.75rem;
align-items: center;
}
.arrow {
font-size: 1.25rem;
color: #666666;
font-weight: 500;
flex-shrink: 0;
}
.current-timestamp-row {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 1rem;
border-top: 1px solid #e5e5e5;
}
.current-timestamp-controls {
display: flex;
align-items: center;
gap: 0.75rem;
}
.current-timestamp-value {
font-family: 'Courier New', monospace;
font-size: 1rem;
color: #1a1a1a;
font-weight: 500;
min-width: 150px;
text-align: right;
}
.control-btn {
padding: 0.5rem 1rem;
background: #f5f5f5;
color: #333333;
border: 1px solid #d0d0d0;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.control-btn:hover {
background: #e5e5e5;
border-color: #1a1a1a;
}
.control-btn:active {
transform: scale(0.98);
}
.input-field {
flex: 1;
padding: 0.75rem;
border: 1px solid #d0d0d0;
border-radius: 6px;
font-size: 0.9375rem;
font-family: 'Courier New', monospace;
transition: all 0.2s;
}
.input-field:focus {
outline: none;
border-color: #1a1a1a;
box-shadow: 0 0 0 3px rgba(26, 26, 26, 0.1);
}
.input-field.readonly {
background: #f9f9f9;
cursor: not-allowed;
}
.action-btn {
padding: 0.75rem 1rem;
background: #1a1a1a;
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.action-btn:hover {
background: #333333;
}
.action-btn:active {
transform: scale(0.98);
}
.copy-btn {
padding: 0.5rem;
background: transparent;
border: 1px solid #d0d0d0;
border-radius: 6px;
color: #666666;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
}
.copy-btn:hover {
background: #f5f5f5;
border-color: #1a1a1a;
color: #1a1a1a;
}
.input-with-calendar {
flex: 1;
position: relative;
display: flex;
align-items: center;
min-width: 0;
}
.input-with-calendar .input-field {
flex: 1;
padding-right: 2.5rem;
min-width: 0;
}
.calendar-btn {
position: absolute;
right: 0.5rem;
padding: 0.5rem;
background: transparent;
border: none;
border-radius: 6px;
color: #666666;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
z-index: 1;
}
.calendar-btn:hover {
background: #f5f5f5;
color: #1a1a1a;
}
.input-with-calendar {
position: relative;
}
/* 提示消息样式 */
.toast-notification {
position: fixed;
top: 80px;
right: 20px;
background: #ffffff;
border: 1px solid #e5e5e5;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 1rem 1.25rem;
display: flex;
align-items: center;
gap: 0.75rem;
z-index: 1000;
min-width: 280px;
max-width: 400px;
}
.toast-notification.success {
border-left: 4px solid #10b981;
}
.toast-notification.error {
border-left: 4px solid #ef4444;
}
.toast-content {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
font-size: 0.875rem;
color: #333333;
}
.toast-content svg {
flex-shrink: 0;
}
.toast-notification.success .toast-content svg {
color: #10b981;
}
.toast-notification.error .toast-content svg {
color: #ef4444;
}
.toast-close-btn {
background: transparent;
border: none;
color: #666666;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
flex-shrink: 0;
}
.toast-close-btn:hover {
color: #1a1a1a;
}
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100%);
}
.toast-leave-to {
opacity: 0;
transform: translateX(100%);
}
@media (max-width: 768px) {
.timestamp-converter {
padding: 1rem 0.5rem;
}
.conversion-card {
padding: 1rem;
}
.controls-row {
flex-direction: column;
align-items: stretch;
}
.precision-selector {
width: 100%;
justify-content: space-between;
}
.precision-btn {
flex: 1;
}
.timezone-selector {
width: 100%;
}
.conversion-inputs {
flex-direction: column;
align-items: stretch;
}
.arrow {
transform: rotate(90deg);
}
.current-timestamp-row {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.current-timestamp-controls {
flex-direction: column;
align-items: stretch;
}
.current-timestamp-value {
text-align: left;
}
.control-btn {
width: 100%;
}
.toast-notification {
right: 10px;
left: 10px;
max-width: none;
}
}
</style>

17
vite.config.js Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 3000,
open: true
}
})