Compare commits
3 Commits
c6d41d18b3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ada0dfa3cc | ||
|
|
2a07fd950f | ||
| 8e5eea02f1 |
111
.gitignore
vendored
111
.gitignore
vendored
@@ -1,90 +1,27 @@
|
|||||||
# ---> Vue
|
# Logs
|
||||||
# gitignore template for Vue.js projects
|
logs
|
||||||
#
|
*.log
|
||||||
# Recommended template: Node.gitignore
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
# TODO: where does this rule come from?
|
node_modules
|
||||||
docs/_book
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
# TODO: where does this rule come from?
|
# Editor directories and files
|
||||||
test/
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
# ---> JetBrains
|
.idea
|
||||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
.DS_Store
|
||||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
# User-specific stuff
|
*.njsproj
|
||||||
.idea/**/workspace.xml
|
*.sln
|
||||||
.idea/**/tasks.xml
|
*.sw?
|
||||||
.idea/**/usage.statistics.xml
|
|
||||||
.idea/**/dictionaries
|
|
||||||
.idea/**/shelf
|
|
||||||
|
|
||||||
# AWS User-specific
|
|
||||||
.idea/**/aws.xml
|
|
||||||
|
|
||||||
# Generated files
|
|
||||||
.idea/**/contentModel.xml
|
|
||||||
|
|
||||||
# Sensitive or high-churn files
|
|
||||||
.idea/**/dataSources/
|
|
||||||
.idea/**/dataSources.ids
|
|
||||||
.idea/**/dataSources.local.xml
|
|
||||||
.idea/**/sqlDataSources.xml
|
|
||||||
.idea/**/dynamic.xml
|
|
||||||
.idea/**/uiDesigner.xml
|
|
||||||
.idea/**/dbnavigator.xml
|
|
||||||
|
|
||||||
# Gradle
|
|
||||||
.idea/**/gradle.xml
|
|
||||||
.idea/**/libraries
|
|
||||||
|
|
||||||
# Gradle and Maven with auto-import
|
|
||||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
|
||||||
# since they will be recreated, and may cause churn. Uncomment if using
|
|
||||||
# auto-import.
|
|
||||||
# .idea/artifacts
|
|
||||||
# .idea/compiler.xml
|
|
||||||
# .idea/jarRepositories.xml
|
|
||||||
# .idea/modules.xml
|
|
||||||
# .idea/*.iml
|
|
||||||
# .idea/modules
|
|
||||||
# *.iml
|
|
||||||
# *.ipr
|
|
||||||
|
|
||||||
# CMake
|
|
||||||
cmake-build-*/
|
|
||||||
|
|
||||||
# Mongo Explorer plugin
|
|
||||||
.idea/**/mongoSettings.xml
|
|
||||||
|
|
||||||
# File-based project format
|
|
||||||
*.iws
|
|
||||||
|
|
||||||
# IntelliJ
|
|
||||||
out/
|
|
||||||
|
|
||||||
# mpeltonen/sbt-idea plugin
|
|
||||||
.idea_modules/
|
|
||||||
|
|
||||||
# JIRA plugin
|
|
||||||
atlassian-ide-plugin.xml
|
|
||||||
|
|
||||||
# Cursive Clojure plugin
|
|
||||||
.idea/replstate.xml
|
|
||||||
|
|
||||||
# SonarLint plugin
|
|
||||||
.idea/sonarlint/
|
|
||||||
|
|
||||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
|
||||||
com_crashlytics_export_strings.xml
|
|
||||||
crashlytics.properties
|
|
||||||
crashlytics-build.properties
|
|
||||||
fabric.properties
|
|
||||||
|
|
||||||
# Editor-based Rest Client
|
|
||||||
.idea/httpRequests
|
|
||||||
|
|
||||||
# Android studio 3.1+ serialized cache file
|
|
||||||
.idea/caches/build_file_checksums.ser
|
|
||||||
|
|
||||||
|
.cursor
|
||||||
|
/package-lock.json
|
||||||
|
|||||||
98
README.md
98
README.md
@@ -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
66
deploy.sh
Executable 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
22
index.html
Normal 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>
|
||||||
|
|
||||||
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"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-router": "^4.2.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
144
src/App.vue
Normal file
144
src/App.vue
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="nav-content">
|
||||||
|
<router-link to="/" class="logo">
|
||||||
|
<h1>RC707的工具箱</h1>
|
||||||
|
</router-link>
|
||||||
|
<div class="nav-links">
|
||||||
|
<router-link to="/" class="nav-link">首页</router-link>
|
||||||
|
<router-link to="/json-formatter" class="nav-link">JSON</router-link>
|
||||||
|
<router-link to="/comparator" class="nav-link">对比</router-link>
|
||||||
|
<router-link to="/encoder-decoder" class="nav-link">编解码</router-link>
|
||||||
|
<router-link to="/variable-name" class="nav-link">变量名</router-link>
|
||||||
|
<router-link to="/qr-code" class="nav-link">二维码</router-link>
|
||||||
|
<router-link to="/timestamp-converter" class="nav-link">时间戳</router-link>
|
||||||
|
<router-link to="/color-converter" class="nav-link">颜色</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="main-content">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// Vue 3 Composition API
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
background: #ffffff;
|
||||||
|
border-bottom: 1px solid #e5e5e5;
|
||||||
|
padding: 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-content {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #1a1a1a;
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
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-active {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-bottom-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.nav-content {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
padding-left: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h1 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
818
src/components/DateTimePicker.vue
Normal file
818
src/components/DateTimePicker.vue
Normal file
@@ -0,0 +1,818 @@
|
|||||||
|
<template>
|
||||||
|
<div class="datetime-picker-wrapper">
|
||||||
|
<!-- 自定义日期时间选择器面板 -->
|
||||||
|
<Transition name="picker">
|
||||||
|
<div v-if="show" class="datetime-picker-panel" @click.stop>
|
||||||
|
<div class="picker-container">
|
||||||
|
<!-- 左侧日期选择区域 -->
|
||||||
|
<div class="date-picker-section">
|
||||||
|
<!-- 年月导航 -->
|
||||||
|
<div class="date-header">
|
||||||
|
<div class="nav-buttons">
|
||||||
|
<button @click="prevYear" class="nav-btn" title="上一年">«</button>
|
||||||
|
<button @click="prevMonth" class="nav-btn" title="上一月">‹</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!isEditingMonthYear"
|
||||||
|
@click="startEditingMonthYear"
|
||||||
|
class="current-month-year editable"
|
||||||
|
title="点击输入年月"
|
||||||
|
>
|
||||||
|
{{ currentViewDate.getFullYear() }} - {{ String(currentViewDate.getMonth() + 1).padStart(2, '0') }}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
v-model="monthYearInput"
|
||||||
|
@blur="confirmMonthYear"
|
||||||
|
@keyup.enter="confirmMonthYear"
|
||||||
|
@keyup.esc="cancelEditingMonthYear"
|
||||||
|
class="month-year-input"
|
||||||
|
placeholder="YYYY-MM"
|
||||||
|
ref="monthYearInputRef"
|
||||||
|
/>
|
||||||
|
<div class="nav-buttons">
|
||||||
|
<button @click="nextMonth" class="nav-btn" title="下一月">›</button>
|
||||||
|
<button @click="nextYear" class="nav-btn" title="下一年">»</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 星期标题 -->
|
||||||
|
<div class="weekdays">
|
||||||
|
<div class="weekday" v-for="day in ['日', '一', '二', '三', '四', '五', '六']" :key="day">
|
||||||
|
{{ day }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 日期网格 -->
|
||||||
|
<div class="calendar-grid">
|
||||||
|
<div
|
||||||
|
v-for="(day, index) in getCalendarDays()"
|
||||||
|
:key="index"
|
||||||
|
@click="selectDate(day)"
|
||||||
|
:class="[
|
||||||
|
'calendar-day',
|
||||||
|
{ 'other-month': !day.isCurrentMonth },
|
||||||
|
{ 'today': isToday(day) },
|
||||||
|
{ 'selected': isSelected(day) }
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ day.date.getDate() }}
|
||||||
|
<span v-if="isToday(day) && !isSelected(day)" class="today-dot"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 此刻按钮 -->
|
||||||
|
<button @click="selectNow" class="now-btn">此刻</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧时间选择区域 -->
|
||||||
|
<div class="time-picker-section">
|
||||||
|
<div class="time-header">选择时间</div>
|
||||||
|
|
||||||
|
<div class="time-selectors">
|
||||||
|
<!-- 小时选择 -->
|
||||||
|
<div class="time-column">
|
||||||
|
<div class="time-list" ref="hourListRef">
|
||||||
|
<div
|
||||||
|
v-for="hour in generateTimeOptions('hour')"
|
||||||
|
:key="hour"
|
||||||
|
:data-value="hour"
|
||||||
|
@click="selectTime('hour', hour)"
|
||||||
|
:class="['time-item', { 'selected': selectedTime.hour === parseInt(hour) }]"
|
||||||
|
>
|
||||||
|
{{ hour }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分钟选择 -->
|
||||||
|
<div class="time-column">
|
||||||
|
<div class="time-list" ref="minuteListRef">
|
||||||
|
<div
|
||||||
|
v-for="minute in generateTimeOptions('minute')"
|
||||||
|
:key="minute"
|
||||||
|
:data-value="minute"
|
||||||
|
@click="selectTime('minute', minute)"
|
||||||
|
:class="['time-item', { 'selected': selectedTime.minute === parseInt(minute) }]"
|
||||||
|
>
|
||||||
|
{{ minute }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 秒选择 -->
|
||||||
|
<div class="time-column">
|
||||||
|
<div class="time-list" ref="secondListRef">
|
||||||
|
<div
|
||||||
|
v-for="second in generateTimeOptions('second')"
|
||||||
|
:key="second"
|
||||||
|
:data-value="second"
|
||||||
|
@click="selectTime('second', second)"
|
||||||
|
:class="['time-item', { 'selected': selectedTime.second === parseInt(second) }]"
|
||||||
|
>
|
||||||
|
{{ second }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 确定按钮 -->
|
||||||
|
<button @click="confirmSelection" class="confirm-btn">确定</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- 遮罩层 -->
|
||||||
|
<Transition name="mask">
|
||||||
|
<div v-if="show" class="picker-mask" @click="close"></div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
precisionType: {
|
||||||
|
type: String,
|
||||||
|
default: 'milliseconds', // seconds, milliseconds, nanoseconds
|
||||||
|
validator: (value) => ['seconds', 'milliseconds', 'nanoseconds'].includes(value)
|
||||||
|
},
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'update:show', 'confirm'])
|
||||||
|
|
||||||
|
// 日期时间选择器状态
|
||||||
|
const currentViewDate = ref(new Date()) // 当前查看的年月
|
||||||
|
const selectedDate = ref(null) // 选中的日期
|
||||||
|
const selectedTime = ref({ hour: 0, minute: 0, second: 0 }) // 选中的时间
|
||||||
|
const hourListRef = ref(null)
|
||||||
|
const minuteListRef = ref(null)
|
||||||
|
const secondListRef = ref(null)
|
||||||
|
const isEditingMonthYear = ref(false) // 是否正在编辑年月
|
||||||
|
const monthYearInput = ref('') // 年月输入值
|
||||||
|
const monthYearInputRef = ref(null) // 年月输入框引用
|
||||||
|
|
||||||
|
// 日期选择功能
|
||||||
|
const getCalendarDays = () => {
|
||||||
|
const year = currentViewDate.value.getFullYear()
|
||||||
|
const month = currentViewDate.value.getMonth()
|
||||||
|
|
||||||
|
// 获取当月第一天和最后一天
|
||||||
|
const firstDay = new Date(year, month, 1)
|
||||||
|
const lastDay = new Date(year, month + 1, 0)
|
||||||
|
|
||||||
|
// 获取第一天是星期几(0=周日)
|
||||||
|
const firstDayWeek = firstDay.getDay()
|
||||||
|
|
||||||
|
// 获取上个月的最后几天
|
||||||
|
const prevMonthLastDay = new Date(year, month, 0).getDate()
|
||||||
|
|
||||||
|
const days = []
|
||||||
|
|
||||||
|
// 添加上个月的日期
|
||||||
|
for (let i = firstDayWeek - 1; i >= 0; i--) {
|
||||||
|
days.push({
|
||||||
|
date: new Date(year, month - 1, prevMonthLastDay - i),
|
||||||
|
isCurrentMonth: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加当月的日期
|
||||||
|
for (let i = 1; i <= lastDay.getDate(); i++) {
|
||||||
|
days.push({
|
||||||
|
date: new Date(year, month, i),
|
||||||
|
isCurrentMonth: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加下个月的日期,补齐到42个(6行7列)
|
||||||
|
const remainingDays = 42 - days.length
|
||||||
|
for (let i = 1; i <= remainingDays; i++) {
|
||||||
|
days.push({
|
||||||
|
date: new Date(year, month + 1, i),
|
||||||
|
isCurrentMonth: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevYear = () => {
|
||||||
|
const date = new Date(currentViewDate.value)
|
||||||
|
date.setFullYear(date.getFullYear() - 1)
|
||||||
|
currentViewDate.value = date
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextYear = () => {
|
||||||
|
const date = new Date(currentViewDate.value)
|
||||||
|
date.setFullYear(date.getFullYear() + 1)
|
||||||
|
currentViewDate.value = date
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevMonth = () => {
|
||||||
|
const date = new Date(currentViewDate.value)
|
||||||
|
date.setMonth(date.getMonth() - 1)
|
||||||
|
currentViewDate.value = date
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMonth = () => {
|
||||||
|
const date = new Date(currentViewDate.value)
|
||||||
|
date.setMonth(date.getMonth() + 1)
|
||||||
|
currentViewDate.value = date
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectDate = (day) => {
|
||||||
|
selectedDate.value = new Date(day.date)
|
||||||
|
// 滚动到选中的时间项
|
||||||
|
nextTick(() => {
|
||||||
|
scrollToSelected()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isToday = (day) => {
|
||||||
|
const today = new Date()
|
||||||
|
return day.date.getDate() === today.getDate() &&
|
||||||
|
day.date.getMonth() === today.getMonth() &&
|
||||||
|
day.date.getFullYear() === today.getFullYear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelected = (day) => {
|
||||||
|
if (!selectedDate.value) return false
|
||||||
|
return day.date.getDate() === selectedDate.value.getDate() &&
|
||||||
|
day.date.getMonth() === selectedDate.value.getMonth() &&
|
||||||
|
day.date.getFullYear() === selectedDate.value.getFullYear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间选择功能
|
||||||
|
const generateTimeOptions = (type) => {
|
||||||
|
if (type === 'hour') {
|
||||||
|
return Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
|
||||||
|
} else {
|
||||||
|
return Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectTime = (type, value) => {
|
||||||
|
selectedTime.value[type] = parseInt(value)
|
||||||
|
// 滚动到选中的项
|
||||||
|
nextTick(() => {
|
||||||
|
scrollToSelected()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToSelected = () => {
|
||||||
|
// 滚动小时列表
|
||||||
|
if (hourListRef.value) {
|
||||||
|
const hourItem = hourListRef.value.querySelector(`[data-value="${String(selectedTime.value.hour).padStart(2, '0')}"]`)
|
||||||
|
if (hourItem) {
|
||||||
|
hourItem.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动分钟列表
|
||||||
|
if (minuteListRef.value) {
|
||||||
|
const minuteItem = minuteListRef.value.querySelector(`[data-value="${String(selectedTime.value.minute).padStart(2, '0')}"]`)
|
||||||
|
if (minuteItem) {
|
||||||
|
minuteItem.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动秒列表
|
||||||
|
if (secondListRef.value) {
|
||||||
|
const secondItem = secondListRef.value.querySelector(`[data-value="${String(selectedTime.value.second).padStart(2, '0')}"]`)
|
||||||
|
if (secondItem) {
|
||||||
|
secondItem.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 面板控制功能
|
||||||
|
const close = () => {
|
||||||
|
emit('update:show', false)
|
||||||
|
isEditingMonthYear.value = false
|
||||||
|
monthYearInput.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmSelection = () => {
|
||||||
|
if (!selectedDate.value) {
|
||||||
|
selectedDate.value = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(selectedDate.value)
|
||||||
|
date.setHours(selectedTime.value.hour)
|
||||||
|
date.setMinutes(selectedTime.value.minute)
|
||||||
|
date.setSeconds(selectedTime.value.second)
|
||||||
|
date.setMilliseconds(0)
|
||||||
|
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||||
|
const milliseconds = String(date.getMilliseconds()).padStart(3, '0')
|
||||||
|
|
||||||
|
// 根据精度类型设置格式
|
||||||
|
let formattedDate = ''
|
||||||
|
if (props.precisionType === 'seconds') {
|
||||||
|
formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||||
|
} else if (props.precisionType === 'milliseconds') {
|
||||||
|
formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`
|
||||||
|
} else {
|
||||||
|
// 纳秒级
|
||||||
|
const nanosecondsStr = '000000'
|
||||||
|
formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}${nanosecondsStr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('update:modelValue', formattedDate)
|
||||||
|
emit('confirm', formattedDate)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectNow = () => {
|
||||||
|
const now = new Date()
|
||||||
|
selectedDate.value = new Date(now)
|
||||||
|
currentViewDate.value = new Date(now)
|
||||||
|
selectedTime.value = {
|
||||||
|
hour: now.getHours(),
|
||||||
|
minute: now.getMinutes(),
|
||||||
|
second: now.getSeconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
scrollToSelected()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始编辑年月
|
||||||
|
const startEditingMonthYear = () => {
|
||||||
|
isEditingMonthYear.value = true
|
||||||
|
const year = currentViewDate.value.getFullYear()
|
||||||
|
const month = String(currentViewDate.value.getMonth() + 1).padStart(2, '0')
|
||||||
|
monthYearInput.value = `${year}-${month}`
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
if (monthYearInputRef.value) {
|
||||||
|
monthYearInputRef.value.focus()
|
||||||
|
monthYearInputRef.value.select()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认年月输入
|
||||||
|
const confirmMonthYear = () => {
|
||||||
|
const input = monthYearInput.value.trim()
|
||||||
|
|
||||||
|
// 验证格式:YYYY-MM 或 YYYY-M
|
||||||
|
const match = input.match(/^(\d{4})-(\d{1,2})$/)
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const year = parseInt(match[1])
|
||||||
|
const month = parseInt(match[2])
|
||||||
|
|
||||||
|
// 验证年份和月份范围
|
||||||
|
if (year >= 1000 && year <= 9999 && month >= 1 && month <= 12) {
|
||||||
|
const date = new Date(year, month - 1, 1)
|
||||||
|
currentViewDate.value = date
|
||||||
|
isEditingMonthYear.value = false
|
||||||
|
monthYearInput.value = ''
|
||||||
|
} else {
|
||||||
|
// 这里可以添加错误提示,但组件中不依赖父组件的 showToast
|
||||||
|
if (monthYearInputRef.value) {
|
||||||
|
monthYearInputRef.value.focus()
|
||||||
|
monthYearInputRef.value.select()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (monthYearInputRef.value) {
|
||||||
|
monthYearInputRef.value.focus()
|
||||||
|
monthYearInputRef.value.select()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消编辑年月
|
||||||
|
const cancelEditingMonthYear = () => {
|
||||||
|
isEditingMonthYear.value = false
|
||||||
|
monthYearInput.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听 show 变化,初始化选择器
|
||||||
|
watch(() => props.show, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
// 如果输入框有值,尝试解析并设置到选择器
|
||||||
|
if (props.modelValue) {
|
||||||
|
try {
|
||||||
|
const dateStr = props.modelValue.trim()
|
||||||
|
let date = null
|
||||||
|
if (dateStr.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d{1,3})?$/)) {
|
||||||
|
date = new Date(dateStr.replace(' ', 'T'))
|
||||||
|
} else if (dateStr.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?/)) {
|
||||||
|
date = new Date(dateStr)
|
||||||
|
} else {
|
||||||
|
date = new Date(dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
selectedDate.value = new Date(date)
|
||||||
|
currentViewDate.value = new Date(date)
|
||||||
|
selectedTime.value = {
|
||||||
|
hour: date.getHours(),
|
||||||
|
minute: date.getMinutes(),
|
||||||
|
second: date.getSeconds()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 如果解析失败,使用当前时间
|
||||||
|
const now = new Date()
|
||||||
|
selectedDate.value = new Date(now)
|
||||||
|
currentViewDate.value = new Date(now)
|
||||||
|
selectedTime.value = {
|
||||||
|
hour: now.getHours(),
|
||||||
|
minute: now.getMinutes(),
|
||||||
|
second: now.getSeconds()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果输入框为空,使用当前时间
|
||||||
|
const now = new Date()
|
||||||
|
selectedDate.value = new Date(now)
|
||||||
|
currentViewDate.value = new Date(now)
|
||||||
|
selectedTime.value = {
|
||||||
|
hour: now.getHours(),
|
||||||
|
minute: now.getMinutes(),
|
||||||
|
second: now.getSeconds()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待DOM更新后滚动到选中项
|
||||||
|
nextTick(() => {
|
||||||
|
scrollToSelected()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
isEditingMonthYear.value = false
|
||||||
|
monthYearInput.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.datetime-picker-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.5rem);
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 日期时间选择器面板 */
|
||||||
|
.picker-mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 998;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datetime-picker-panel {
|
||||||
|
position: relative;
|
||||||
|
z-index: 999;
|
||||||
|
width: 500px;
|
||||||
|
min-width: 500px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
border: 1px solid #e5e5e5;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-container {
|
||||||
|
display: flex;
|
||||||
|
min-height: 320px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧日期选择区域 */
|
||||||
|
.date-picker-section {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #d0d0d0;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #333333;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-month-year {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-month-year.editable {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-month-year.editable:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-year-input {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333333;
|
||||||
|
border: 1px solid #1a1a1a;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
outline: none;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-year-input:focus {
|
||||||
|
border-color: #1a1a1a;
|
||||||
|
box-shadow: 0 0 0 2px rgba(26, 26, 26, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekdays {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday {
|
||||||
|
width: 28px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: #666666;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #333333;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.other-month {
|
||||||
|
color: #999999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.today {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.selected {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-dot {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.selected .today-dot {
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-btn {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d0d0d0;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #666666;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-btn:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-color: #1a1a1a;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧时间选择区域 */
|
||||||
|
.time-picker-section {
|
||||||
|
flex: 0 0 160px;
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-header {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333333;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-selectors {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-column {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
max-height: 240px;
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-list::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome, Safari, Opera */
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-item {
|
||||||
|
padding: 0.25rem 0.375rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #333333;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin: 0.0625rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-item:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-item.selected {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn:hover {
|
||||||
|
background: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 过渡动画 */
|
||||||
|
.picker-enter-active,
|
||||||
|
.picker-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask-enter-active,
|
||||||
|
.mask-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask-enter-from,
|
||||||
|
.mask-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.datetime-picker-panel {
|
||||||
|
width: calc(100vw - 2rem);
|
||||||
|
min-width: calc(100vw - 2rem);
|
||||||
|
max-width: calc(100vw - 2rem);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-container {
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-picker-section {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-picker-section {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
min-height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-selectors {
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
396
src/components/JsonTreeNode.vue
Normal file
396
src/components/JsonTreeNode.vue
Normal 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>
|
||||||
9
src/main.js
Normal file
9
src/main.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './style.css'
|
||||||
|
// 引入 Font Awesome 6.4
|
||||||
|
import '@fortawesome/fontawesome-free/css/all.css'
|
||||||
|
|
||||||
|
createApp(App).use(router).mount('#app')
|
||||||
|
|
||||||
85
src/router/index.js
Normal file
85
src/router/index.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import Home from '../views/Home.vue'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Home',
|
||||||
|
component: Home,
|
||||||
|
meta: {
|
||||||
|
title: 'RC707的工具箱'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/json-formatter',
|
||||||
|
name: 'JsonFormatter',
|
||||||
|
component: () => import('../views/JsonFormatter.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'RC707的工具箱-JSON'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/comparator',
|
||||||
|
name: 'Comparator',
|
||||||
|
component: () => import('../views/Comparator.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'RC707的工具箱-对比'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/encoder-decoder',
|
||||||
|
name: 'EncoderDecoder',
|
||||||
|
component: () => import('../views/EncoderDecoder.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'RC707的工具箱-编解码'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/variable-name',
|
||||||
|
name: 'VariableNameConverter',
|
||||||
|
component: () => import('../views/VariableNameConverter.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'RC707的工具箱-变量名'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/qr-code',
|
||||||
|
name: 'QRCodeGenerator',
|
||||||
|
component: () => import('../views/QRCodeGenerator.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'RC707的工具箱-二维码'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/timestamp-converter',
|
||||||
|
name: 'TimestampConverter',
|
||||||
|
component: () => import('../views/TimestampConverter.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'RC707的工具箱-时间戳'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/color-converter',
|
||||||
|
name: 'ColorConverter',
|
||||||
|
component: () => import('../views/ColorConverter.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'RC707的工具箱-颜色'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
// 路由守卫:设置页面标题
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
if (to.meta.title) {
|
||||||
|
document.title = to.meta.title
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
||||||
17
src/style.css
Normal file
17
src/style.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
1482
src/views/ColorConverter.vue
Normal file
1482
src/views/ColorConverter.vue
Normal file
File diff suppressed because it is too large
Load Diff
2875
src/views/Comparator.vue
Normal file
2875
src/views/Comparator.vue
Normal file
File diff suppressed because it is too large
Load Diff
1205
src/views/EncoderDecoder.vue
Normal file
1205
src/views/EncoderDecoder.vue
Normal file
File diff suppressed because it is too large
Load Diff
197
src/views/Home.vue
Normal file
197
src/views/Home.vue
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<template>
|
||||||
|
<div class="home-container">
|
||||||
|
<div class="hero-section">
|
||||||
|
<h2 class="hero-title">
|
||||||
|
今天是{{ `${new Date().getFullYear()}年${new Date().getMonth() + 1}月${new Date().getDate()}日` }}
|
||||||
|
</h2>
|
||||||
|
<p id="jinrishici-sentence" class="hero-subtitle"></p>
|
||||||
|
</div>
|
||||||
|
<div class="tools-grid">
|
||||||
|
<router-link
|
||||||
|
v-for="tool in tools"
|
||||||
|
:key="tool.path"
|
||||||
|
:to="tool.path"
|
||||||
|
class="tool-card"
|
||||||
|
>
|
||||||
|
<h3 class="tool-title">{{ tool.title }}</h3>
|
||||||
|
<p class="tool-description">{{ tool.description }}</p>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="footer-section">
|
||||||
|
<p class="icp-info">苏ICP备2022013040号-1</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {onMounted, ref} from 'vue'
|
||||||
|
|
||||||
|
const tools = ref([
|
||||||
|
{
|
||||||
|
path: '/json-formatter',
|
||||||
|
title: 'JSON',
|
||||||
|
description: '格式化、验证和美化JSON数据'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/comparator',
|
||||||
|
title: '对比',
|
||||||
|
description: '文本和JSON对比工具'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/encoder-decoder',
|
||||||
|
title: '编解码',
|
||||||
|
description: '编码/解码工具'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/variable-name',
|
||||||
|
title: '变量名',
|
||||||
|
description: '变量名格式转换'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/qr-code',
|
||||||
|
title: '二维码',
|
||||||
|
description: '生成二维码'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/timestamp-converter',
|
||||||
|
title: '时间戳',
|
||||||
|
description: '时间戳与时间字符串相互转换'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/color-converter',
|
||||||
|
title: '颜色',
|
||||||
|
description: '颜色格式转换'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
onMounted(() => {
|
||||||
|
let 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>
|
||||||
|
|
||||||
2153
src/views/JsonFormatter.vue
Normal file
2153
src/views/JsonFormatter.vue
Normal file
File diff suppressed because it is too large
Load Diff
700
src/views/QRCodeGenerator.vue
Normal file
700
src/views/QRCodeGenerator.vue
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
<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="关闭">
|
||||||
|
<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>历史记录</h3>
|
||||||
|
<button @click="toggleSidebar" class="close-btn">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<div v-if="historyList.length === 0" class="empty-history">
|
||||||
|
暂无历史记录
|
||||||
|
</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="请输入要生成二维码的内容"
|
||||||
|
class="input-textarea"
|
||||||
|
rows="4"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<button @click="generateQRCode" class="generate-btn">
|
||||||
|
<i class="fas fa-qrcode"></i>
|
||||||
|
生成二维码
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 二维码显示区域 -->
|
||||||
|
<div v-if="qrCodeDataUrl" class="qr-display-section">
|
||||||
|
<div class="qr-code-wrapper">
|
||||||
|
<img :src="qrCodeDataUrl" alt="二维码" class="qr-code-image" />
|
||||||
|
</div>
|
||||||
|
<div class="qr-actions">
|
||||||
|
<button @click="downloadQRCode" class="action-btn">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
下载
|
||||||
|
</button>
|
||||||
|
<button @click="copyQRCodeImage" class="action-btn">
|
||||||
|
<i class="far fa-copy"></i>
|
||||||
|
复制图片
|
||||||
|
</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 QRCode from 'qrcode'
|
||||||
|
|
||||||
|
// 输入文本
|
||||||
|
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('请输入要生成二维码的内容', '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('二维码生成成功', 'success', 2000)
|
||||||
|
} catch (error) {
|
||||||
|
showToast('生成二维码失败:' + error.message, 'error')
|
||||||
|
qrCodeDataUrl.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载二维码
|
||||||
|
const downloadQRCode = () => {
|
||||||
|
if (!qrCodeDataUrl.value) {
|
||||||
|
showToast('没有可下载的二维码', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.download = `qrcode-${Date.now()}.png`
|
||||||
|
link.href = qrCodeDataUrl.value
|
||||||
|
link.click()
|
||||||
|
showToast('下载成功', 'success', 2000)
|
||||||
|
} catch (error) {
|
||||||
|
showToast('下载失败:' + error.message, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制二维码图片
|
||||||
|
const copyQRCodeImage = async () => {
|
||||||
|
if (!qrCodeDataUrl.value) {
|
||||||
|
showToast('没有可复制的二维码', '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('已复制到剪贴板', 'success', 2000)
|
||||||
|
} catch (error) {
|
||||||
|
// 降级方案:提示用户手动保存
|
||||||
|
showToast('复制失败,请使用下载功能', '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>
|
||||||
913
src/views/TimestampConverter.vue
Normal file
913
src/views/TimestampConverter.vue
Normal file
@@ -0,0 +1,913 @@
|
|||||||
|
<template>
|
||||||
|
<div class="timestamp-converter">
|
||||||
|
<div class="container">
|
||||||
|
<div class="conversion-card">
|
||||||
|
<!-- 精度选择器和时区选择器 -->
|
||||||
|
<div class="controls-row">
|
||||||
|
<div class="precision-selector">
|
||||||
|
<button
|
||||||
|
v-for="option in precisionOptions"
|
||||||
|
:key="option.value"
|
||||||
|
@click="timestampType = option.value; outputTimestampType = option.value"
|
||||||
|
:class="['precision-btn', { active: timestampType === option.value }]"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="timezone-selector">
|
||||||
|
<select v-model="timezone" class="timezone-select">
|
||||||
|
<option v-for="tz in timezoneOptions" :key="tz.value" :value="tz.value">
|
||||||
|
{{ tz.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 日期转换为时间戳 -->
|
||||||
|
<div class="conversion-row">
|
||||||
|
<div class="conversion-label">日期 → ({{ timezoneLabel }}) 时间戳:</div>
|
||||||
|
<div class="conversion-inputs">
|
||||||
|
<div class="input-with-calendar">
|
||||||
|
<input
|
||||||
|
v-model="dateStringInput"
|
||||||
|
@input="convertDateToTimestamp"
|
||||||
|
type="text"
|
||||||
|
:placeholder="getDatePlaceholder()"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
<button @click="showDateTimePicker = true" class="calendar-btn" title="选择日期时间">
|
||||||
|
<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="复制">
|
||||||
|
<i class="far fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 时间戳转换为日期 -->
|
||||||
|
<div class="conversion-row">
|
||||||
|
<div class="conversion-label">时间戳 → ({{ timezoneLabel }}) 日期</div>
|
||||||
|
<div class="conversion-inputs">
|
||||||
|
<input
|
||||||
|
v-model="timestampInput"
|
||||||
|
@input="convertTimestampToDate"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入时间戳"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<input
|
||||||
|
v-model="dateStringOutput"
|
||||||
|
type="text"
|
||||||
|
readonly
|
||||||
|
class="input-field readonly"
|
||||||
|
/>
|
||||||
|
<button @click="copyToClipboard(dateStringOutput)" class="copy-btn" title="复制">
|
||||||
|
<i class="far fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 当前时间戳显示与控制 -->
|
||||||
|
<div class="current-timestamp-row">
|
||||||
|
<div class="conversion-label">当前时间戳:</div>
|
||||||
|
<div class="current-timestamp-controls">
|
||||||
|
<span class="current-timestamp-value">{{ currentTimestampDisplay }}</span>
|
||||||
|
<button @click="togglePause" class="control-btn-icon" :title="isPaused ? '继续' : '暂停'">
|
||||||
|
<i :class="isPaused ? 'fas fa-play' : 'fas fa-pause'"></i>
|
||||||
|
</button>
|
||||||
|
<button @click="resetData" class="control-btn-icon" title="重置数据">
|
||||||
|
<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="关闭">
|
||||||
|
<i class="fas fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import DateTimePicker from '@/components/DateTimePicker.vue'
|
||||||
|
|
||||||
|
// 精度选项
|
||||||
|
const precisionOptions = [
|
||||||
|
{ value: 'seconds', label: '秒' },
|
||||||
|
{ value: 'milliseconds', label: '毫秒' },
|
||||||
|
{ value: 'nanoseconds', label: '纳秒' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 所有时区选项
|
||||||
|
const timezoneOptions = [
|
||||||
|
{ value: 'UTC-12:00', label: 'UTC-12:00 | 贝克岛' },
|
||||||
|
{ value: 'UTC-11:00', label: 'UTC-11:00 | 萨摩亚' },
|
||||||
|
{ value: 'UTC-10:00', label: 'UTC-10:00 | 夏威夷' },
|
||||||
|
{ value: 'UTC-09:30', label: 'UTC-09:30 | 马克萨斯群岛' },
|
||||||
|
{ value: 'UTC-09:00', label: 'UTC-09:00 | 阿拉斯加' },
|
||||||
|
{ value: 'UTC-08:00', label: 'UTC-08:00 | 洛杉矶' },
|
||||||
|
{ value: 'UTC-07:00', label: 'UTC-07:00 | 丹佛' },
|
||||||
|
{ value: 'UTC-06:00', label: 'UTC-06:00 | 芝加哥' },
|
||||||
|
{ value: 'UTC-05:00', label: 'UTC-05:00 | 纽约' },
|
||||||
|
{ value: 'UTC-04:00', label: 'UTC-04:00 | 加拉加斯' },
|
||||||
|
{ value: 'UTC-03:30', label: 'UTC-03:30 | 纽芬兰' },
|
||||||
|
{ value: 'UTC-03:00', label: 'UTC-03:00 | 布宜诺斯艾利斯' },
|
||||||
|
{ value: 'UTC-02:00', label: 'UTC-02:00 | 大西洋中部' },
|
||||||
|
{ value: 'UTC-01:00', label: 'UTC-01:00 | 亚速尔群岛' },
|
||||||
|
{ value: 'UTC+00:00', label: 'UTC+00:00 | 伦敦' },
|
||||||
|
{ value: 'UTC+01:00', label: 'UTC+01:00 | 巴黎' },
|
||||||
|
{ value: 'UTC+02:00', label: 'UTC+02:00 | 开罗' },
|
||||||
|
{ value: 'UTC+03:00', label: 'UTC+03:00 | 莫斯科' },
|
||||||
|
{ value: 'UTC+03:30', label: 'UTC+03:30 | 德黑兰' },
|
||||||
|
{ value: 'UTC+04:00', label: 'UTC+04:00 | 迪拜' },
|
||||||
|
{ value: 'UTC+04:30', label: 'UTC+04:30 | 喀布尔' },
|
||||||
|
{ value: 'UTC+05:00', label: 'UTC+05:00 | 伊斯兰堡' },
|
||||||
|
{ value: 'UTC+05:30', label: 'UTC+05:30 | 新德里' },
|
||||||
|
{ value: 'UTC+05:45', label: 'UTC+05:45 | 加德满都' },
|
||||||
|
{ value: 'UTC+06:00', label: 'UTC+06:00 | 达卡' },
|
||||||
|
{ value: 'UTC+06:30', label: 'UTC+06:30 | 仰光' },
|
||||||
|
{ value: 'UTC+07:00', label: 'UTC+07:00 | 曼谷' },
|
||||||
|
{ value: 'UTC+08:00', label: 'UTC+08:00 | 北京' },
|
||||||
|
{ value: 'UTC+08:45', label: 'UTC+08:45 | 尤克拉' },
|
||||||
|
{ value: 'UTC+09:00', label: 'UTC+09:00 | 东京' },
|
||||||
|
{ value: 'UTC+09:30', label: 'UTC+09:30 | 阿德莱德' },
|
||||||
|
{ value: 'UTC+10:00', label: 'UTC+10:00 | 悉尼' },
|
||||||
|
{ value: 'UTC+10:30', label: 'UTC+10:30 | 豪勋爵岛' },
|
||||||
|
{ value: 'UTC+11:00', label: 'UTC+11:00 | 新喀里多尼亚' },
|
||||||
|
{ value: 'UTC+12:00', label: 'UTC+12:00 | 奥克兰' },
|
||||||
|
{ value: 'UTC+12:45', label: 'UTC+12:45 | 查塔姆群岛' },
|
||||||
|
{ value: 'UTC+13:00', label: 'UTC+13:00 | 萨摩亚' },
|
||||||
|
{ value: 'UTC+14:00', label: 'UTC+14:00 | 基里巴斯' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 当前时间相关
|
||||||
|
const currentTime = ref(new Date())
|
||||||
|
let timeInterval = null
|
||||||
|
const isPaused = ref(false)
|
||||||
|
|
||||||
|
// 时区相关
|
||||||
|
const timezone = ref('UTC+08:00')
|
||||||
|
const timezoneLabel = computed(() => {
|
||||||
|
const tz = timezoneOptions.find(opt => opt.value === timezone.value)
|
||||||
|
return tz ? tz.label.split('|')[1].trim() : '北京'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 时间戳转时间
|
||||||
|
const timestampType = ref('milliseconds')
|
||||||
|
const timestampInput = ref('')
|
||||||
|
const dateStringOutput = ref('')
|
||||||
|
|
||||||
|
// 时间转时间戳
|
||||||
|
const dateStringInput = ref('')
|
||||||
|
const outputTimestampType = ref('milliseconds')
|
||||||
|
const timestampOutput = ref('')
|
||||||
|
|
||||||
|
// 日期时间选择器状态
|
||||||
|
const showDateTimePicker = ref(false)
|
||||||
|
|
||||||
|
// 提示消息
|
||||||
|
const toastMessage = ref('')
|
||||||
|
const toastType = ref('success')
|
||||||
|
|
||||||
|
// 计算当前秒级时间戳
|
||||||
|
const currentTimestampSeconds = computed(() => {
|
||||||
|
return Math.floor(currentTime.value.getTime() / 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算当前毫秒级时间戳
|
||||||
|
const currentTimestampMilliseconds = computed(() => {
|
||||||
|
return currentTime.value.getTime()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算当前纳秒级时间戳
|
||||||
|
const currentTimestampNanoseconds = computed(() => {
|
||||||
|
// JavaScript Date 只能精确到毫秒,所以纳秒部分设为0
|
||||||
|
// 纳秒 = 毫秒 * 1000000
|
||||||
|
const ms = currentTime.value.getTime()
|
||||||
|
return BigInt(ms) * BigInt(1000000)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当前时间戳显示
|
||||||
|
const currentTimestampDisplay = computed(() => {
|
||||||
|
if (timestampType.value === 'seconds') {
|
||||||
|
return currentTimestampSeconds.value.toString()
|
||||||
|
} else if (timestampType.value === 'milliseconds') {
|
||||||
|
return currentTimestampMilliseconds.value.toString()
|
||||||
|
} else {
|
||||||
|
return currentTimestampNanoseconds.value.toString()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取日期输入框的placeholder
|
||||||
|
const getDatePlaceholder = () => {
|
||||||
|
if (timestampType.value === 'seconds') {
|
||||||
|
return '格式:yyyy-MM-dd HH:mm:ss'
|
||||||
|
} else if (timestampType.value === 'milliseconds') {
|
||||||
|
return '格式:yyyy-MM-dd HH:mm:ss.SSS'
|
||||||
|
} else {
|
||||||
|
return '格式:yyyy-MM-dd HH:mm:ss.SSSSSSSSS'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新时间
|
||||||
|
const updateCurrentTime = () => {
|
||||||
|
if (!isPaused.value) {
|
||||||
|
currentTime.value = new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换暂停/继续
|
||||||
|
const togglePause = () => {
|
||||||
|
isPaused.value = !isPaused.value
|
||||||
|
if (!isPaused.value) {
|
||||||
|
updateCurrentTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置数据
|
||||||
|
const resetData = () => {
|
||||||
|
timestampInput.value = ''
|
||||||
|
dateStringOutput.value = ''
|
||||||
|
dateStringInput.value = ''
|
||||||
|
timestampOutput.value = ''
|
||||||
|
currentTime.value = new Date()
|
||||||
|
showToast('数据已重置', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间戳转时间字符串
|
||||||
|
const convertTimestampToDate = () => {
|
||||||
|
if (!timestampInput.value.trim()) {
|
||||||
|
dateStringOutput.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let timestampStr = timestampInput.value.trim()
|
||||||
|
|
||||||
|
if (!timestampStr) {
|
||||||
|
dateStringOutput.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let timestampMs = 0
|
||||||
|
let nanoseconds = 0
|
||||||
|
|
||||||
|
if (timestampType.value === 'nanoseconds') {
|
||||||
|
// 纳秒级时间戳,使用 BigInt 处理
|
||||||
|
try {
|
||||||
|
const timestampNs = BigInt(timestampStr)
|
||||||
|
// 转换为毫秒(纳秒 / 1000000)
|
||||||
|
timestampMs = Number(timestampNs / BigInt(1000000))
|
||||||
|
// 获取纳秒部分(纳秒 % 1000000)
|
||||||
|
nanoseconds = Number(timestampNs % BigInt(1000000))
|
||||||
|
} catch (error) {
|
||||||
|
dateStringOutput.value = ''
|
||||||
|
showToast('请输入有效的纳秒级时间戳', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const timestamp = parseInt(timestampStr)
|
||||||
|
|
||||||
|
if (isNaN(timestamp)) {
|
||||||
|
dateStringOutput.value = ''
|
||||||
|
showToast('请输入有效的数字', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是秒级时间戳,转换为毫秒
|
||||||
|
if (timestampType.value === 'seconds') {
|
||||||
|
// 判断是否是秒级时间戳(通常小于13位数字)
|
||||||
|
if (timestamp.toString().length <= 10) {
|
||||||
|
timestampMs = timestamp * 1000
|
||||||
|
} else {
|
||||||
|
timestampMs = timestamp
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
timestampMs = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(timestampMs)
|
||||||
|
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
dateStringOutput.value = ''
|
||||||
|
showToast('无效的时间戳', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||||
|
const milliseconds = String(date.getMilliseconds()).padStart(3, '0')
|
||||||
|
|
||||||
|
if (timestampType.value === 'seconds') {
|
||||||
|
dateStringOutput.value = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||||
|
} else if (timestampType.value === 'milliseconds') {
|
||||||
|
dateStringOutput.value = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`
|
||||||
|
} else {
|
||||||
|
// 纳秒级:显示毫秒 + 纳秒部分
|
||||||
|
const nanosecondsStr = String(nanoseconds).padStart(6, '0')
|
||||||
|
dateStringOutput.value = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}${nanosecondsStr}`
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
dateStringOutput.value = ''
|
||||||
|
showToast('转换失败:' + error.message, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间字符串转时间戳
|
||||||
|
const convertDateToTimestamp = () => {
|
||||||
|
if (!dateStringInput.value.trim()) {
|
||||||
|
timestampOutput.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let dateStr = dateStringInput.value.trim()
|
||||||
|
|
||||||
|
// 尝试解析不同的时间格式
|
||||||
|
let date = null
|
||||||
|
|
||||||
|
// 处理纳秒级格式:yyyy-MM-dd HH:mm:ss.SSSSSSSSS(9位纳秒)
|
||||||
|
if (dateStr.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{9}$/)) {
|
||||||
|
// 提取毫秒部分(前3位)和纳秒部分(后6位)
|
||||||
|
const parts = dateStr.split('.')
|
||||||
|
const baseTime = parts[0].replace(' ', 'T')
|
||||||
|
const nanoseconds = parts[1]
|
||||||
|
const milliseconds = nanoseconds.substring(0, 3)
|
||||||
|
date = new Date(baseTime + '.' + milliseconds)
|
||||||
|
}
|
||||||
|
// 格式1: yyyy-MM-dd HH:mm:ss 或 yyyy-MM-dd HH:mm:ss.SSS
|
||||||
|
else if (dateStr.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d{1,3})?$/)) {
|
||||||
|
date = new Date(dateStr.replace(' ', 'T'))
|
||||||
|
}
|
||||||
|
// 格式2: yyyy-MM-ddTHH:mm:ss 或 yyyy-MM-ddTHH:mm:ss.SSS
|
||||||
|
else if (dateStr.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?/)) {
|
||||||
|
date = new Date(dateStr)
|
||||||
|
}
|
||||||
|
// 格式3: yyyy/MM/dd HH:mm:ss
|
||||||
|
else if (dateStr.match(/^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}/)) {
|
||||||
|
date = new Date(dateStr.replace(' ', 'T').replace(/\//g, '-'))
|
||||||
|
}
|
||||||
|
// 其他格式,直接尝试解析
|
||||||
|
else {
|
||||||
|
date = new Date(dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
timestampOutput.value = ''
|
||||||
|
showToast('无效的时间格式', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outputTimestampType.value === 'seconds') {
|
||||||
|
timestampOutput.value = Math.floor(date.getTime() / 1000).toString()
|
||||||
|
} else if (outputTimestampType.value === 'milliseconds') {
|
||||||
|
timestampOutput.value = date.getTime().toString()
|
||||||
|
} else {
|
||||||
|
// 纳秒级:毫秒 * 1000000
|
||||||
|
const ms = date.getTime()
|
||||||
|
timestampOutput.value = (BigInt(ms) * BigInt(1000000)).toString()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
timestampOutput.value = ''
|
||||||
|
showToast('转换失败:' + error.message, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理日期时间选择器确认
|
||||||
|
const handleDateTimeConfirm = (value) => {
|
||||||
|
dateStringInput.value = value
|
||||||
|
convertDateToTimestamp()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制到剪贴板
|
||||||
|
const copyToClipboard = async (text) => {
|
||||||
|
if (!text) {
|
||||||
|
showToast('没有可复制的内容', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
showToast('已复制到剪贴板', 'success')
|
||||||
|
} catch (error) {
|
||||||
|
// 降级方案
|
||||||
|
const textArea = document.createElement('textarea')
|
||||||
|
textArea.value = text
|
||||||
|
textArea.style.position = 'fixed'
|
||||||
|
textArea.style.opacity = '0'
|
||||||
|
document.body.appendChild(textArea)
|
||||||
|
textArea.select()
|
||||||
|
try {
|
||||||
|
document.execCommand('copy')
|
||||||
|
showToast('已复制到剪贴板', 'success')
|
||||||
|
} catch (err) {
|
||||||
|
showToast('复制失败', 'error')
|
||||||
|
}
|
||||||
|
document.body.removeChild(textArea)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示提示消息
|
||||||
|
const showToast = (message, type = 'success') => {
|
||||||
|
toastMessage.value = message
|
||||||
|
toastType.value = type
|
||||||
|
setTimeout(() => {
|
||||||
|
closeToast()
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭提示消息
|
||||||
|
const closeToast = () => {
|
||||||
|
toastMessage.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听时间戳类型变化
|
||||||
|
const watchTimestampType = () => {
|
||||||
|
if (timestampInput.value) {
|
||||||
|
convertTimestampToDate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听输出时间戳类型变化
|
||||||
|
const watchOutputTimestampType = () => {
|
||||||
|
if (dateStringInput.value) {
|
||||||
|
convertDateToTimestamp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听时间戳类型变化
|
||||||
|
watch(timestampType, watchTimestampType)
|
||||||
|
watch(outputTimestampType, watchOutputTimestampType)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateCurrentTime()
|
||||||
|
timeInterval = setInterval(updateCurrentTime, 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timeInterval) {
|
||||||
|
clearInterval(timeInterval)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.timestamp-converter {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversion-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
border: 1px solid #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precision-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precision-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #333333;
|
||||||
|
border: 1px solid #d0d0d0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precision-btn:hover {
|
||||||
|
background: #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precision-btn.active {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timezone-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timezone-select {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid #d0d0d0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timezone-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.conversion-row {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversion-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333333;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversion-inputs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #666666;
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-timestamp-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-timestamp-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 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>
|
||||||
526
src/views/VariableNameConverter.vue
Normal file
526
src/views/VariableNameConverter.vue
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
<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="关闭">
|
||||||
|
<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="请输入变量名(支持任意格式)"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
<button @click="clearInput" class="clear-btn" title="清空">
|
||||||
|
<i class="fas fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 输出区域 -->
|
||||||
|
<div class="output-section">
|
||||||
|
<div class="output-row">
|
||||||
|
<div
|
||||||
|
v-for="format in formats.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="`复制${format.label}`"
|
||||||
|
>
|
||||||
|
<i class="far fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="output-value" :class="{ empty: !format.value }">
|
||||||
|
{{ format.value || '—' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="output-row">
|
||||||
|
<div
|
||||||
|
v-for="format in formats.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="`复制${format.label}`"
|
||||||
|
>
|
||||||
|
<i class="far fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="output-value" :class="{ empty: !format.value }">
|
||||||
|
{{ format.value || '—' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const inputText = ref('')
|
||||||
|
const toastMessage = ref('')
|
||||||
|
const toastType = ref('success')
|
||||||
|
let toastTimer = null
|
||||||
|
|
||||||
|
// 变量名格式定义
|
||||||
|
const formats = ref([
|
||||||
|
{ key: 'camelCase', label: '小驼峰 (camelCase)', value: '' },
|
||||||
|
{ key: 'PascalCase', label: '大驼峰 (PascalCase)', value: '' },
|
||||||
|
{ key: 'snake_case', label: '下划线 (snake_case)', value: '' },
|
||||||
|
{ key: 'kebab-case', label: '横线 (kebab-case)', value: '' },
|
||||||
|
{ key: 'CONSTANT_CASE', label: '常量 (CONSTANT_CASE)', value: '' }
|
||||||
|
])
|
||||||
|
|
||||||
|
// 显示提示
|
||||||
|
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) {
|
||||||
|
formats.value.forEach(format => {
|
||||||
|
format.value = ''
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
formats.value.forEach(format => {
|
||||||
|
switch (format.key) {
|
||||||
|
case 'camelCase':
|
||||||
|
format.value = toCamelCase(words)
|
||||||
|
break
|
||||||
|
case 'PascalCase':
|
||||||
|
format.value = toPascalCase(words)
|
||||||
|
break
|
||||||
|
case 'snake_case':
|
||||||
|
format.value = toSnakeCase(words)
|
||||||
|
break
|
||||||
|
case 'kebab-case':
|
||||||
|
format.value = toKebabCase(words)
|
||||||
|
break
|
||||||
|
case 'CONSTANT_CASE':
|
||||||
|
format.value = toConstantCase(words)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空输入
|
||||||
|
const clearInput = () => {
|
||||||
|
inputText.value = ''
|
||||||
|
convertVariableName()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制到剪贴板
|
||||||
|
const copyToClipboard = async (text, label) => {
|
||||||
|
if (!text || text === '—') {
|
||||||
|
showToast('没有可复制的内容', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
showToast(`${label}已复制到剪贴板`, 'success', 2000)
|
||||||
|
} catch (error) {
|
||||||
|
showToast('复制失败:' + 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
17
vite.config.js
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
Reference in New Issue
Block a user