Files
ToolBox/src/views/ColorConverter.vue
2026-02-02 00:09:20 +08:00

1483 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="color-converter" :style="{ backgroundColor: currentColor }">
<!-- 浮层提示 -->
<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-info"></i>
<span>{{ toastMessage }}</span>
</div>
<button @click="closeToast" class="toast-close-btn" title="关闭">
<i class="fas fa-xmark"></i>
</button>
</div>
</Transition>
<!-- 左侧侧栏历史记录 -->
<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.color)"
>
<div class="history-color-preview" :style="{ backgroundColor: item.color.rgb }"></div>
<div class="history-info">
<div class="history-time">{{ formatTime(item.time) }}</div>
<div class="history-preview">{{ item.color.hex }}</div>
</div>
</div>
</div>
</div>
<div class="content-wrapper" :class="{ 'sidebar-pushed': sidebarOpen }">
<div class="sidebar-toggle">
<button @click="toggleSidebar" class="toggle-btn">
{{ sidebarOpen ? '◀' : '▶' }}
</button>
</div>
<div class="container">
<div class="conversion-card">
<!-- RGB输入 -->
<div class="input-group">
<div class="input-header">
<label class="input-label">RGB</label>
<div class="copy-paste-buttons">
<button @click="copyRgb" class="copy-btn" title="复制RGB">
<i class="far fa-copy"></i>
</button>
<button @click="pasteRgb" class="paste-btn" title="粘贴RGB">
<i class="far fa-paste"></i>
</button>
</div>
</div>
<div class="rgb-inputs">
<div class="rgb-item">
<span class="rgb-label">R</span>
<input
v-model.number="rgb.r"
@input="handleRgbInput"
@paste="handleRgbPaste"
type="number"
min="0"
max="255"
class="rgb-input"
placeholder="0-255"
/>
</div>
<div class="rgb-item">
<span class="rgb-label">G</span>
<input
v-model.number="rgb.g"
@input="handleRgbInput"
@paste="handleRgbPaste"
type="number"
min="0"
max="255"
class="rgb-input"
placeholder="0-255"
/>
</div>
<div class="rgb-item">
<span class="rgb-label">B</span>
<input
v-model.number="rgb.b"
@input="handleRgbInput"
@paste="handleRgbPaste"
type="number"
min="0"
max="255"
class="rgb-input"
placeholder="0-255"
/>
</div>
</div>
</div>
<!-- 十六进制输入 -->
<div class="input-group">
<div class="input-header">
<label class="input-label">十六进制</label>
<div class="copy-paste-buttons">
<button @click="copyHex" class="copy-btn" title="复制十六进制">
<i class="far fa-copy"></i>
</button>
<button @click="pasteHex" class="paste-btn" title="粘贴十六进制">
<i class="far fa-paste"></i>
</button>
</div>
</div>
<div class="hex-input-wrapper">
<span class="hex-prefix">#</span>
<input
v-model="hex"
@input="handleHexInput"
type="text"
class="hex-input"
placeholder="FFFFFF"
maxlength="6"
/>
</div>
</div>
<!-- HSL输入 -->
<div class="input-group">
<div class="input-header">
<label class="input-label">HSL</label>
<div class="copy-paste-buttons">
<button @click="copyHsl" class="copy-btn" title="复制HSL">
<i class="far fa-copy"></i>
</button>
<button @click="pasteHsl" class="paste-btn" title="粘贴HSL">
<i class="far fa-paste"></i>
</button>
</div>
</div>
<div class="hsl-inputs">
<div class="hsl-item">
<span class="hsl-label">H</span>
<input
v-model.number="hsl.h"
@input="handleHslInput"
@paste="handleHslPaste"
type="number"
min="0"
max="360"
class="hsl-input"
placeholder="0-360"
/>
</div>
<div class="hsl-item">
<span class="hsl-label">S</span>
<input
v-model.number="hsl.s"
@input="handleHslInput"
@paste="handleHslPaste"
type="number"
min="0"
max="100"
class="hsl-input"
placeholder="0-100"
/>
<span class="hsl-unit">%</span>
</div>
<div class="hsl-item">
<span class="hsl-label">L</span>
<input
v-model.number="hsl.l"
@input="handleHslInput"
@paste="handleHslPaste"
type="number"
min="0"
max="100"
class="hsl-input"
placeholder="0-100"
/>
<span class="hsl-unit">%</span>
</div>
</div>
</div>
<!-- 隐藏的粘贴输入框备用方案 -->
<input
ref="pasteInputRef"
v-model="pasteInputValue"
@paste="handlePasteEvent"
type="text"
class="hidden-paste-input"
tabindex="-1"
/>
<!-- 操作按钮 -->
<div class="action-buttons">
<button @click="resetColor" class="action-btn reset-btn">重置</button>
<button @click="randomColor" class="action-btn random-btn">随机颜色</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const rgb = ref({ r: 255, g: 255, b: 255 })
const hex = ref('FFFFFF')
const hsl = ref({ h: 0, s: 0, l: 100 })
const updating = ref(false)
const pasteInputRef = ref(null)
const pasteInputValue = ref('')
const currentPasteType = ref(null) // 'rgb', 'hex', 'hsl'
// 侧边栏
const sidebarOpen = ref(false)
// 历史记录
const historyList = ref([])
const STORAGE_KEY = 'color-converter-history'
const MAX_HISTORY = 50
// Toast通知系统
const toastMessage = ref('')
const toastType = ref('error') // 'error' 或 'info'
let toastTimer = null
const showToast = (message, type = 'error', duration = 3000) => {
toastMessage.value = message
toastType.value = type
if (toastTimer) {
clearTimeout(toastTimer)
}
toastTimer = setTimeout(() => {
toastMessage.value = ''
toastTimer = null
}, duration)
}
const closeToast = () => {
if (toastTimer) {
clearTimeout(toastTimer)
toastTimer = null
}
toastMessage.value = ''
}
// 当前颜色(用于背景)
const currentColor = computed(() => {
return `rgb(${rgb.value.r}, ${rgb.value.g}, ${rgb.value.b})`
})
/**
* RGB转十六进制
* @param {number} r - 红色值 (0-255)
* @param {number} g - 绿色值 (0-255)
* @param {number} b - 蓝色值 (0-255)
* @returns {string} 十六进制颜色值(不含#号)
*/
function rgbToHex(r, g, b) {
const toHex = (n) => {
const hex = Math.round(Math.max(0, Math.min(255, n))).toString(16)
return hex.length === 1 ? '0' + hex : hex
}
return (toHex(r) + toHex(g) + toHex(b)).toUpperCase()
}
/**
* RGB转HSL
* @param {number} r - 红色值 (0-255)
* @param {number} g - 绿色值 (0-255)
* @param {number} b - 蓝色值 (0-255)
* @returns {{h: number, s: number, l: number}} HSL对象
*/
function rgbToHsl(r, g, b) {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h, s, l = (max + min) / 2
if (max === min) {
h = s = 0
} else {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6
break
case g:
h = ((b - r) / d + 2) / 6
break
case b:
h = ((r - g) / d + 4) / 6
break
}
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100)
}
}
/**
* 十六进制转RGB
* @param {string} hex - 十六进制颜色值(不含#号)
* @returns {{r: number, g: number, b: number}|null} RGB对象或null
*/
function hexToRgb(hex) {
const result = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
}
: null
}
/**
* HSL转RGB
* @param {number} h - 色相 (0-360)
* @param {number} s - 饱和度 (0-100)
* @param {number} l - 亮度 (0-100)
* @returns {{r: number, g: number, b: number}} RGB对象
*/
function hslToRgb(h, s, l) {
h /= 360
s /= 100
l /= 100
let r, g, b
if (s === 0) {
r = g = b = l
} else {
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1 / 6) return p + (q - p) * 6 * t
if (t < 1 / 2) return q
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
return p
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
r = hue2rgb(p, q, h + 1 / 3)
g = hue2rgb(p, q, h)
b = hue2rgb(p, q, h - 1 / 3)
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255)
}
}
// 处理RGB粘贴事件
function handleRgbPaste(event) {
const pastedText = event.clipboardData?.getData('text') || ''
if (!pastedText || !pastedText.trim()) {
return // 如果没有粘贴内容,允许默认行为
}
const text = pastedText.trim()
// 支持格式rgb(255, 255, 255) 或 255, 255, 255
const rgbMatch = text.match(/rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/i) ||
text.match(/(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/)
if (rgbMatch) {
event.preventDefault() // 阻止默认粘贴行为
const r = parseInt(rgbMatch[1])
const g = parseInt(rgbMatch[2])
const b = parseInt(rgbMatch[3])
if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) {
updating.value = true
rgb.value = { r, g, b }
// 更新十六进制
hex.value = rgbToHex(r, g, b)
// 更新HSL
const hslValue = rgbToHsl(r, g, b)
hsl.value = hslValue
updating.value = false
// 保存到历史记录
saveToHistory()
showToast('RGB已粘贴并解析', 'info', 2000)
} else {
showToast('RGB值超出范围0-255', 'error')
}
}
// 如果不是RGB格式允许默认粘贴行为粘贴单个数字
}
// 处理RGB输入
function handleRgbInput() {
if (updating.value) return
updating.value = true
// 限制RGB值范围
rgb.value.r = Math.max(0, Math.min(255, rgb.value.r || 0))
rgb.value.g = Math.max(0, Math.min(255, rgb.value.g || 0))
rgb.value.b = Math.max(0, Math.min(255, rgb.value.b || 0))
// 更新十六进制
hex.value = rgbToHex(rgb.value.r, rgb.value.g, rgb.value.b)
// 更新HSL
const hslValue = rgbToHsl(rgb.value.r, rgb.value.g, rgb.value.b)
hsl.value = hslValue
updating.value = false
// 保存到历史记录
saveToHistory()
}
// 处理十六进制输入
function handleHexInput() {
if (updating.value) return
updating.value = true
// 移除#号并转换为大写
let hexValue = hex.value.replace(/^#/, '').toUpperCase()
// 验证十六进制格式
if (/^[0-9A-F]{6}$/.test(hexValue)) {
const rgbValue = hexToRgb(hexValue)
if (rgbValue) {
rgb.value = rgbValue
const hslValue = rgbToHsl(rgb.value.r, rgb.value.g, rgb.value.b)
hsl.value = hslValue
}
} else if (/^[0-9A-F]{3}$/.test(hexValue)) {
// 支持3位十六进制
hexValue = hexValue.split('').map(c => c + c).join('')
const rgbValue = hexToRgb(hexValue)
if (rgbValue) {
rgb.value = rgbValue
hex.value = hexValue
const hslValue = rgbToHsl(rgb.value.r, rgb.value.g, rgb.value.b)
hsl.value = hslValue
}
}
updating.value = false
// 保存到历史记录
if (/^[0-9A-F]{6}$/.test(hexValue) || /^[0-9A-F]{3}$/.test(hex.value.replace(/^#/, '').toUpperCase())) {
saveToHistory()
}
}
// 处理HSL粘贴事件
function handleHslPaste(event) {
const pastedText = event.clipboardData?.getData('text') || ''
if (!pastedText || !pastedText.trim()) {
return // 如果没有粘贴内容,允许默认行为
}
const text = pastedText.trim()
// 支持格式hsl(0, 0%, 100%) 或 0, 0%, 100% 或 0, 0, 100不带%
const hslMatchWithPercent = text.match(/hsl\s*\(\s*(\d+)\s*,\s*(\d+)\s*%\s*,\s*(\d+)\s*%\s*\)/i) ||
text.match(/(\d+)\s*,\s*(\d+)\s*%\s*,\s*(\d+)\s*%/)
const hslMatchWithoutPercent = text.match(/hsl\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/i) ||
text.match(/(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/)
let hslMatch = hslMatchWithPercent || hslMatchWithoutPercent
const hasPercent = !!hslMatchWithPercent
if (hslMatch) {
event.preventDefault() // 阻止默认粘贴行为
let h = parseInt(hslMatch[1])
let s = parseInt(hslMatch[2])
let l = parseInt(hslMatch[3])
// 如果匹配的是不带%的格式假设值是百分比0-100
// 如果值在合理范围内0-100直接使用否则可能是小数格式0-1需要转换
if (!hasPercent) {
// 不带%的格式如果值大于1认为是百分比否则认为是小数
if (s <= 1 && l <= 1) {
s = Math.round(s * 100)
l = Math.round(l * 100)
}
}
if (h >= 0 && h <= 360 && s >= 0 && s <= 100 && l >= 0 && l <= 100) {
updating.value = true
hsl.value = { h, s, l }
// 更新RGB
const rgbValue = hslToRgb(h, s, l)
rgb.value = rgbValue
// 更新十六进制
hex.value = rgbToHex(rgb.value.r, rgb.value.g, rgb.value.b)
updating.value = false
// 保存到历史记录
saveToHistory()
showToast('HSL已粘贴并解析', 'info', 2000)
} else {
showToast('HSL值超出范围H: 0-360, S/L: 0-100', 'error')
}
}
// 如果不是HSL格式允许默认粘贴行为粘贴单个数字
}
// 处理HSL输入
function handleHslInput() {
if (updating.value) return
updating.value = true
// 限制HSL值范围
hsl.value.h = Math.max(0, Math.min(360, hsl.value.h || 0))
hsl.value.s = Math.max(0, Math.min(100, hsl.value.s || 0))
hsl.value.l = Math.max(0, Math.min(100, hsl.value.l || 0))
// 更新RGB
const rgbValue = hslToRgb(hsl.value.h, hsl.value.s, hsl.value.l)
rgb.value = rgbValue
// 更新十六进制
hex.value = rgbToHex(rgb.value.r, rgb.value.g, rgb.value.b)
updating.value = false
// 保存到历史记录
saveToHistory()
}
// 重置颜色
function resetColor() {
updating.value = true
rgb.value = { r: 255, g: 255, b: 255 }
hex.value = 'FFFFFF'
hsl.value = { h: 0, s: 0, l: 100 }
updating.value = false
saveToHistory()
}
// 随机颜色
function randomColor() {
const r = Math.floor(Math.random() * 256)
const g = Math.floor(Math.random() * 256)
const b = Math.floor(Math.random() * 256)
updating.value = true
rgb.value = { r, g, b }
hex.value = rgbToHex(r, g, b)
hsl.value = rgbToHsl(r, g, b)
updating.value = false
saveToHistory()
}
// 复制RGB
async function copyRgb() {
const rgbText = `rgb(${rgb.value.r}, ${rgb.value.g}, ${rgb.value.b})`
try {
await navigator.clipboard.writeText(rgbText)
showToast('RGB已复制到剪贴板', 'info', 2000)
} catch (err) {
showToast('复制失败:' + err.message)
}
}
// 处理粘贴的文本(通用函数)
function processPastedText(text, type) {
if (!text || !text.trim()) {
showToast('剪贴板内容为空')
return false
}
text = text.trim()
if (type === 'rgb') {
// 支持格式rgb(255, 255, 255) 或 255, 255, 255
const rgbMatch = text.match(/rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/i) ||
text.match(/(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/)
if (rgbMatch) {
const r = parseInt(rgbMatch[1])
const g = parseInt(rgbMatch[2])
const b = parseInt(rgbMatch[3])
if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) {
rgb.value = { r, g, b }
handleRgbInput()
showToast('粘贴成功', 'info', 2000)
return true
}
}
showToast('剪贴板内容不是有效的RGB格式')
return false
} else if (type === 'hex') {
// 移除#号并转换为大写
text = text.replace(/^#/, '').toUpperCase().trim()
// 验证十六进制格式
if (/^[0-9A-F]{6}$/.test(text)) {
hex.value = text
handleHexInput()
showToast('粘贴成功', 'info', 2000)
return true
} else if (/^[0-9A-F]{3}$/.test(text)) {
// 支持3位十六进制
hex.value = text.split('').map(c => c + c).join('')
handleHexInput()
showToast('粘贴成功', 'info', 2000)
return true
}
showToast('剪贴板内容不是有效的十六进制格式')
return false
} else if (type === 'hsl') {
// 支持格式hsl(0, 0%, 100%) 或 0, 0%, 100%
const hslMatch = text.match(/hsl\s*\(\s*(\d+)\s*,\s*(\d+)\s*%\s*,\s*(\d+)\s*%\s*\)/i) ||
text.match(/(\d+)\s*,\s*(\d+)\s*%\s*,\s*(\d+)\s*%/)
if (hslMatch) {
const h = parseInt(hslMatch[1])
const s = parseInt(hslMatch[2])
const l = parseInt(hslMatch[3])
if (h >= 0 && h <= 360 && s >= 0 && s <= 100 && l >= 0 && l <= 100) {
hsl.value = { h, s, l }
handleHslInput()
showToast('粘贴成功', 'info', 2000)
return true
}
}
showToast('剪贴板内容不是有效的HSL格式')
return false
}
return false
}
// 粘贴RGB
async function pasteRgb() {
// 优先使用现代 Clipboard API需要 HTTPS 或 localhost
if (navigator.clipboard && navigator.clipboard.readText) {
try {
const text = await navigator.clipboard.readText()
processPastedText(text, 'rgb')
} catch (err) {
// Clipboard API 失败,使用备用方法
currentPasteType.value = 'rgb'
pasteInputValue.value = ''
if (pasteInputRef.value) {
pasteInputRef.value.focus()
showToast('请按 Ctrl+V 或 Cmd+V 粘贴内容', 'info', 3000)
} else {
showToast('粘贴失败:请手动粘贴到输入框', 'error')
}
}
} else {
// 不支持 Clipboard API使用备用方法
currentPasteType.value = 'rgb'
pasteInputValue.value = ''
if (pasteInputRef.value) {
pasteInputRef.value.focus()
showToast('请按 Ctrl+V 或 Cmd+V 粘贴内容', 'info', 3000)
} else {
showToast('请手动粘贴到输入框', 'error')
}
}
}
// 复制十六进制
async function copyHex() {
const hexText = `#${hex.value}`
try {
await navigator.clipboard.writeText(hexText)
showToast('十六进制已复制到剪贴板', 'info', 2000)
} catch (err) {
showToast('复制失败:' + err.message)
}
}
// 粘贴十六进制
async function pasteHex() {
// 优先使用现代 Clipboard API需要 HTTPS 或 localhost
if (navigator.clipboard && navigator.clipboard.readText) {
try {
const text = await navigator.clipboard.readText()
processPastedText(text, 'hex')
} catch (err) {
// Clipboard API 失败,使用备用方法
currentPasteType.value = 'hex'
pasteInputValue.value = ''
if (pasteInputRef.value) {
pasteInputRef.value.focus()
showToast('请按 Ctrl+V 或 Cmd+V 粘贴内容', 'info', 3000)
} else {
showToast('粘贴失败:请手动粘贴到输入框', 'error')
}
}
} else {
// 不支持 Clipboard API使用备用方法
currentPasteType.value = 'hex'
pasteInputValue.value = ''
if (pasteInputRef.value) {
pasteInputRef.value.focus()
showToast('请按 Ctrl+V 或 Cmd+V 粘贴内容', 'info', 3000)
} else {
showToast('请手动粘贴到输入框', 'error')
}
}
}
// 复制HSL
async function copyHsl() {
const hslText = `hsl(${hsl.value.h}, ${hsl.value.s}%, ${hsl.value.l}%)`
try {
await navigator.clipboard.writeText(hslText)
showToast('HSL已复制到剪贴板', 'info', 2000)
} catch (err) {
showToast('复制失败:' + err.message)
}
}
// 粘贴HSL
async function pasteHsl() {
// 优先使用现代 Clipboard API需要 HTTPS 或 localhost
if (navigator.clipboard && navigator.clipboard.readText) {
try {
const text = await navigator.clipboard.readText()
processPastedText(text, 'hsl')
} catch (err) {
// Clipboard API 失败,使用备用方法
currentPasteType.value = 'hsl'
pasteInputValue.value = ''
if (pasteInputRef.value) {
pasteInputRef.value.focus()
showToast('请按 Ctrl+V 或 Cmd+V 粘贴内容', 'info', 3000)
} else {
showToast('粘贴失败:请手动粘贴到输入框', 'error')
}
}
} else {
// 不支持 Clipboard API使用备用方法
currentPasteType.value = 'hsl'
pasteInputValue.value = ''
if (pasteInputRef.value) {
pasteInputRef.value.focus()
showToast('请按 Ctrl+V 或 Cmd+V 粘贴内容', 'info', 3000)
} else {
showToast('请手动粘贴到输入框', 'error')
}
}
}
// 处理隐藏输入框的粘贴事件
function handlePasteEvent(event) {
const pastedText = event.clipboardData?.getData('text') || ''
if (pastedText && currentPasteType.value) {
// 延迟处理,确保值已更新
setTimeout(() => {
processPastedText(pastedText, currentPasteType.value)
pasteInputValue.value = ''
currentPasteType.value = null
if (pasteInputRef.value) {
pasteInputRef.value.blur()
}
}, 0)
}
}
/**
* 保存当前颜色到历史记录
* 自动去重最多保存50条记录
*/
function saveToHistory() {
const colorData = {
rgb: `rgb(${rgb.value.r}, ${rgb.value.g}, ${rgb.value.b})`,
hex: `#${hex.value}`,
hsl: `hsl(${hsl.value.h}, ${hsl.value.s}%, ${hsl.value.l}%)`,
rgbValues: { ...rgb.value },
hexValue: hex.value,
hslValues: { ...hsl.value }
}
const historyItem = {
color: colorData,
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.color.rgbValues.r === rgb.value.r &&
lastHistory.color.rgbValues.g === rgb.value.g &&
lastHistory.color.rgbValues.b === rgb.value.b) {
return // 如果颜色相同,不保存
}
// 添加到开头
history.unshift(historyItem)
// 限制最多50条
if (history.length > MAX_HISTORY) {
history = history.slice(0, MAX_HISTORY)
}
// 保存到localStorage
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(history))
loadHistoryList()
} catch (e) {
// 保存历史记录失败,忽略错误
}
}
/**
* 从localStorage加载历史记录列表
*/
function loadHistoryList() {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
historyList.value = JSON.parse(stored)
}
} catch (e) {
// 加载历史记录失败,重置为空数组
historyList.value = []
}
}
/**
* 加载历史记录到编辑器
* @param {Object} colorData - 颜色数据对象
*/
function loadHistory(colorData) {
updating.value = true
rgb.value = { ...colorData.rgbValues }
hex.value = colorData.hexValue
hsl.value = { ...colorData.hslValues }
updating.value = false
// 注意:加载历史记录时不保存,避免重复保存
}
// 切换侧栏
function toggleSidebar() {
sidebarOpen.value = !sidebarOpen.value
}
/**
* 格式化时间戳为相对时间或日期字符串
* @param {number} timestamp - 时间戳(毫秒)
* @returns {string} 格式化后的时间字符串
*/
function 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'
})
}
// 初始化
handleRgbInput()
loadHistoryList()
</script>
<style scoped>
.color-converter {
width: 100%;
min-height: 100vh;
padding: 2rem 1rem;
transition: background-color 0.3s ease;
position: relative;
}
.content-wrapper {
position: relative;
transition: margin-left 0.3s ease;
min-height: calc(100vh - 40px);
}
.content-wrapper.sidebar-pushed {
margin-left: 300px;
}
.container {
max-width: 900px;
margin: 0 auto;
}
.conversion-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(229, 229, 229, 0.5);
backdrop-filter: blur(10px);
}
.input-group {
margin-bottom: 1.5rem;
}
.input-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.input-label {
font-size: 0.875rem;
font-weight: 500;
color: #333333;
}
.copy-paste-buttons {
display: flex;
gap: 0.75rem;
}
.copy-btn,
.paste-btn {
padding: 0.375rem;
border: 1px solid #d0d0d0;
border-radius: 6px;
background: #f5f5f5;
color: #333333;
font-size: 0.8125rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
}
.copy-btn i,
.paste-btn i {
font-size: 0.8125rem;
}
.copy-btn:hover,
.paste-btn:hover {
background: #e5e5e5;
border-color: #1a1a1a;
}
.copy-btn:active,
.paste-btn:active {
transform: scale(0.98);
}
.rgb-inputs,
.hsl-inputs {
display: flex;
gap: 0.75rem;
}
.rgb-item,
.hsl-item {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
}
.rgb-label,
.hsl-label {
font-size: 0.875rem;
font-weight: 500;
color: #333333;
min-width: 20px;
}
.rgb-input,
.hsl-input {
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;
}
.rgb-input:focus,
.hsl-input:focus {
outline: none;
border-color: #1a1a1a;
box-shadow: 0 0 0 3px rgba(26, 26, 26, 0.1);
}
.hsl-unit {
font-size: 0.875rem;
color: #666666;
}
.hex-input-wrapper {
display: flex;
align-items: center;
border: 1px solid #d0d0d0;
border-radius: 6px;
overflow: hidden;
transition: all 0.2s;
}
.hex-input-wrapper:focus-within {
border-color: #1a1a1a;
box-shadow: 0 0 0 3px rgba(26, 26, 26, 0.1);
}
.hex-prefix {
padding: 0.75rem;
background: #f9f9f9;
color: #333333;
font-weight: 500;
border-right: 1px solid #d0d0d0;
font-size: 0.9375rem;
}
.hex-input {
flex: 1;
padding: 0.75rem;
border: none;
font-size: 0.9375rem;
font-family: 'Courier New', monospace;
text-transform: uppercase;
}
.hex-input:focus {
outline: none;
}
.action-buttons {
display: flex;
gap: 0.75rem;
margin-top: 1.5rem;
}
.action-btn {
flex: 1;
padding: 0.75rem 1rem;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.reset-btn {
background: #f5f5f5;
color: #333333;
border: 1px solid #d0d0d0;
}
.reset-btn:hover {
background: #e5e5e5;
border-color: #1a1a1a;
}
.reset-btn:active {
transform: scale(0.98);
}
.random-btn {
background: #1a1a1a;
color: #ffffff;
}
.random-btn:hover {
background: #333333;
}
.random-btn:active {
transform: scale(0.98);
}
/* 隐藏的粘贴输入框 */
.hidden-paste-input {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
/* Toast通知样式 */
.toast-notification {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 280px;
max-width: 90%;
font-size: 0.875rem;
}
.toast-notification.error {
background: #fff5f5;
color: #c33;
border: 1px solid #ffcccc;
}
.toast-notification.info {
background: #f0f9ff;
color: #0369a1;
border: 1px solid #bae6fd;
}
.toast-content {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.toast-content svg,
.toast-content i {
flex-shrink: 0;
}
.toast-content span {
flex: 1;
word-break: break-word;
}
.toast-close-btn {
flex-shrink: 0;
margin-left: 0.75rem;
padding: 0.25rem;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.7;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.toast-close-btn:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.08);
}
.toast-notification.error .toast-close-btn:hover {
background: rgba(204, 51, 51, 0.15);
}
.toast-notification.info .toast-close-btn:hover {
background: rgba(3, 105, 161, 0.15);
}
.toast-close-btn:active {
opacity: 0.8;
}
.toast-close-btn svg,
.toast-close-btn i {
display: block;
font-size: 14px;
}
/* Toast动画 */
.toast-enter-active {
animation: slideUp 0.3s ease-out;
}
.toast-leave-active {
animation: slideDown 0.3s ease-in;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateX(-50%) translateY(20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@keyframes slideDown {
from {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
to {
opacity: 0;
transform: translateX(-50%) translateY(20px);
}
}
/* 侧边栏样式 */
.sidebar {
position: fixed;
left: 0;
top: 40px;
bottom: 0;
width: 300px;
background: rgba(255, 255, 255, 0.95);
border-right: 1px solid rgba(229, 229, 229, 0.5);
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);
backdrop-filter: blur(10px);
}
.sidebar-open {
transform: translateX(0);
}
.sidebar-header {
padding: 1rem;
border-bottom: 1px solid rgba(229, 229, 229, 0.5);
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255, 255, 255, 0.95);
}
.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: rgba(255, 255, 255, 0.95);
}
.empty-history {
padding: 2rem;
text-align: center;
color: #999999;
font-size: 0.875rem;
}
.history-item {
padding: 0.75rem;
margin-bottom: 0.5rem;
background: rgba(255, 255, 255, 0.8);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.75rem;
border: 1px solid rgba(229, 229, 229, 0.5);
}
.history-item:hover {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(26, 26, 26, 0.2);
transform: translateX(2px);
}
.history-color-preview {
width: 40px;
height: 40px;
border-radius: 4px;
border: 1px solid rgba(208, 208, 208, 0.5);
flex-shrink: 0;
}
.history-info {
flex: 1;
min-width: 0;
}
.history-time {
font-size: 0.75rem;
color: #666666;
margin-bottom: 0.25rem;
}
.history-preview {
font-family: 'Courier New', monospace;
font-size: 0.875rem;
color: #1a1a1a;
font-weight: 500;
word-break: break-all;
}
.sidebar-toggle {
position: absolute;
left: 0;
bottom: 1rem;
z-index: 5;
}
.content-wrapper .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: rgba(26, 26, 26, 0.9);
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);
backdrop-filter: blur(10px);
}
.toggle-btn:hover {
background: rgba(51, 51, 51, 0.9);
}
@media (max-width: 768px) {
.color-converter {
padding: 1rem 0.5rem;
}
.content-wrapper.sidebar-pushed {
margin-left: 0;
}
.sidebar {
width: 80%;
max-width: 300px;
}
.conversion-card {
padding: 1rem;
}
.rgb-inputs,
.hsl-inputs {
flex-direction: column;
}
.rgb-item,
.hsl-item {
flex-direction: row;
}
.action-buttons {
flex-direction: column;
}
.toast-notification {
bottom: 10px;
left: 1rem;
right: 1rem;
transform: none;
min-width: auto;
}
.toast-enter-active {
animation: slideUpMobile 0.3s ease-out;
}
.toast-leave-active {
animation: slideDownMobile 0.3s ease-in;
}
@keyframes slideUpMobile {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideDownMobile {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(20px);
}
}
}
</style>