Files
ToolBox/src/views/QRCodeGenerator.vue
T
renjue f1502ed08c
CI / docker (push) Successful in 11m42s
初始化 ToolBox 开发者工具箱。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 11:15:38 +08:00

700 lines
14 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="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>