Compare commits

...

4 Commits

Author SHA1 Message Date
renjue
e397d03850 i18n 2026-02-05 11:49:42 +08:00
renjue
ada0dfa3cc 修改REMOTE_DIR 2026-02-03 12:30:15 +08:00
renjue
2a07fd950f 编解码增加zlib 2026-02-02 16:19:44 +08:00
8e5eea02f1 init 2026-02-02 00:09:20 +08:00
25 changed files with 12877 additions and 88 deletions

111
.gitignore vendored
View File

@@ -1,90 +1,27 @@
# ---> Vue
# gitignore template for Vue.js projects
#
# Recommended template: Node.gitignore
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# TODO: where does this rule come from?
docs/_book
node_modules
dist
dist-ssr
*.local
# TODO: where does this rule come from?
test/
# ---> JetBrains
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.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
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.cursor
/package-lock.json

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` 数组中添加工具信息

66
deploy.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -e
# 部署脚本:打包并通过 SSH 将构建产物同步到远程目录
# 用法: ./deploy.sh <服务器IP> <SSH端口> <登录账户> <登录密码> [sudo]
# 示例: ./deploy.sh 192.168.1.100 22 root mypassword
# 若远程目录无写权限加第5个参数 sudo先传到 /tmp再通过 sudo 拷到目标(会用到同一密码作为 sudo 密码)
# 示例: ./deploy.sh 192.168.1.100 22 myuser mypassword sudo
if [ $# -lt 4 ]; then
echo "用法: $0 <服务器IP> <SSH端口> <登录账户> <登录密码> [sudo]"
echo "示例: $0 192.168.1.100 22 root mypassword"
echo "远程目录无写权限时加第5参数: $0 <IP> <端口> <用户> <密码> sudo"
exit 1
fi
HOST="$1"
PORT="$2"
USER="$3"
PASSWORD="$4"
USE_SUDO="${5:-}"
REMOTE_DIR="/root/caddy/site/tool"
REMOTE_TMP="/tmp/tool_deploy_$$"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DIST_DIR="${SCRIPT_DIR}/dist"
SSH_OPTS="-p ${PORT} -o StrictHostKeyChecking=accept-new -T"
echo ">>> 正在打包..."
cd "$SCRIPT_DIR"
npm run build
if [ ! -d "$DIST_DIR" ]; then
echo "错误: 构建产物目录 dist 不存在"
exit 1
fi
# 检查 sshpass 是否可用(用于非交互式传入密码)
if ! command -v sshpass &> /dev/null; then
echo "未找到 sshpass请先安装"
echo " macOS: brew install sshpass"
echo " Ubuntu: sudo apt-get install sshpass"
exit 1
fi
export SSHPASS="$PASSWORD"
if [ "$USE_SUDO" = "sudo" ]; then
echo ">>> 正在同步到远程临时目录 ${REMOTE_TMP}"
sshpass -e rsync -avz --delete \
-e "ssh ${SSH_OPTS}" \
"${DIST_DIR}/" \
"${USER}@${HOST}:${REMOTE_TMP}/"
echo ">>> 正在用 sudo 拷贝到目标目录 ${REMOTE_DIR}"
# 只执行一次 sudo读一次密码在子 shell 里完成 rsync 与清理,避免第二次 sudo 无密码
printf '%s\n' "$PASSWORD" | sshpass -e ssh ${SSH_OPTS} "${USER}@${HOST}" \
"sudo -S sh -c 'rsync -avz --delete ${REMOTE_TMP}/ ${REMOTE_DIR}/ && rm -rf ${REMOTE_TMP}'"
else
echo ">>> 正在通过 SSH 同步到 ${USER}@${HOST}:${PORT} -> ${REMOTE_DIR}"
sshpass -e rsync -avz --delete \
-e "ssh ${SSH_OPTS}" \
"${DIST_DIR}/" \
"${USER}@${HOST}:${REMOTE_DIR}/"
fi
unset SSHPASS
echo ">>> 部署完成"

22
index.html Normal file
View File

@@ -0,0 +1,22 @@
<!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>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-C2H4BGZJBD"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-C2H4BGZJBD');
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "toolbox",
"version": "1.0.0",
"description": "A Vue-based toolbox application",
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^7.1.0",
"fflate": "^0.8.2",
"qrcode": "^1.5.4",
"vue": "^3.4.0",
"vue-i18n": "^9.14.4",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0"
}
}

326
src/App.vue Normal file
View File

@@ -0,0 +1,326 @@
<template>
<div class="app-container">
<nav class="navbar">
<div class="nav-content">
<router-link :to="localePath(currentPathLocale, '')" class="logo">
<h1>{{ t('app.title') }}</h1>
</router-link>
<div class="nav-right">
<div class="nav-links">
<router-link :to="localePath(currentPathLocale, '')" class="nav-link">{{ t('nav.home') }}</router-link>
<router-link :to="localePath(currentPathLocale, 'json-formatter')" class="nav-link">{{ t('nav.json') }}</router-link>
<router-link :to="localePath(currentPathLocale, 'comparator')" class="nav-link">{{ t('nav.comparator') }}</router-link>
<router-link :to="localePath(currentPathLocale, 'encoder-decoder')" class="nav-link">{{ t('nav.encoderDecoder') }}</router-link>
<router-link :to="localePath(currentPathLocale, 'variable-name')" class="nav-link">{{ t('nav.variableName') }}</router-link>
<router-link :to="localePath(currentPathLocale, 'qr-code')" class="nav-link">{{ t('nav.qrCode') }}</router-link>
<router-link :to="localePath(currentPathLocale, 'timestamp-converter')" class="nav-link">{{ t('nav.timestamp') }}</router-link>
<router-link :to="localePath(currentPathLocale, 'color-converter')" class="nav-link">{{ t('nav.color') }}</router-link>
</div>
</div>
</div>
<div ref="localeDropRef" class="nav-locale">
<button
type="button"
class="locale-trigger"
:aria-expanded="localeOpen"
aria-haspopup="listbox"
aria-label="选择语言"
@click="localeOpen = !localeOpen"
>
{{ currentLocaleLabel }}
</button>
<Transition name="locale-drop">
<div
v-show="localeOpen"
class="locale-dropdown"
role="listbox"
aria-label="语言选项"
>
<button
v-for="opt in localeOptions"
:key="opt.pathLocale"
type="button"
role="option"
:aria-selected="currentPathLocale === opt.pathLocale"
class="locale-option"
:class="{ active: currentPathLocale === opt.pathLocale }"
@click="selectLocale(opt.pathLocale)"
>
{{ opt.label }}
</button>
</div>
</Transition>
</div>
</nav>
<main class="main-content">
<router-view />
</main>
</div>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { localePath } from './router'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const localeOpen = ref(false)
const localeDropRef = ref(null)
const currentPathLocale = computed(() => route.params.locale || 'zh')
const localeOptions = [
{ pathLocale: 'zh', label: '简体中文' },
{ pathLocale: 'zh-tw', label: '繁體中文' },
{ pathLocale: 'en', label: 'English' },
]
const currentLocaleLabel = computed(() => {
const opt = localeOptions.find(o => o.pathLocale === currentPathLocale.value)
return opt ? opt.label : '简体中文'
})
function switchLocale(newPathLocale) {
if (newPathLocale === currentPathLocale.value) return
const pathWithoutLocale = route.path.replace(/^\/[^/]+/, '') || ''
const segment = pathWithoutLocale.startsWith('/') ? pathWithoutLocale.slice(1) : pathWithoutLocale
router.push(localePath(newPathLocale, segment))
}
function selectLocale(pathLocale) {
switchLocale(pathLocale)
localeOpen.value = false
}
function onClickOutside(e) {
if (localeDropRef.value && !localeDropRef.value.contains(e.target)) {
localeOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', onClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', onClickOutside)
})
</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;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
display: flex;
justify-content: space-between;
align-items: center;
min-height: 40px;
flex: 1;
min-width: 0;
}
.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-right {
display: flex;
align-items: center;
gap: 0;
}
.nav-links {
display: flex;
gap: 0;
align-items: center;
}
.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;
display: flex;
align-items: center;
justify-content: center;
line-height: 1.5;
}
.nav-link:hover {
color: #1a1a1a;
background: #f5f5f5;
}
.nav-link.router-link-exact-active {
color: #ffffff;
background: #1a1a1a;
border-bottom-color: #1a1a1a;
}
.nav-locale {
position: relative;
flex-shrink: 0;
margin-right: 0;
padding-right: 1rem;
}
.locale-trigger {
min-width: 5.5rem;
padding: 0.35rem 0.6rem;
font-size: 0.8125rem;
font-weight: 500;
color: #333;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s;
}
.locale-trigger:hover {
background: #f5f5f5;
}
.locale-dropdown {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 8.5rem;
padding: 4px;
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
z-index: 200;
}
.locale-option {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
color: #333;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
text-align: left;
transition: background-color 0.15s;
}
.locale-option:hover {
background: #f5f5f5;
}
.locale-option.active {
background: #1a1a1a;
color: #fff;
}
.locale-drop-enter-active,
.locale-drop-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.locale-drop-enter-from,
.locale-drop-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.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;
}
.navbar {
flex-wrap: wrap;
}
.nav-content {
flex: 1 1 100%;
}
.nav-right {
flex-wrap: wrap;
justify-content: flex-start;
width: 100%;
}
.nav-links {
flex-wrap: wrap;
justify-content: flex-start;
}
.nav-link {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
}
.nav-locale {
padding-right: 0.75rem;
}
.main-content {
padding: 1rem;
}
}
</style>

6
src/AppRoot.vue Normal file
View File

@@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script setup>
</script>

View File

@@ -0,0 +1,827 @@
<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="t('dateTimePicker.prevYear')">«</button>
<button @click="prevMonth" class="nav-btn" :title="t('dateTimePicker.prevMonth')"></button>
</div>
<div
v-if="!isEditingMonthYear"
@click="startEditingMonthYear"
class="current-month-year editable"
:title="t('dateTimePicker.clickInputMonthYear')"
>
{{ 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="t('dateTimePicker.placeholderMonthYear')"
ref="monthYearInputRef"
/>
<div class="nav-buttons">
<button @click="nextMonth" class="nav-btn" :title="t('dateTimePicker.nextMonth')"></button>
<button @click="nextYear" class="nav-btn" :title="t('dateTimePicker.nextYear')">»</button>
</div>
</div>
<!-- 星期标题 -->
<div class="weekdays">
<div class="weekday" v-for="(day, idx) in weekdays" :key="idx">
{{ 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">{{ t('dateTimePicker.now') }}</button>
</div>
<!-- 右侧时间选择区域 -->
<div class="time-picker-section">
<div class="time-header">{{ t('dateTimePicker.selectTime') }}</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">{{ t('dateTimePicker.confirm') }}</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'
import { useI18n } from 'vue-i18n'
const { t, tm } = useI18n()
const weekdays = computed(() => {
const msg = tm('dateTimePicker')
const w = msg?.weekdays
return Array.isArray(w) ? w : ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
})
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,396 @@
<template>
<div class="json-tree-node" :class="{ 'is-matched': isMatched, 'is-filtered': isFiltered }">
<div
class="node-line"
:class="{ 'has-children': hasChildren, 'is-expanded': isExpanded, 'is-matched': isMatched }"
@click="toggle"
>
<span v-if="hasChildren" class="expand-icon">
<i :class="isExpanded ? 'fas fa-chevron-down' : 'fas fa-chevron-right'"></i>
</span>
<span v-else class="expand-placeholder"></span>
<span v-if="showPath && nodePath" class="node-path">{{ nodePath }}:</span>
<span class="node-key" v-if="key !== null">
<span class="key-name" :class="{ 'matched': isMatched }">{{ key }}</span>:
</span>
<span class="node-value" :class="[valueType, { 'matched': isMatched }]">
<span v-if="valueType === 'string'">"{{ displayValue }}"</span>
<span v-else>{{ displayValue }}</span>
</span>
</div>
<div v-if="hasChildren && isExpanded && shouldShowChildren" class="node-children">
<JsonTreeNode
v-for="(child, index) in filteredChildren"
:key="index"
:data="child.value"
:key-name="child.key"
:path="child.path"
:expanded="expanded"
:matched-paths="matchedPaths"
:json-path-query="jsonPathQuery"
@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
},
matchedPaths: {
type: Set,
default: () => new Set()
},
jsonPathQuery: {
type: String,
default: ''
},
showPath: {
type: Boolean,
default: false
},
nodePath: {
type: String,
default: ''
}
})
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)
})
// 判断当前节点是否匹配 JSONPath
const isMatched = computed(() => {
if (!props.jsonPathQuery || !props.jsonPathQuery.trim()) return false
return props.matchedPaths.has(currentPath.value)
})
// 判断是否有筛选
const hasFilter = computed(() => {
return props.jsonPathQuery && props.jsonPathQuery.trim() !== ''
})
// 判断是否应该显示子节点
const shouldShowChildren = computed(() => {
if (!hasFilter.value) return true
// 如果节点匹配,显示子节点(展开匹配节点的内容)
if (isMatched.value) return true
// 如果当前节点是匹配节点的子节点,显示所有子节点
if (isChildOfMatched.value) return true
// 检查是否有子节点匹配
return hasMatchingChildren.value
})
// 检查是否有子节点匹配
const hasMatchingChildren = computed(() => {
if (!hasFilter.value) return true
if (!hasChildren.value) return false
const basePath = props.path === '' && props.keyName === null ? 'root' : currentPath.value
if (Array.isArray(props.data)) {
return props.data.some((item, index) => {
const childPath = `${basePath}[${index}]`
return props.matchedPaths.has(childPath) || checkChildrenMatch(item, childPath)
})
}
if (typeof props.data === 'object' && props.data !== null) {
return Object.keys(props.data).some(key => {
const childPath = basePath === 'root' ? `root.${key}` : `${basePath}.${key}`
return props.matchedPaths.has(childPath) || checkChildrenMatch(props.data[key], childPath)
})
}
return false
})
// 递归检查子节点是否匹配
const checkChildrenMatch = (obj, path) => {
if (props.matchedPaths.has(path)) return true
if (Array.isArray(obj)) {
return obj.some((item, index) => {
const childPath = `${path}[${index}]`
return props.matchedPaths.has(childPath) || checkChildrenMatch(item, childPath)
})
}
if (typeof obj === 'object' && obj !== null) {
return Object.keys(obj).some(key => {
const childPath = `${path}.${key}`
return props.matchedPaths.has(childPath) || checkChildrenMatch(obj[key], childPath)
})
}
return false
}
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 filteredChildren = computed(() => {
if (!hasFilter.value) return children.value
// 如果当前节点匹配,显示所有子节点(不进行过滤)
if (isMatched.value) return children.value
// 如果当前节点是匹配节点的子节点,显示所有子节点(不进行过滤)
if (isChildOfMatched.value) return children.value
// 否则,只显示匹配的子节点或其子节点匹配的子节点
return children.value.filter(child => {
const childPath = child.path === 'root'
? (typeof child.key === 'number' ? `root[${child.key}]` : `root.${child.key}`)
: (typeof child.key === 'number' ? `${child.path}[${child.key}]` : `${child.path}.${child.key}`)
// 如果子节点匹配,显示
if (props.matchedPaths.has(childPath)) return true
// 如果子节点的子节点匹配,也显示
return checkChildrenMatch(child.value, childPath)
})
})
// 检查当前节点是否是匹配节点的子节点
const isChildOfMatched = computed(() => {
if (!hasFilter.value || isMatched.value) return false
// 检查当前路径是否以任何匹配路径开头(作为前缀)
for (const matchedPath of props.matchedPaths) {
if (matchedPath === 'root') continue
// 如果当前路径以匹配路径开头,且后面跟着 . 或 [,说明是子节点
const prefix = matchedPath + '.'
const prefixBracket = matchedPath + '['
if (currentPath.value.startsWith(prefix) || currentPath.value.startsWith(prefixBracket)) {
return true
}
}
return false
})
// 判断是否被筛选(有筛选但当前节点不匹配且没有匹配的子节点,且不是匹配节点的子节点)
const isFiltered = computed(() => {
if (!hasFilter.value) return false
if (isMatched.value) return false
if (isChildOfMatched.value) return false
return !hasMatchingChildren.value
})
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-icon i {
font-size: 10px;
}
.expand-placeholder {
display: inline-block;
width: 16px;
margin-right: 4px;
}
.node-path {
color: #666666;
font-size: 0.8125rem;
margin-right: 8px;
font-family: 'Courier New', monospace;
opacity: 0.7;
}
.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;
}
/* 匹配的节点高亮样式 */
.json-tree-node.is-matched .node-line {
background: #fff9e6;
border-left: 3px solid #ff9800;
padding-left: 5px;
margin-left: -8px;
}
.json-tree-node.is-matched .key-name.matched {
color: #ff6f00;
font-weight: 600;
}
.json-tree-node.is-matched .node-value.matched {
color: #ff6f00;
font-weight: 500;
}
/* 被筛选掉的节点隐藏 */
.json-tree-node.is-filtered {
display: none;
}
</style>

17
src/i18n/index.js Normal file
View File

@@ -0,0 +1,17 @@
import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN'
import zhTW from './locales/zh-TW'
import en from './locales/en'
export const supportedLocales = ['zh-CN', 'zh-TW', 'en']
export const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
fallbackLocale: 'zh-CN',
messages: {
'zh-CN': zhCN,
'zh-TW': zhTW,
en,
},
})

293
src/i18n/locales/en.js Normal file
View File

@@ -0,0 +1,293 @@
export default {
nav: {
home: 'Home',
json: 'JSON',
comparator: 'Compare',
encoderDecoder: 'Encode/Decode',
variableName: 'Variable',
qrCode: 'QR Code',
timestamp: 'Timestamp',
color: 'Color',
},
app: {
title: "RC707's Toolbox",
titleJson: "RC707's Toolbox - JSON",
titleComparator: "RC707's Toolbox - Compare",
titleEncoderDecoder: "RC707's Toolbox - Encode/Decode",
titleVariableName: "RC707's Toolbox - Variable",
titleQrCode: "RC707's Toolbox - QR Code",
titleTimestamp: "RC707's Toolbox - Timestamp",
titleColor: "RC707's Toolbox - Color",
},
home: {
heroToday: 'Today is {date}',
toolJson: 'JSON',
toolJsonDesc: 'Format, validate and beautify JSON',
toolComparator: 'Compare',
toolComparatorDesc: 'Text and JSON comparison',
toolEncoderDecoder: 'Encode/Decode',
toolEncoderDecoderDesc: 'Encoding/decoding tools',
toolVariableName: 'Variable',
toolVariableNameDesc: 'Variable name format conversion',
toolQrCode: 'QR Code',
toolQrCodeDesc: 'Generate QR codes',
toolTimestamp: 'Timestamp',
toolTimestampDesc: 'Timestamp and date string conversion',
toolColor: 'Color',
toolColorDesc: 'Color format conversion',
},
locale: {
zhCN: '简',
zhTW: '繁',
en: 'EN',
},
common: {
close: 'Close',
copy: 'Copy',
paste: 'Paste',
clear: 'Clear',
history: 'History',
noHistory: 'No history yet',
copied: 'Copied to clipboard',
copyFailed: 'Copy failed: ',
clipboardEmpty: 'Clipboard is empty',
pasteHint: 'Press Ctrl+V or Cmd+V to paste',
pasteFailed: 'Cannot access editor, please paste manually',
cleared: 'Cleared',
},
json: {
editor: 'Editor',
maxSize: '(max 5MB)',
tree: 'Tree',
placeholder: 'Enter or paste JSON, e.g. {"name":"toolbox","version":1.0}',
jsonPathPlaceholder: 'Enter JSONPath, e.g. $.key.subkey',
jsonPathFilter: 'JSONPath filter',
clearFilter: 'Clear filter',
copyFilterResult: 'Copy filter result',
expandAll: 'Expand all',
collapseAll: 'Collapse all',
format: 'Format',
minify: 'Minify',
escape: 'Escape',
unescape: 'Unescape',
emptyState: 'Enter or paste JSON on the left, tree view will show on the right',
noMatchedNodes: 'No matching nodes',
noContentToCopy: 'Nothing to copy',
copiedCount: 'Copied {count} match(es)',
contentOverLimit: 'Content exceeds 5MB limit, truncated',
pleaseInputJson: 'Please enter JSON',
inputOverLimit: 'Input exceeds 5MB limit, cannot format',
outputOverLimit: 'Formatted output exceeds 5MB limit',
formatSuccess: 'Formatted',
jsonError: 'JSON error: ',
minifyOverLimit: 'Input exceeds 5MB limit, cannot minify',
minifyOutputOverLimit: 'Minified output exceeds 5MB limit',
minifySuccess: 'Minified',
escapeOverLimit: 'Input exceeds 5MB limit, cannot escape',
escapeOutputOverLimit: 'Escaped output exceeds 5MB limit',
escapeSuccess: 'Escaped',
escapeFailed: 'Escape failed: ',
unescapeOverLimit: 'Input exceeds 5MB limit, cannot unescape',
unescapeOutputOverLimit: 'Unescaped output exceeds 5MB limit',
unescapeSuccess: 'Unescaped',
unescapeFormatSuccess: 'Unescaped and formatted',
unescapeFailed: 'Unescape failed: ',
editorEmpty: 'Editor is empty, nothing to copy',
pasteOverLimit: 'Pasted content exceeds 5MB limit, truncated',
},
encoder: {
input: 'Input',
output: 'Output',
encode: 'Encode',
decode: 'Decode',
base64: 'Base64',
url: 'URL',
unicode: 'Unicode',
zlib: 'Zlib',
titleBase64: 'Base64 encode',
titleUrl: 'URL encode',
titleUnicode: 'Unicode encode',
titleZlib: 'Zlib compress/decompress',
copyOutput: 'Copy output',
inputPlaceholder: 'Enter text to encode or decode',
outputPlaceholder: 'Encoded or decoded result will appear here',
pleaseInputEncode: 'Please enter text to encode',
encodeSuccess: 'Encoded',
encodeFailed: 'Encode failed: ',
pleaseInputDecode: 'Please enter string to decode',
decodeSuccess: 'Decoded',
decodeFailed: 'Decode failed: invalid {type} string',
inputEmptyCopy: 'Input is empty, nothing to copy',
copiedInput: 'Input copied to clipboard',
outputEmptyCopy: 'Output is empty, nothing to copy',
copiedOutput: 'Output copied to clipboard',
manualPaste: 'Please paste manually',
},
variable: {
placeholder: 'Enter variable name (any format)',
camelCase: 'camelCase',
pascalCase: 'PascalCase',
snakeCase: 'snake_case',
kebabCase: 'kebab-case',
constantCase: 'CONSTANT_CASE',
copyLabel: 'Copy {label}',
empty: '—',
noContentToCopy: 'Nothing to copy',
copiedLabel: '{label} copied to clipboard',
},
qr: {
inputPlaceholder: 'Enter content for QR code',
generate: 'Generate QR code',
download: 'Download',
copyImage: 'Copy image',
qrCode: 'QR code',
pleaseInput: 'Please enter content for QR code',
generateSuccess: 'QR code generated',
generateFailed: 'Failed to generate: ',
noQrToDownload: 'No QR code to download',
downloadSuccess: 'Downloaded',
downloadFailed: 'Download failed: ',
noQrToCopy: 'No QR code to copy',
copyImageFailed: 'Copy failed, use download instead',
},
timestamp: {
dateToTs: 'Date → ({tz}) Timestamp:',
tsToDate: 'Timestamp → ({tz}) Date',
currentTs: 'Current timestamp:',
placeholderTs: 'Enter timestamp',
selectDateTime: 'Select date & time',
resetData: 'Reset',
resume: 'Resume',
pause: 'Pause',
seconds: 'Seconds',
milliseconds: 'Milliseconds',
nanoseconds: 'Nanoseconds',
datePlaceholderSeconds: 'Format: yyyy-MM-dd HH:mm:ss',
datePlaceholderMs: 'Format: yyyy-MM-dd HH:mm:ss.SSS',
datePlaceholderNs: 'Format: yyyy-MM-dd HH:mm:ss.SSSSSSSSS',
dataReset: 'Data reset',
invalidNs: 'Please enter a valid nanosecond timestamp',
invalidNumber: 'Please enter a valid number',
invalidTs: 'Invalid timestamp',
convertFailed: 'Convert failed: ',
invalidDateFormat: 'Invalid date format',
noContentToCopy: 'Nothing to copy',
copyFailed: 'Copy failed',
},
color: {
rgb: 'RGB',
hex: 'Hex',
hsl: 'HSL',
copyRgb: 'Copy RGB',
pasteRgb: 'Paste RGB',
copyHex: 'Copy hex',
pasteHex: 'Paste hex',
copyHsl: 'Copy HSL',
pasteHsl: 'Paste HSL',
placeholderRgb: '0-255',
placeholderHex: 'FFFFFF',
placeholderH: '0-360',
placeholderSL: '0-100',
reset: 'Reset',
random: 'Random',
rgbPasted: 'RGB pasted',
rgbOutOfRange: 'RGB out of range (0-255)',
hslPasted: 'HSL pasted',
hslOutOfRange: 'HSL out of range (H: 0-360, S/L: 0-100)',
rgbCopied: 'RGB copied to clipboard',
hexCopied: 'Hex copied to clipboard',
hslCopied: 'HSL copied to clipboard',
pasteSuccess: 'Pasted',
invalidRgb: 'Clipboard is not valid RGB',
invalidHex: 'Clipboard is not valid hex',
invalidHsl: 'Clipboard is not valid HSL',
manualPaste: 'Please paste into input manually',
pasteFailed: 'Paste failed',
},
comparator: {
textCompare: 'Text',
jsonCompare: 'JSON',
lineMode: 'By line',
charMode: 'By char',
ignoreListOrder: 'Ignore list order',
compare: 'Compare',
startCompare: 'Compare',
textA: 'Text A',
textB: 'Text B',
maxSize: '(max {size})',
placeholderA: 'Enter or paste content A',
placeholderB: 'Enter or paste content B',
result: 'Result',
same: 'Same:',
insert: 'Insert:',
delete: 'Delete:',
modify: 'Modify:',
fullscreen: 'Fullscreen',
exitFullscreen: 'Exit fullscreen',
left: 'Left',
right: 'Right',
contentOverLimit: 'Content exceeds {size} limit, truncated',
emptyCopy: '{side} is empty, nothing to copy',
copiedSide: '{side} copied to clipboard',
pasteOverLimit: 'Paste exceeds {size} limit, truncated',
pleaseInput: 'Please enter content to compare',
inputOverLimit: 'Input exceeds {size} limit',
largeArrayHint: 'Large array detected, comparison may take a while...',
compareDone: 'Done',
noResultToCopy: 'No result to copy',
resultCopied: 'Result copied to clipboard',
},
dateTimePicker: {
prevYear: 'Prev year',
prevMonth: 'Prev month',
nextMonth: 'Next month',
nextYear: 'Next year',
clickInputMonthYear: 'Click to input year-month',
placeholderMonthYear: 'YYYY-MM',
weekdays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
now: 'Now',
selectTime: 'Select time',
confirm: 'OK',
cancel: 'Cancel',
},
timestampTz: {
'UTC-12:00': 'Baker Island',
'UTC-11:00': 'Samoa',
'UTC-10:00': 'Hawaii',
'UTC-09:30': 'Marquesas',
'UTC-09:00': 'Alaska',
'UTC-08:00': 'Los Angeles',
'UTC-07:00': 'Denver',
'UTC-06:00': 'Chicago',
'UTC-05:00': 'New York',
'UTC-04:00': 'Caracas',
'UTC-03:30': 'Newfoundland',
'UTC-03:00': 'Buenos Aires',
'UTC-02:00': 'Mid-Atlantic',
'UTC-01:00': 'Azores',
'UTC+00:00': 'London',
'UTC+01:00': 'Paris',
'UTC+02:00': 'Cairo',
'UTC+03:00': 'Moscow',
'UTC+03:30': 'Tehran',
'UTC+04:00': 'Dubai',
'UTC+04:30': 'Kabul',
'UTC+05:00': 'Islamabad',
'UTC+05:30': 'New Delhi',
'UTC+05:45': 'Kathmandu',
'UTC+06:00': 'Dhaka',
'UTC+06:30': 'Yangon',
'UTC+07:00': 'Bangkok',
'UTC+08:00': 'Beijing',
'UTC+08:45': 'Eucla',
'UTC+09:00': 'Tokyo',
'UTC+09:30': 'Adelaide',
'UTC+10:00': 'Sydney',
'UTC+10:30': 'Lord Howe',
'UTC+11:00': 'Noumea',
'UTC+12:00': 'Auckland',
'UTC+12:45': 'Chatham',
'UTC+13:00': 'Samoa',
'UTC+14:00': 'Kiribati',
},
}

293
src/i18n/locales/zh-CN.js Normal file
View File

@@ -0,0 +1,293 @@
export default {
nav: {
home: '首页',
json: 'JSON',
comparator: '对比',
encoderDecoder: '编解码',
variableName: '变量名',
qrCode: '二维码',
timestamp: '时间戳',
color: '颜色',
},
app: {
title: 'RC707的工具箱',
titleJson: 'RC707的工具箱-JSON',
titleComparator: 'RC707的工具箱-对比',
titleEncoderDecoder: 'RC707的工具箱-编解码',
titleVariableName: 'RC707的工具箱-变量名',
titleQrCode: 'RC707的工具箱-二维码',
titleTimestamp: 'RC707的工具箱-时间戳',
titleColor: 'RC707的工具箱-颜色',
},
home: {
heroToday: '今天是{date}',
toolJson: 'JSON',
toolJsonDesc: '格式化、验证和美化JSON数据',
toolComparator: '对比',
toolComparatorDesc: '文本和JSON对比工具',
toolEncoderDecoder: '编解码',
toolEncoderDecoderDesc: '编码/解码工具',
toolVariableName: '变量名',
toolVariableNameDesc: '变量名格式转换',
toolQrCode: '二维码',
toolQrCodeDesc: '生成二维码',
toolTimestamp: '时间戳',
toolTimestampDesc: '时间戳与时间字符串相互转换',
toolColor: '颜色',
toolColorDesc: '颜色格式转换',
},
locale: {
zhCN: '简体',
zhTW: '繁中',
en: 'EN',
},
common: {
close: '关闭',
copy: '复制',
paste: '粘贴',
clear: '清空',
history: '历史记录',
noHistory: '暂无历史记录',
copied: '已复制到剪贴板',
copyFailed: '复制失败:',
clipboardEmpty: '剪贴板内容为空',
pasteHint: '请按 Ctrl+V 或 Cmd+V 粘贴内容',
pasteFailed: '无法访问编辑器,请手动粘贴内容',
cleared: '已清空',
},
json: {
editor: '编辑器',
maxSize: '(最大 5MB)',
tree: '树形',
placeholder: '请输入或粘贴JSON数据例如{"name":"工具箱","version":1.0}',
jsonPathPlaceholder: '输入 JSONPath例如: $.key.subkey',
jsonPathFilter: 'JSONPath 筛选',
clearFilter: '清除筛选',
copyFilterResult: '复制筛选结果',
expandAll: '展开全部',
collapseAll: '折叠全部',
format: '格式化',
minify: '压缩',
escape: '转义',
unescape: '取消转义',
emptyState: '在左侧输入或粘贴JSON数据右侧将实时显示树形结构',
noMatchedNodes: '未找到匹配的节点',
noContentToCopy: '没有可复制的结果',
copiedCount: '已复制 {count} 个匹配结果',
contentOverLimit: '内容已超过 5MB 限制,已自动截断',
pleaseInputJson: '请输入JSON数据',
inputOverLimit: '输入内容超过 5MB 限制,无法格式化',
outputOverLimit: '格式化后的内容超过 5MB 限制,无法显示',
formatSuccess: '格式化成功',
jsonError: 'JSON格式错误',
minifyOverLimit: '输入内容超过 5MB 限制,无法压缩',
minifyOutputOverLimit: '压缩后的内容超过 5MB 限制,无法显示',
minifySuccess: '压缩成功',
escapeOverLimit: '输入内容超过 5MB 限制,无法转义',
escapeOutputOverLimit: '转义后的内容超过 5MB 限制,无法显示',
escapeSuccess: '转义成功',
escapeFailed: '转义失败:',
unescapeOverLimit: '输入内容超过 5MB 限制,无法取消转义',
unescapeOutputOverLimit: '取消转义后的内容超过 5MB 限制,无法显示',
unescapeSuccess: '取消转义成功',
unescapeFormatSuccess: '取消转义并格式化成功',
unescapeFailed: '取消转义失败:',
editorEmpty: '编辑器内容为空,无法复制',
pasteOverLimit: '粘贴内容已超过 5MB 限制,已自动截断',
},
encoder: {
input: '输入',
output: '输出',
encode: '编码',
decode: '解码',
base64: 'Base64',
url: 'URL',
unicode: 'Unicode',
zlib: 'Zlib',
titleBase64: 'Base64编码',
titleUrl: 'URL编码',
titleUnicode: 'Unicode编码',
titleZlib: 'Zlib 压缩/解压',
copyOutput: '复制输出',
inputPlaceholder: '请输入要编码或解码的文本',
outputPlaceholder: '编码或解码结果将显示在这里',
pleaseInputEncode: '请输入要编码的文本',
encodeSuccess: '编码成功',
encodeFailed: '编码失败:',
pleaseInputDecode: '请输入要解码的字符串',
decodeSuccess: '解码成功',
decodeFailed: '解码失败:请检查输入是否为有效的{type}编码字符串',
inputEmptyCopy: '输入内容为空,无法复制',
copiedInput: '已复制输入到剪贴板',
outputEmptyCopy: '输出内容为空,无法复制',
copiedOutput: '已复制输出到剪贴板',
manualPaste: '请手动粘贴内容',
},
variable: {
placeholder: '请输入变量名(支持任意格式)',
camelCase: '小驼峰 (camelCase)',
pascalCase: '大驼峰 (PascalCase)',
snakeCase: '下划线 (snake_case)',
kebabCase: '横线 (kebab-case)',
constantCase: '常量 (CONSTANT_CASE)',
copyLabel: '复制{label}',
empty: '—',
noContentToCopy: '没有可复制的内容',
copiedLabel: '{label}已复制到剪贴板',
},
qr: {
inputPlaceholder: '请输入要生成二维码的内容',
generate: '生成二维码',
download: '下载',
copyImage: '复制图片',
qrCode: '二维码',
pleaseInput: '请输入要生成二维码的内容',
generateSuccess: '二维码生成成功',
generateFailed: '生成二维码失败:',
noQrToDownload: '没有可下载的二维码',
downloadSuccess: '下载成功',
downloadFailed: '下载失败:',
noQrToCopy: '没有可复制的二维码',
copyImageFailed: '复制失败,请使用下载功能',
},
timestamp: {
dateToTs: '日期 → ({tz}) 时间戳:',
tsToDate: '时间戳 → ({tz}) 日期',
currentTs: '当前时间戳:',
placeholderTs: '请输入时间戳',
selectDateTime: '选择日期时间',
resetData: '重置数据',
resume: '继续',
pause: '暂停',
seconds: '秒',
milliseconds: '毫秒',
nanoseconds: '纳秒',
datePlaceholderSeconds: '格式yyyy-MM-dd HH:mm:ss',
datePlaceholderMs: '格式yyyy-MM-dd HH:mm:ss.SSS',
datePlaceholderNs: '格式yyyy-MM-dd HH:mm:ss.SSSSSSSSS',
dataReset: '数据已重置',
invalidNs: '请输入有效的纳秒级时间戳',
invalidNumber: '请输入有效的数字',
invalidTs: '无效的时间戳',
convertFailed: '转换失败:',
invalidDateFormat: '无效的时间格式',
noContentToCopy: '没有可复制的内容',
copyFailed: '复制失败',
},
color: {
rgb: 'RGB',
hex: '十六进制',
hsl: 'HSL',
copyRgb: '复制RGB',
pasteRgb: '粘贴RGB',
copyHex: '复制十六进制',
pasteHex: '粘贴十六进制',
copyHsl: '复制HSL',
pasteHsl: '粘贴HSL',
placeholderRgb: '0-255',
placeholderHex: 'FFFFFF',
placeholderH: '0-360',
placeholderSL: '0-100',
reset: '重置',
random: '随机颜色',
rgbPasted: 'RGB已粘贴并解析',
rgbOutOfRange: 'RGB值超出范围0-255',
hslPasted: 'HSL已粘贴并解析',
hslOutOfRange: 'HSL值超出范围H: 0-360, S/L: 0-100',
rgbCopied: 'RGB已复制到剪贴板',
hexCopied: '十六进制已复制到剪贴板',
hslCopied: 'HSL已复制到剪贴板',
pasteSuccess: '粘贴成功',
invalidRgb: '剪贴板内容不是有效的RGB格式',
invalidHex: '剪贴板内容不是有效的十六进制格式',
invalidHsl: '剪贴板内容不是有效的HSL格式',
manualPaste: '请手动粘贴到输入框',
pasteFailed: '粘贴失败:请手动粘贴到输入框',
},
comparator: {
textCompare: '文本对比',
jsonCompare: 'JSON对比',
lineMode: '行维度',
charMode: '字符维度',
ignoreListOrder: '忽略列表顺序',
compare: '对比',
startCompare: '开始对比',
textA: '文本 A',
textB: '文本 B',
maxSize: '(最大 {size})',
placeholderA: '请输入或粘贴要对比的内容 A',
placeholderB: '请输入或粘贴要对比的内容 B',
result: '对比结果',
same: '相同:',
insert: '插入:',
delete: '删除:',
modify: '修改:',
fullscreen: '全屏展示',
exitFullscreen: '退出全屏',
left: '左侧',
right: '右侧',
contentOverLimit: '内容已超过 {size} 限制,已自动截断',
emptyCopy: '{side}内容为空,无法复制',
copiedSide: '已复制{side}内容到剪贴板',
pasteOverLimit: '粘贴内容已超过 {size} 限制,已自动截断',
pleaseInput: '请输入要对比的内容',
inputOverLimit: '输入内容超过 {size} 限制,请减小输入大小',
largeArrayHint: '检测到大型数组,对比可能需要较长时间,请耐心等待...',
compareDone: '对比完成',
noResultToCopy: '没有对比结果可复制',
resultCopied: '已复制对比结果到剪贴板',
},
dateTimePicker: {
prevYear: '上一年',
prevMonth: '上一月',
nextMonth: '下一月',
nextYear: '下一年',
clickInputMonthYear: '点击输入年月',
placeholderMonthYear: 'YYYY-MM',
weekdays: ['日', '一', '二', '三', '四', '五', '六'],
now: '此刻',
selectTime: '选择时间',
confirm: '确定',
cancel: '取消',
},
timestampTz: {
'UTC-12:00': '贝克岛',
'UTC-11:00': '萨摩亚',
'UTC-10:00': '夏威夷',
'UTC-09:30': '马克萨斯群岛',
'UTC-09:00': '阿拉斯加',
'UTC-08:00': '洛杉矶',
'UTC-07:00': '丹佛',
'UTC-06:00': '芝加哥',
'UTC-05:00': '纽约',
'UTC-04:00': '加拉加斯',
'UTC-03:30': '纽芬兰',
'UTC-03:00': '布宜诺斯艾利斯',
'UTC-02:00': '大西洋中部',
'UTC-01:00': '亚速尔群岛',
'UTC+00:00': '伦敦',
'UTC+01:00': '巴黎',
'UTC+02:00': '开罗',
'UTC+03:00': '莫斯科',
'UTC+03:30': '德黑兰',
'UTC+04:00': '迪拜',
'UTC+04:30': '喀布尔',
'UTC+05:00': '伊斯兰堡',
'UTC+05:30': '新德里',
'UTC+05:45': '加德满都',
'UTC+06:00': '达卡',
'UTC+06:30': '仰光',
'UTC+07:00': '曼谷',
'UTC+08:00': '北京',
'UTC+08:45': '尤克拉',
'UTC+09:00': '东京',
'UTC+09:30': '阿德莱德',
'UTC+10:00': '悉尼',
'UTC+10:30': '豪勋爵岛',
'UTC+11:00': '新喀里多尼亚',
'UTC+12:00': '奥克兰',
'UTC+12:45': '查塔姆群岛',
'UTC+13:00': '萨摩亚',
'UTC+14:00': '基里巴斯',
},
}

293
src/i18n/locales/zh-TW.js Normal file
View File

@@ -0,0 +1,293 @@
export default {
nav: {
home: '首頁',
json: 'JSON',
comparator: '對比',
encoderDecoder: '編解碼',
variableName: '變數名',
qrCode: '二維碼',
timestamp: '時間戳',
color: '顏色',
},
app: {
title: 'RC707的工具箱',
titleJson: 'RC707的工具箱-JSON',
titleComparator: 'RC707的工具箱-對比',
titleEncoderDecoder: 'RC707的工具箱-編解碼',
titleVariableName: 'RC707的工具箱-變數名',
titleQrCode: 'RC707的工具箱-二維碼',
titleTimestamp: 'RC707的工具箱-時間戳',
titleColor: 'RC707的工具箱-顏色',
},
home: {
heroToday: '今天是{date}',
toolJson: 'JSON',
toolJsonDesc: '格式化、驗證和美化JSON資料',
toolComparator: '對比',
toolComparatorDesc: '文字和JSON對比工具',
toolEncoderDecoder: '編解碼',
toolEncoderDecoderDesc: '編碼/解碼工具',
toolVariableName: '變數名',
toolVariableNameDesc: '變數名格式轉換',
toolQrCode: '二維碼',
toolQrCodeDesc: '產生二維碼',
toolTimestamp: '時間戳',
toolTimestampDesc: '時間戳與時間字串相互轉換',
toolColor: '顏色',
toolColorDesc: '顏色格式轉換',
},
locale: {
zhCN: '簡體',
zhTW: '繁中',
en: 'EN',
},
common: {
close: '關閉',
copy: '複製',
paste: '貼上',
clear: '清空',
history: '歷史記錄',
noHistory: '暫無歷史記錄',
copied: '已複製到剪貼簿',
copyFailed: '複製失敗:',
clipboardEmpty: '剪貼簿內容為空',
pasteHint: '請按 Ctrl+V 或 Cmd+V 貼上內容',
pasteFailed: '無法存取編輯器,請手動貼上內容',
cleared: '已清空',
},
json: {
editor: '編輯器',
maxSize: '(最大 5MB)',
tree: '樹形',
placeholder: '請輸入或貼上JSON資料例如{"name":"工具箱","version":1.0}',
jsonPathPlaceholder: '輸入 JSONPath例如: $.key.subkey',
jsonPathFilter: 'JSONPath 篩選',
clearFilter: '清除篩選',
copyFilterResult: '複製篩選結果',
expandAll: '展開全部',
collapseAll: '摺疊全部',
format: '格式化',
minify: '壓縮',
escape: '轉義',
unescape: '取消轉義',
emptyState: '在左側輸入或貼上JSON資料右側將即時顯示樹形結構',
noMatchedNodes: '未找到符合的節點',
noContentToCopy: '沒有可複製的結果',
copiedCount: '已複製 {count} 個符合結果',
contentOverLimit: '內容已超過 5MB 限制,已自動截斷',
pleaseInputJson: '請輸入JSON資料',
inputOverLimit: '輸入內容超過 5MB 限制,無法格式化',
outputOverLimit: '格式化後的內容超過 5MB 限制,無法顯示',
formatSuccess: '格式化成功',
jsonError: 'JSON格式錯誤',
minifyOverLimit: '輸入內容超過 5MB 限制,無法壓縮',
minifyOutputOverLimit: '壓縮後的內容超過 5MB 限制,無法顯示',
minifySuccess: '壓縮成功',
escapeOverLimit: '輸入內容超過 5MB 限制,無法轉義',
escapeOutputOverLimit: '轉義後的內容超過 5MB 限制,無法顯示',
escapeSuccess: '轉義成功',
escapeFailed: '轉義失敗:',
unescapeOverLimit: '輸入內容超過 5MB 限制,無法取消轉義',
unescapeOutputOverLimit: '取消轉義後的內容超過 5MB 限制,無法顯示',
unescapeSuccess: '取消轉義成功',
unescapeFormatSuccess: '取消轉義並格式化成功',
unescapeFailed: '取消轉義失敗:',
editorEmpty: '編輯器內容為空,無法複製',
pasteOverLimit: '貼上內容已超過 5MB 限制,已自動截斷',
},
encoder: {
input: '輸入',
output: '輸出',
encode: '編碼',
decode: '解碼',
base64: 'Base64',
url: 'URL',
unicode: 'Unicode',
zlib: 'Zlib',
titleBase64: 'Base64編碼',
titleUrl: 'URL編碼',
titleUnicode: 'Unicode編碼',
titleZlib: 'Zlib 壓縮/解壓',
copyOutput: '複製輸出',
inputPlaceholder: '請輸入要編碼或解碼的文字',
outputPlaceholder: '編碼或解碼結果將顯示在這裡',
pleaseInputEncode: '請輸入要編碼的文字',
encodeSuccess: '編碼成功',
encodeFailed: '編碼失敗:',
pleaseInputDecode: '請輸入要解碼的字串',
decodeSuccess: '解碼成功',
decodeFailed: '解碼失敗:請檢查輸入是否為有效的{type}編碼字串',
inputEmptyCopy: '輸入內容為空,無法複製',
copiedInput: '已複製輸入到剪貼簿',
outputEmptyCopy: '輸出內容為空,無法複製',
copiedOutput: '已複製輸出到剪貼簿',
manualPaste: '請手動貼上內容',
},
variable: {
placeholder: '請輸入變數名(支援任意格式)',
camelCase: '小駝峰 (camelCase)',
pascalCase: '大駝峰 (PascalCase)',
snakeCase: '底線 (snake_case)',
kebabCase: '橫線 (kebab-case)',
constantCase: '常數 (CONSTANT_CASE)',
copyLabel: '複製{label}',
empty: '—',
noContentToCopy: '沒有可複製的內容',
copiedLabel: '{label}已複製到剪貼簿',
},
qr: {
inputPlaceholder: '請輸入要產生二維碼的內容',
generate: '產生二維碼',
download: '下載',
copyImage: '複製圖片',
qrCode: '二維碼',
pleaseInput: '請輸入要產生二維碼的內容',
generateSuccess: '二維碼產生成功',
generateFailed: '產生二維碼失敗:',
noQrToDownload: '沒有可下載的二維碼',
downloadSuccess: '下載成功',
downloadFailed: '下載失敗:',
noQrToCopy: '沒有可複製的二維碼',
copyImageFailed: '複製失敗,請使用下載功能',
},
timestamp: {
dateToTs: '日期 → ({tz}) 時間戳:',
tsToDate: '時間戳 → ({tz}) 日期',
currentTs: '目前時間戳:',
placeholderTs: '請輸入時間戳',
selectDateTime: '選擇日期時間',
resetData: '重設資料',
resume: '繼續',
pause: '暫停',
seconds: '秒',
milliseconds: '毫秒',
nanoseconds: '奈秒',
datePlaceholderSeconds: '格式yyyy-MM-dd HH:mm:ss',
datePlaceholderMs: '格式yyyy-MM-dd HH:mm:ss.SSS',
datePlaceholderNs: '格式yyyy-MM-dd HH:mm:ss.SSSSSSSSS',
dataReset: '資料已重設',
invalidNs: '請輸入有效的奈秒級時間戳',
invalidNumber: '請輸入有效的數字',
invalidTs: '無效的時間戳',
convertFailed: '轉換失敗:',
invalidDateFormat: '無效的時間格式',
noContentToCopy: '沒有可複製的內容',
copyFailed: '複製失敗',
},
color: {
rgb: 'RGB',
hex: '十六進位',
hsl: 'HSL',
copyRgb: '複製RGB',
pasteRgb: '貼上RGB',
copyHex: '複製十六進位',
pasteHex: '貼上十六進位',
copyHsl: '複製HSL',
pasteHsl: '貼上HSL',
placeholderRgb: '0-255',
placeholderHex: 'FFFFFF',
placeholderH: '0-360',
placeholderSL: '0-100',
reset: '重設',
random: '隨機顏色',
rgbPasted: 'RGB已貼上並解析',
rgbOutOfRange: 'RGB值超出範圍0-255',
hslPasted: 'HSL已貼上並解析',
hslOutOfRange: 'HSL值超出範圍H: 0-360, S/L: 0-100',
rgbCopied: 'RGB已複製到剪貼簿',
hexCopied: '十六進位已複製到剪貼簿',
hslCopied: 'HSL已複製到剪貼簿',
pasteSuccess: '貼上成功',
invalidRgb: '剪貼簿內容不是有效的RGB格式',
invalidHex: '剪貼簿內容不是有效的十六進位格式',
invalidHsl: '剪貼簿內容不是有效的HSL格式',
manualPaste: '請手動貼上到輸入框',
pasteFailed: '貼上失敗:請手動貼上到輸入框',
},
comparator: {
textCompare: '文字對比',
jsonCompare: 'JSON對比',
lineMode: '行維度',
charMode: '字元維度',
ignoreListOrder: '忽略列表順序',
compare: '對比',
startCompare: '開始對比',
textA: '文字 A',
textB: '文字 B',
maxSize: '(最大 {size})',
placeholderA: '請輸入或貼上要對比的內容 A',
placeholderB: '請輸入或貼上要對比的內容 B',
result: '對比結果',
same: '相同:',
insert: '插入:',
delete: '刪除:',
modify: '修改:',
fullscreen: '全螢幕展示',
exitFullscreen: '結束全螢幕',
left: '左側',
right: '右側',
contentOverLimit: '內容已超過 {size} 限制,已自動截斷',
emptyCopy: '{side}內容為空,無法複製',
copiedSide: '已複製{side}內容到剪貼簿',
pasteOverLimit: '貼上內容已超過 {size} 限制,已自動截斷',
pleaseInput: '請輸入要對比的內容',
inputOverLimit: '輸入內容超過 {size} 限制,請減小輸入大小',
largeArrayHint: '偵測到大型陣列,對比可能需要較長時間,請耐心等候...',
compareDone: '對比完成',
noResultToCopy: '沒有對比結果可複製',
resultCopied: '已複製對比結果到剪貼簿',
},
dateTimePicker: {
prevYear: '上一年',
prevMonth: '上一月',
nextMonth: '下一月',
nextYear: '下一年',
clickInputMonthYear: '點擊輸入年月',
placeholderMonthYear: 'YYYY-MM',
weekdays: ['日', '一', '二', '三', '四', '五', '六'],
now: '此刻',
selectTime: '選擇時間',
confirm: '確定',
cancel: '取消',
},
timestampTz: {
'UTC-12:00': '貝克島',
'UTC-11:00': '薩摩亞',
'UTC-10:00': '夏威夷',
'UTC-09:30': '馬克薩斯群島',
'UTC-09:00': '阿拉斯加',
'UTC-08:00': '洛杉磯',
'UTC-07:00': '丹佛',
'UTC-06:00': '芝加哥',
'UTC-05:00': '紐約',
'UTC-04:00': '加拉加斯',
'UTC-03:30': '紐芬蘭',
'UTC-03:00': '布宜諾斯艾利斯',
'UTC-02:00': '大西洋中部',
'UTC-01:00': '亞速爾群島',
'UTC+00:00': '倫敦',
'UTC+01:00': '巴黎',
'UTC+02:00': '開羅',
'UTC+03:00': '莫斯科',
'UTC+03:30': '德黑蘭',
'UTC+04:00': '迪拜',
'UTC+04:30': '喀布爾',
'UTC+05:00': '伊斯蘭堡',
'UTC+05:30': '新德里',
'UTC+05:45': '加德滿都',
'UTC+06:00': '達卡',
'UTC+06:30': '仰光',
'UTC+07:00': '曼谷',
'UTC+08:00': '北京',
'UTC+08:45': '尤克拉',
'UTC+09:00': '東京',
'UTC+09:30': '阿德萊德',
'UTC+10:00': '悉尼',
'UTC+10:30': '豪勳爵島',
'UTC+11:00': '新喀里多尼亞',
'UTC+12:00': '奧克蘭',
'UTC+12:45': '查塔姆群島',
'UTC+13:00': '薩摩亞',
'UTC+14:00': '吉里巴斯',
},
}

10
src/main.js Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import AppRoot from './AppRoot.vue'
import router from './router'
import { i18n } from './i18n'
import './style.css'
// 引入 Font Awesome 6.4
import '@fortawesome/fontawesome-free/css/all.css'
createApp(AppRoot).use(router).use(i18n).mount('#app')

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

@@ -0,0 +1,113 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import { i18n } from '../i18n'
// 路径中的 locale 与 i18n locale 的映射
export const PATH_LOCALE_TO_I18N = {
zh: 'zh-CN',
'zh-tw': 'zh-TW',
en: 'en',
}
export const I18N_TO_PATH_LOCALE = {
'zh-CN': 'zh',
'zh-TW': 'zh-tw',
en: 'en',
}
export const PATH_LOCALES = ['zh', 'zh-tw', 'en']
const localePath = (locale, path = '') => {
const base = path.startsWith('/') ? path.slice(1) : path
return base ? `/${locale}/${base}` : `/${locale}`
}
const routes = [
// 无前缀路径重定向到简体中文
{ path: '/', redirect: '/zh' },
{ path: '/json-formatter', redirect: '/zh/json-formatter' },
{ path: '/comparator', redirect: '/zh/comparator' },
{ path: '/encoder-decoder', redirect: '/zh/encoder-decoder' },
{ path: '/variable-name', redirect: '/zh/variable-name' },
{ path: '/qr-code', redirect: '/zh/qr-code' },
{ path: '/timestamp-converter', redirect: '/zh/timestamp-converter' },
{ path: '/color-converter', redirect: '/zh/color-converter' },
// 带语言前缀的路由App 作为父级以获取 locale
{
path: '/:locale(zh|zh-tw|en)',
component: () => import('../App.vue'),
children: [
{
path: '',
name: 'Home',
component: Home,
meta: { titleKey: 'app.title' },
},
{
path: 'json-formatter',
name: 'JsonFormatter',
component: () => import('../views/JsonFormatter.vue'),
meta: { titleKey: 'app.titleJson' },
},
{
path: 'comparator',
name: 'Comparator',
component: () => import('../views/Comparator.vue'),
meta: { titleKey: 'app.titleComparator' },
},
{
path: 'encoder-decoder',
name: 'EncoderDecoder',
component: () => import('../views/EncoderDecoder.vue'),
meta: { titleKey: 'app.titleEncoderDecoder' },
},
{
path: 'variable-name',
name: 'VariableNameConverter',
component: () => import('../views/VariableNameConverter.vue'),
meta: { titleKey: 'app.titleVariableName' },
},
{
path: 'qr-code',
name: 'QRCodeGenerator',
component: () => import('../views/QRCodeGenerator.vue'),
meta: { titleKey: 'app.titleQrCode' },
},
{
path: 'timestamp-converter',
name: 'TimestampConverter',
component: () => import('../views/TimestampConverter.vue'),
meta: { titleKey: 'app.titleTimestamp' },
},
{
path: 'color-converter',
name: 'ColorConverter',
component: () => import('../views/ColorConverter.vue'),
meta: { titleKey: 'app.titleColor' },
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, from, next) => {
const pathLocale = to.params.locale
if (pathLocale && PATH_LOCALE_TO_I18N[pathLocale]) {
const i18nLocale = PATH_LOCALE_TO_I18N[pathLocale]
if (i18n.global.locale.value !== i18nLocale) {
i18n.global.locale.value = i18nLocale
}
}
const key = to.meta.titleKey
if (key) {
document.title = i18n.global.t(key)
}
next()
})
export { localePath }
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;
}

1485
src/views/ColorConverter.vue Normal file

File diff suppressed because it is too large Load Diff

2878
src/views/Comparator.vue Normal file

File diff suppressed because it is too large Load Diff

1206
src/views/EncoderDecoder.vue Normal file

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,187 @@
<template>
<div class="home-container">
<div class="hero-section">
<h2 class="hero-title">
{{ t('home.heroToday', { date: heroDate }) }}
</h2>
<p id="jinrishici-sentence" class="hero-subtitle"></p>
</div>
<div class="tools-grid">
<router-link
v-for="tool in tools"
:key="tool.path"
:to="localePath(currentPathLocale, tool.path)"
class="tool-card"
>
<h3 class="tool-title">{{ tool.title }}</h3>
<p class="tool-description">{{ tool.description }}</p>
</router-link>
</div>
<div class="footer-section">
<p class="icp-info">苏ICP备2022013040号-1</p>
</div>
</div>
</template>
<script setup>
import { onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { localePath } from '../router'
const { t, locale } = useI18n()
const route = useRoute()
const currentPathLocale = computed(() => route.params.locale || 'zh')
const heroDate = computed(() => {
const d = new Date()
const y = d.getFullYear()
const m = d.getMonth() + 1
const day = d.getDate()
if (locale.value === 'en') {
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
}
return `${y}${m}${day}`
})
const tools = computed(() => [
{ path: 'json-formatter', title: t('home.toolJson'), description: t('home.toolJsonDesc') },
{ path: 'comparator', title: t('home.toolComparator'), description: t('home.toolComparatorDesc') },
{ path: 'encoder-decoder', title: t('home.toolEncoderDecoder'), description: t('home.toolEncoderDecoderDesc') },
{ path: 'variable-name', title: t('home.toolVariableName'), description: t('home.toolVariableNameDesc') },
{ path: 'qr-code', title: t('home.toolQrCode'), description: t('home.toolQrCodeDesc') },
{ path: 'timestamp-converter', title: t('home.toolTimestamp'), description: t('home.toolTimestampDesc') },
{ path: 'color-converter', title: t('home.toolColor'), description: t('home.toolColorDesc') },
])
onMounted(() => {
const words_script = document.createElement('script')
words_script.charset = 'utf-8'
words_script.src = 'https://sdk.jinrishici.com/v2/browser/jinrishici.js'
document.body.appendChild(words_script)
})
</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%;
padding: 1.5rem;
}
}
.footer-section {
text-align: center;
padding: 2rem 0;
margin-top: 3rem;
border-top: 1px solid #e5e5e5;
}
.icp-info {
color: #999999;
font-size: 0.875rem;
margin: 0;
}
</style>

2156
src/views/JsonFormatter.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,702 @@
<template>
<div class="tool-page">
<!-- 浮层提示 -->
<Transition name="toast">
<div v-if="toastMessage" class="toast-notification" :class="toastType">
<div class="toast-content">
<i v-if="toastType === 'error'" class="fas fa-circle-exclamation"></i>
<i v-else class="fas fa-circle-check"></i>
<span>{{ toastMessage }}</span>
</div>
<button @click="closeToast" class="toast-close-btn" :title="t('common.close')">
<i class="fas fa-xmark"></i>
</button>
</div>
</Transition>
<div class="main-container">
<!-- 左侧侧栏历史记录 -->
<div class="sidebar" :class="{ 'sidebar-open': sidebarOpen }">
<div class="sidebar-header">
<h3>{{ t('common.history') }}</h3>
<button @click="toggleSidebar" class="close-btn">×</button>
</div>
<div class="sidebar-content">
<div v-if="historyList.length === 0" class="empty-history">
{{ t('common.noHistory') }}
</div>
<div
v-for="(item, index) in historyList"
:key="index"
class="history-item"
@click="loadHistory(item.text)"
>
<div class="history-time">{{ formatTime(item.time) }}</div>
<div class="history-preview">{{ truncateText(item.text, 50) }}</div>
</div>
</div>
</div>
<!-- 主内容区域 -->
<div class="content-wrapper" :class="{ 'sidebar-pushed': sidebarOpen }">
<div class="container">
<div class="qr-card">
<!-- 输入区域 -->
<div class="input-section">
<div class="input-wrapper">
<textarea
v-model="inputText"
@keydown.enter.prevent="generateQRCode"
:placeholder="t('qr.inputPlaceholder')"
class="input-textarea"
rows="4"
></textarea>
</div>
<button @click="generateQRCode" class="generate-btn">
<i class="fas fa-qrcode"></i>
{{ t('qr.generate') }}
</button>
</div>
<!-- 二维码显示区域 -->
<div v-if="qrCodeDataUrl" class="qr-display-section">
<div class="qr-code-wrapper">
<img :src="qrCodeDataUrl" :alt="t('qr.qrCode')" class="qr-code-image" />
</div>
<div class="qr-actions">
<button @click="downloadQRCode" class="action-btn">
<i class="fas fa-download"></i>
{{ t('qr.download') }}
</button>
<button @click="copyQRCodeImage" class="action-btn">
<i class="far fa-copy"></i>
{{ t('qr.copyImage') }}
</button>
</div>
</div>
</div>
</div>
<!-- 侧栏切换按钮 -->
<div class="sidebar-toggle">
<button @click="toggleSidebar" class="toggle-btn">
{{ sidebarOpen ? '◀' : '▶' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import QRCode from 'qrcode'
const { t } = useI18n()
// 输入文本
const inputText = ref('')
// 二维码数据URL
const qrCodeDataUrl = ref('')
// 侧栏状态
const sidebarOpen = ref(false)
// 历史记录
const historyList = ref([])
const STORAGE_KEY = 'qr-code-history'
const MAX_HISTORY = 20
// 提示消息
const toastMessage = ref('')
const toastType = ref('success')
let toastTimer = null
// 显示提示
const showToast = (message, type = 'success', duration = 3000) => {
toastMessage.value = message
toastType.value = type
if (toastTimer) {
clearTimeout(toastTimer)
}
toastTimer = setTimeout(() => {
toastMessage.value = ''
}, duration)
}
// 关闭提示
const closeToast = () => {
if (toastTimer) {
clearTimeout(toastTimer)
toastTimer = null
}
toastMessage.value = ''
}
// 生成二维码
const generateQRCode = async () => {
if (!inputText.value.trim()) {
showToast(t('qr.pleaseInput'), 'error')
return
}
try {
// 生成二维码
const dataUrl = await QRCode.toDataURL(inputText.value.trim(), {
width: 300,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
qrCodeDataUrl.value = dataUrl
// 保存到历史记录
saveToHistory(inputText.value.trim())
showToast(t('qr.generateSuccess'), 'success', 2000)
} catch (error) {
showToast(t('qr.generateFailed') + error.message, 'error')
qrCodeDataUrl.value = ''
}
}
// 下载二维码
const downloadQRCode = () => {
if (!qrCodeDataUrl.value) {
showToast(t('qr.noQrToDownload'), 'error')
return
}
try {
const link = document.createElement('a')
link.download = `qrcode-${Date.now()}.png`
link.href = qrCodeDataUrl.value
link.click()
showToast(t('qr.downloadSuccess'), 'success', 2000)
} catch (error) {
showToast(t('qr.downloadFailed') + error.message, 'error')
}
}
// 复制二维码图片
const copyQRCodeImage = async () => {
if (!qrCodeDataUrl.value) {
showToast(t('qr.noQrToCopy'), 'error')
return
}
try {
// 将 data URL 转换为 blob
const response = await fetch(qrCodeDataUrl.value)
const blob = await response.blob()
// 复制到剪贴板
await navigator.clipboard.write([
new ClipboardItem({
'image/png': blob
})
])
showToast(t('common.copied'), 'success', 2000)
} catch (error) {
showToast(t('qr.copyImageFailed'), 'error')
}
}
// 保存到历史记录
const saveToHistory = (text) => {
const historyItem = {
text: text,
time: Date.now()
}
// 从localStorage读取现有历史
let history = []
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
history = JSON.parse(stored)
}
} catch (e) {
// 读取历史记录失败,忽略错误
}
// 避免重复保存相同的记录
const lastHistory = history[0]
if (lastHistory && lastHistory.text === historyItem.text) {
return
}
// 添加到开头
history.unshift(historyItem)
// 限制最多20条
if (history.length > MAX_HISTORY) {
history = history.slice(0, MAX_HISTORY)
}
// 保存到localStorage
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(history))
loadHistoryList()
} catch (e) {
// 保存历史记录失败,忽略错误
}
}
// 加载历史记录列表
const loadHistoryList = () => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
historyList.value = JSON.parse(stored)
}
} catch (e) {
// 加载历史记录失败,重置为空数组
historyList.value = []
}
}
// 加载历史记录
const loadHistory = (text) => {
inputText.value = text
generateQRCode()
}
// 切换侧栏
const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value
}
// 格式化时间
const formatTime = (timestamp) => {
const date = new Date(timestamp)
const now = new Date()
const diff = now - date
if (diff < 60000) return '刚刚'
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 截断文本
const truncateText = (text, maxLength) => {
if (text.length <= maxLength) return text
return text.substring(0, maxLength) + '...'
}
onMounted(() => {
loadHistoryList()
})
</script>
<style scoped>
.tool-page {
height: calc(100vh - 64px);
display: flex;
flex-direction: column;
margin: -1rem;
padding: 0;
background: #ffffff;
}
.main-container {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
background: #ffffff;
}
/* 侧栏样式 */
.sidebar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 300px;
background: #ffffff;
border-right: 1px solid #e5e5e5;
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 10;
display: flex;
flex-direction: column;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
}
.sidebar-open {
transform: translateX(0);
}
.content-wrapper.sidebar-pushed {
margin-left: 300px;
}
.sidebar-header {
padding: 1rem;
border-bottom: 1px solid #e5e5e5;
display: flex;
justify-content: space-between;
align-items: center;
background: #ffffff;
}
.sidebar-header h3 {
margin: 0;
font-size: 1rem;
color: #1a1a1a;
font-weight: 500;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666666;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.close-btn:hover {
color: #1a1a1a;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
background: #ffffff;
}
.empty-history {
padding: 2rem;
text-align: center;
color: #999999;
font-size: 0.875rem;
}
.history-item {
padding: 0.75rem;
margin-bottom: 0.5rem;
background: #ffffff;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid #e5e5e5;
}
.history-item:hover {
background: #f5f5f5;
border-color: #d0d0d0;
}
.history-time {
font-size: 0.75rem;
color: #999999;
margin-bottom: 0.25rem;
}
.history-preview {
font-size: 0.875rem;
color: #1a1a1a;
word-break: break-all;
}
.content-wrapper {
flex: 1;
display: flex;
flex-direction: column;
transition: margin-left 0.3s ease;
overflow-y: auto;
padding: 1rem;
}
.container {
max-width: 600px;
margin: 0 auto;
width: 100%;
}
.qr-card {
background: #ffffff;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid #e5e5e5;
}
.input-section {
margin-bottom: 2rem;
}
.input-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #333333;
margin-bottom: 0.5rem;
}
.input-wrapper {
margin-bottom: 1rem;
}
.input-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #d0d0d0;
border-radius: 6px;
font-size: 0.9375rem;
font-family: inherit;
resize: vertical;
transition: all 0.2s;
box-sizing: border-box;
}
.input-textarea:focus {
outline: none;
border-color: #1a1a1a;
box-shadow: 0 0 0 3px rgba(26, 26, 26, 0.1);
}
.generate-btn {
width: 100%;
padding: 0.75rem 1.5rem;
background: #1a1a1a;
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.generate-btn:hover {
background: #333333;
}
.generate-btn:active {
transform: scale(0.98);
}
.qr-display-section {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid #e5e5e5;
}
.qr-code-wrapper {
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
background: #fafafa;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.qr-code-image {
max-width: 100%;
height: auto;
display: block;
}
.qr-actions {
display: flex;
gap: 0.75rem;
justify-content: center;
}
.action-btn {
padding: 0.75rem 1.5rem;
background: #f5f5f5;
color: #333333;
border: 1px solid #d0d0d0;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.action-btn:hover {
background: #e5e5e5;
border-color: #1a1a1a;
color: #1a1a1a;
}
.action-btn:active {
transform: scale(0.98);
}
/* 侧栏切换按钮 */
.sidebar-toggle {
position: fixed;
left: 0;
bottom: 1rem;
z-index: 11;
}
.content-wrapper.sidebar-pushed .sidebar-toggle {
left: 300px;
}
.toggle-btn {
width: 32px;
height: 48px;
background: #1a1a1a;
color: #ffffff;
border: none;
border-radius: 0 4px 4px 0;
cursor: pointer;
font-size: 0.875rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
}
.toggle-btn:hover {
background: #333333;
}
/* 提示消息样式 */
.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,
.toast-content i {
flex-shrink: 0;
}
.toast-notification.success .toast-content i {
color: #10b981;
}
.toast-notification.error .toast-content i {
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) {
.qr-card {
padding: 1.5rem;
}
.sidebar {
width: 280px;
}
.content-wrapper.sidebar-pushed {
margin-left: 0;
}
.sidebar-toggle {
bottom: 0.5rem;
}
.toggle-btn {
width: 28px;
height: 40px;
font-size: 0.75rem;
}
.toast-notification {
right: 10px;
left: 10px;
max-width: none;
top: 60px;
}
}
</style>

View File

@@ -0,0 +1,886 @@
<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">{{ t('timestamp.dateToTs', { tz: 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="t('timestamp.selectDateTime')">
<i class="far fa-calendar"></i>
</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="t('common.copy')">
<i class="far fa-copy"></i>
</button>
</div>
</div>
<!-- 时间戳转换为日期 -->
<div class="conversion-row">
<div class="conversion-label">{{ t('timestamp.tsToDate', { tz: timezoneLabel }) }}</div>
<div class="conversion-inputs">
<input
v-model="timestampInput"
@input="convertTimestampToDate"
type="text"
:placeholder="t('timestamp.placeholderTs')"
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="t('common.copy')">
<i class="far fa-copy"></i>
</button>
</div>
</div>
<!-- 当前时间戳显示与控制 -->
<div class="current-timestamp-row">
<div class="conversion-label">{{ t('timestamp.currentTs') }}</div>
<div class="current-timestamp-controls">
<span class="current-timestamp-value">{{ currentTimestampDisplay }}</span>
<button @click="togglePause" class="control-btn-icon" :title="isPaused ? t('timestamp.resume') : t('timestamp.pause')">
<i :class="isPaused ? 'fas fa-play' : 'fas fa-pause'"></i>
</button>
<button @click="resetData" class="control-btn-icon" :title="t('timestamp.resetData')">
<i class="fas fa-rotate-right"></i>
</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">
<i v-if="toastType === 'error'" class="fas fa-circle-exclamation"></i>
<i v-else class="fas fa-circle-check"></i>
<span>{{ toastMessage }}</span>
</div>
<button @click="closeToast" class="toast-close-btn" :title="t('common.close')">
<i class="fas fa-xmark"></i>
</button>
</div>
</Transition>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import DateTimePicker from '@/components/DateTimePicker.vue'
const { t, tm } = useI18n()
// 精度选项
const precisionOptions = computed(() => [
{ value: 'seconds', label: t('timestamp.seconds') },
{ value: 'milliseconds', label: t('timestamp.milliseconds') },
{ value: 'nanoseconds', label: t('timestamp.nanoseconds') },
])
const TIMEZONE_VALUES = [
'UTC-12:00', 'UTC-11:00', 'UTC-10:00', 'UTC-09:30', 'UTC-09:00', 'UTC-08:00', 'UTC-07:00', 'UTC-06:00',
'UTC-05:00', 'UTC-04:00', 'UTC-03:30', 'UTC-03:00', 'UTC-02:00', 'UTC-01:00', 'UTC+00:00', 'UTC+01:00',
'UTC+02:00', 'UTC+03:00', 'UTC+03:30', 'UTC+04:00', 'UTC+04:30', 'UTC+05:00', 'UTC+05:30', 'UTC+05:45',
'UTC+06:00', 'UTC+06:30', 'UTC+07:00', 'UTC+08:00', 'UTC+08:45', 'UTC+09:00', 'UTC+09:30', 'UTC+10:00',
'UTC+10:30', 'UTC+11:00', 'UTC+12:00', 'UTC+12:45', 'UTC+13:00', 'UTC+14:00',
]
const timezoneOptions = computed(() => {
const tzNames = tm('timestampTz') || {}
return TIMEZONE_VALUES.map(value => ({
value,
label: `${value} | ${tzNames[value] || value}`,
}))
})
// 当前时间相关
const currentTime = ref(new Date())
let timeInterval = null
const isPaused = ref(false)
// 时区相关
const timezone = ref('UTC+08:00')
const timezoneLabel = computed(() => {
const tz = timezoneOptions.value.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 t('timestamp.datePlaceholderSeconds')
if (timestampType.value === 'milliseconds') return t('timestamp.datePlaceholderMs')
return t('timestamp.datePlaceholderNs')
}
// 更新时间
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(t('timestamp.dataReset'), '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(t('timestamp.invalidNs'), 'error')
return
}
} else {
const timestamp = parseInt(timestampStr)
if (isNaN(timestamp)) {
dateStringOutput.value = ''
showToast(t('timestamp.invalidNumber'), '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(t('timestamp.invalidTs'), '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(t('timestamp.convertFailed') + 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(t('timestamp.invalidDateFormat'), '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(t('timestamp.convertFailed') + error.message, 'error')
}
}
// 处理日期时间选择器确认
const handleDateTimeConfirm = (value) => {
dateStringInput.value = value
convertDateToTimestamp()
}
// 复制到剪贴板
const copyToClipboard = async (text) => {
if (!text) {
showToast(t('timestamp.noContentToCopy'), 'error')
return
}
try {
await navigator.clipboard.writeText(text)
showToast(t('common.copied'), '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(t('common.copied'), 'success')
} catch (err) {
showToast(t('timestamp.copyFailed'), '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: 1rem;
}
.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);
}
.control-btn-icon {
padding: 0.375rem;
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;
width: 2rem;
height: 2rem;
font-size: 0.875rem;
}
.control-btn-icon:hover {
background: #f5f5f5;
border-color: #1a1a1a;
color: #1a1a1a;
}
.control-btn-icon: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,
.toast-content i {
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: row;
align-items: center;
justify-content: flex-start;
}
.current-timestamp-value {
text-align: left;
}
.control-btn-icon {
width: 2rem;
height: 2rem;
}
.toast-notification {
right: 10px;
left: 10px;
max-width: none;
}
}
</style>

View File

@@ -0,0 +1,536 @@
<template>
<div class="variable-name-converter">
<!-- 浮层提示 -->
<Transition name="toast">
<div v-if="toastMessage" class="toast-notification" :class="toastType">
<div class="toast-content">
<i v-if="toastType === 'error'" class="fas fa-circle-exclamation"></i>
<i v-else class="fas fa-circle-check"></i>
<span>{{ toastMessage }}</span>
</div>
<button @click="closeToast" class="toast-close-btn" :title="t('common.close')">
<i class="fas fa-xmark"></i>
</button>
</div>
</Transition>
<div class="container">
<div class="conversion-card">
<!-- 输入区域 -->
<div class="input-section">
<div class="input-wrapper">
<input
v-model="inputText"
@input="convertVariableName"
type="text"
:placeholder="t('variable.placeholder')"
class="input-field"
/>
<button @click="clearInput" class="clear-btn" :title="t('common.clear')">
<i class="fas fa-xmark"></i>
</button>
</div>
</div>
<!-- 输出区域 -->
<div class="output-section">
<div class="output-row">
<div
v-for="format in formatList.slice(0, 3)"
:key="format.key"
class="output-item"
>
<div class="output-header">
<span class="output-label">{{ format.label }}</span>
<button
@click="copyToClipboard(format.value, format.label)"
class="copy-btn"
:title="t('variable.copyLabel', { label: format.label })"
>
<i class="far fa-copy"></i>
</button>
</div>
<div class="output-value" :class="{ empty: !format.value }">
{{ format.value || t('variable.empty') }}
</div>
</div>
</div>
<div class="output-row">
<div
v-for="format in formatList.slice(3)"
:key="format.key"
class="output-item"
>
<div class="output-header">
<span class="output-label">{{ format.label }}</span>
<button
@click="copyToClipboard(format.value, format.label)"
class="copy-btn"
:title="t('variable.copyLabel', { label: format.label })"
>
<i class="far fa-copy"></i>
</button>
</div>
<div class="output-value" :class="{ empty: !format.value }">
{{ format.value || t('variable.empty') }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const inputText = ref('')
const toastMessage = ref('')
const toastType = ref('success')
let toastTimer = null
const formatKeys = [
{ key: 'camelCase', labelKey: 'variable.camelCase' },
{ key: 'PascalCase', labelKey: 'variable.pascalCase' },
{ key: 'snake_case', labelKey: 'variable.snakeCase' },
{ key: 'kebab-case', labelKey: 'variable.kebabCase' },
{ key: 'CONSTANT_CASE', labelKey: 'variable.constantCase' },
]
const formatValues = ref({
camelCase: '',
PascalCase: '',
snake_case: '',
'kebab-case': '',
CONSTANT_CASE: '',
})
const formatList = computed(() =>
formatKeys.map(({ key, labelKey }) => ({
key,
label: t(labelKey),
value: formatValues.value[key],
}))
)
// 显示提示
const showToast = (message, type = 'success', duration = 3000) => {
toastMessage.value = message
toastType.value = type
if (toastTimer) {
clearTimeout(toastTimer)
}
toastTimer = setTimeout(() => {
toastMessage.value = ''
}, duration)
}
// 关闭提示
const closeToast = () => {
if (toastTimer) {
clearTimeout(toastTimer)
toastTimer = null
}
toastMessage.value = ''
}
// 将输入文本解析为单词数组
const parseToWords = (text) => {
if (!text || !text.trim()) {
return []
}
let processed = text.trim()
// 处理各种分隔符:空格、下划线、横线、驼峰
// 1. 先处理连续大写字母的情况XMLHttpRequest -> XML Http Request
processed = processed.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
// 2. 先处理数字和字母的边界(必须在驼峰处理之前)
// 2.1 字母+数字+字母temp2Detail -> temp 2 Detail
processed = processed.replace(/([a-zA-Z])(\d+)([a-zA-Z])/g, '$1 $2 $3')
// 2.2 字母+数字后面跟着分隔符或结尾但不是字母item2 -> item 2
// 注意这里不匹配后面跟着字母的情况已由2.1处理)
processed = processed.replace(/([a-zA-Z])(\d+)(?=[_\-\s]|$)/g, '$1 $2')
// 2.3 数字+字母在单词开头或前面是分隔符2item -> 2 item
processed = processed.replace(/(\d+)([a-zA-Z])/g, '$1 $2')
// 3. 处理驼峰camelCase -> camel Case在数字处理之后
processed = processed.replace(/([a-z])([A-Z])/g, '$1 $2')
// 4. 统一分隔符:下划线、横线、空格统一为空格
processed = processed.replace(/[_\-\s]+/g, ' ')
// 5. 分割并处理
let words = processed
.split(' ')
.filter(word => word.length > 0)
.map(word => {
// 转换为小写,保留字母和数字
return word.toLowerCase()
})
.filter(word => word.length > 0) // 允许纯数字
return words
}
// 转换单词首字母为大写(处理数字情况)
const capitalizeWord = (word) => {
if (!word) return ''
// 如果单词是纯数字,直接返回
if (/^\d+$/.test(word)) return word
// 否则首字母大写
return word.charAt(0).toUpperCase() + word.slice(1)
}
// 转换为小驼峰 (camelCase)
const toCamelCase = (words) => {
if (words.length === 0) return ''
const firstWord = words[0]
const restWords = words.slice(1).map(word => capitalizeWord(word))
return firstWord + restWords.join('')
}
// 转换为大驼峰 (PascalCase)
const toPascalCase = (words) => {
if (words.length === 0) return ''
return words.map(word => capitalizeWord(word)).join('')
}
// 转换为下划线 (snake_case)
const toSnakeCase = (words) => {
if (words.length === 0) return ''
return words.join('_')
}
// 转换为横线 (kebab-case)
const toKebabCase = (words) => {
if (words.length === 0) return ''
return words.join('-')
}
// 转换为常量 (CONSTANT_CASE)
const toConstantCase = (words) => {
if (words.length === 0) return ''
return words.map(word => word.toUpperCase()).join('_')
}
// 转换变量名
const convertVariableName = () => {
const words = parseToWords(inputText.value)
if (words.length === 0) {
formatValues.value = {
camelCase: '',
PascalCase: '',
snake_case: '',
'kebab-case': '',
CONSTANT_CASE: '',
}
return
}
formatValues.value = {
camelCase: toCamelCase(words),
PascalCase: toPascalCase(words),
snake_case: toSnakeCase(words),
'kebab-case': toKebabCase(words),
CONSTANT_CASE: toConstantCase(words),
}
}
// 清空输入
const clearInput = () => {
inputText.value = ''
convertVariableName()
}
// 复制到剪贴板
const copyToClipboard = async (text, label) => {
if (!text || text === t('variable.empty')) {
showToast(t('variable.noContentToCopy'), 'error')
return
}
try {
await navigator.clipboard.writeText(text)
showToast(t('variable.copiedLabel', { label }), 'success', 2000)
} catch (error) {
showToast(t('common.copyFailed') + error.message, 'error')
}
}
</script>
<style scoped>
.variable-name-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: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid #e5e5e5;
}
.input-section {
margin-bottom: 2rem;
}
.input-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #333333;
margin-bottom: 0.5rem;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.input-field {
flex: 1;
padding: 0.75rem;
padding-right: 2.5rem;
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);
}
.clear-btn {
position: absolute;
right: 0.5rem;
padding: 0.375rem;
background: transparent;
border: none;
border-radius: 4px;
color: #666666;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
width: 24px;
height: 24px;
}
.clear-btn:hover {
background: #f5f5f5;
color: #1a1a1a;
}
.output-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.output-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.output-item {
border: 1px solid #e5e5e5;
border-radius: 6px;
padding: 1rem;
background: #fafafa;
transition: all 0.2s;
display: flex;
flex-direction: column;
}
.output-item:hover {
border-color: #d0d0d0;
background: #ffffff;
}
.output-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.output-label {
font-size: 0.875rem;
font-weight: 500;
color: #333333;
}
.copy-btn {
padding: 0.375rem;
background: transparent;
border: 1px solid #d0d0d0;
border-radius: 4px;
color: #666666;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
width: 28px;
height: 28px;
}
.copy-btn:hover {
background: #f5f5f5;
border-color: #1a1a1a;
color: #1a1a1a;
}
.copy-btn:active {
transform: scale(0.98);
}
.copy-btn i {
font-size: 0.875rem;
}
.output-value {
font-family: 'Courier New', monospace;
font-size: 1rem;
color: #1a1a1a;
word-break: break-all;
min-height: 1.5rem;
padding: 0.5rem 0;
}
.output-value.empty {
color: #999999;
}
/* Toast通知样式 */
.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 i {
flex-shrink: 0;
}
.toast-notification.success .toast-content i {
color: #10b981;
}
.toast-notification.error .toast-content i {
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) {
.variable-name-converter {
padding: 1rem 0.5rem;
}
.conversion-card {
padding: 1.5rem;
}
.output-row {
grid-template-columns: 1fr;
}
.toast-notification {
right: 10px;
left: 10px;
max-width: none;
top: 60px;
}
}
</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
}
})