1778 lines
63 KiB
HTML
1778 lines
63 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-Hant">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>NFC 生產工具</title>
|
||
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: 'Inter', 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
|
||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%);
|
||
min-height: 100vh;
|
||
padding: 20px;
|
||
color: #f1f5f9;
|
||
}
|
||
|
||
.container {
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
background: rgba(15, 23, 42, 0.95);
|
||
border-radius: 24px;
|
||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.4);
|
||
overflow: hidden;
|
||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||
backdrop-filter: blur(20px);
|
||
}
|
||
|
||
.header {
|
||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 50%, #1e40af 100%);
|
||
color: white;
|
||
padding: 40px;
|
||
text-align: center;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.header::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: linear-gradient(45deg, transparent 30%, rgba(255, 255, 255, 0.1) 50%, transparent 70%);
|
||
animation: shimmer 3s infinite;
|
||
}
|
||
|
||
@keyframes shimmer {
|
||
0% { transform: translateX(-100%); }
|
||
100% { transform: translateX(100%); }
|
||
}
|
||
|
||
.header h1 {
|
||
font-size: 2.8em;
|
||
margin-bottom: 12px;
|
||
font-weight: 600;
|
||
text-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||
letter-spacing: -0.02em;
|
||
}
|
||
|
||
.header p {
|
||
font-size: 1.1em;
|
||
opacity: 0.9;
|
||
}
|
||
|
||
.nav-bar {
|
||
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
||
padding: 20px 40px;
|
||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||
}
|
||
|
||
.nav-bar a {
|
||
color: #60a5fa;
|
||
text-decoration: none;
|
||
margin-right: 30px;
|
||
padding: 12px 20px;
|
||
border-radius: 12px;
|
||
transition: all 0.3s ease;
|
||
font-weight: 500;
|
||
font-size: 15px;
|
||
}
|
||
|
||
.nav-bar a:hover {
|
||
background: rgba(96, 165, 250, 0.15);
|
||
color: #93c5fd;
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.nav-bar a.active {
|
||
background: rgba(96, 165, 250, 0.25);
|
||
color: #dbeafe;
|
||
box-shadow: 0 4px 12px rgba(96, 165, 250, 0.2);
|
||
}
|
||
|
||
.content {
|
||
padding: 40px;
|
||
}
|
||
|
||
.top-section {
|
||
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
||
padding: 30px;
|
||
border-radius: 20px;
|
||
margin-bottom: 30px;
|
||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.top-section h3 {
|
||
color: #60a5fa;
|
||
margin-bottom: 25px;
|
||
font-size: 1.3em;
|
||
font-weight: 600;
|
||
text-shadow: 0 0 15px rgba(96, 165, 250, 0.3);
|
||
}
|
||
|
||
.main-layout {
|
||
display: grid;
|
||
grid-template-columns: 1fr 400px;
|
||
gap: 30px;
|
||
margin-bottom: 30px;
|
||
}
|
||
|
||
.output-section {
|
||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||
border-radius: 20px;
|
||
padding: 25px;
|
||
height: 600px;
|
||
position: relative;
|
||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4);
|
||
}
|
||
|
||
.control-panel {
|
||
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
||
border-radius: 20px;
|
||
padding: 30px;
|
||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
|
||
height: 600px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.control-panel h3 {
|
||
color: #60a5fa;
|
||
margin-bottom: 25px;
|
||
font-size: 1.3em;
|
||
font-weight: 600;
|
||
text-shadow: 0 0 15px rgba(96, 165, 250, 0.3);
|
||
}
|
||
|
||
.api-section {
|
||
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
||
padding: 25px;
|
||
border-radius: 20px;
|
||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.api-section h3 {
|
||
color: #60a5fa;
|
||
margin-bottom: 20px;
|
||
font-size: 1.3em;
|
||
font-weight: 600;
|
||
text-shadow: 0 0 15px rgba(96, 165, 250, 0.3);
|
||
}
|
||
|
||
.api-input-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.api-input-group label {
|
||
font-weight: 600;
|
||
color: #d1d5db;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.api-input-group input {
|
||
padding: 14px 18px;
|
||
border: 2px solid rgba(148, 163, 184, 0.3);
|
||
border-radius: 12px;
|
||
font-size: 14px;
|
||
transition: all 0.3s ease;
|
||
background: rgba(15, 23, 42, 0.5);
|
||
color: #f1f5f9;
|
||
width: 100%;
|
||
}
|
||
|
||
.api-input-group input:focus {
|
||
outline: none;
|
||
border-color: #3b82f6;
|
||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
|
||
}
|
||
|
||
.api-input-group button {
|
||
align-self: flex-start;
|
||
}
|
||
|
||
.api-status {
|
||
margin-top: 10px;
|
||
padding: 8px 12px;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
color: #9ca3af;
|
||
}
|
||
|
||
.select-group {
|
||
display: flex;
|
||
gap: 20px;
|
||
margin-bottom: 25px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
}
|
||
|
||
.select-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.select-item label {
|
||
font-weight: 600;
|
||
color: #d1d5db;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.select-item select {
|
||
padding: 12px 18px;
|
||
border: 2px solid rgba(148, 163, 184, 0.3);
|
||
border-radius: 12px;
|
||
font-size: 14px;
|
||
min-width: 200px;
|
||
transition: all 0.3s ease;
|
||
background: rgba(15, 23, 42, 0.5);
|
||
color: #f1f5f9;
|
||
}
|
||
|
||
.select-item select:focus {
|
||
outline: none;
|
||
border-color: #3b82f6;
|
||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
|
||
}
|
||
|
||
.button-group {
|
||
display: flex;
|
||
gap: 15px;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.btn {
|
||
padding: 12px 25px;
|
||
border: none;
|
||
border-radius: 10px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
min-width: 120px;
|
||
}
|
||
|
||
.btn::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: -100%;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||
transition: left 0.5s;
|
||
}
|
||
|
||
.btn:hover::before {
|
||
left: 100%;
|
||
}
|
||
|
||
.btn-primary {
|
||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||
color: white;
|
||
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3);
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.4);
|
||
}
|
||
|
||
.btn-secondary {
|
||
background: linear-gradient(135deg, #64748b 0%, #475569 100%);
|
||
color: white;
|
||
}
|
||
|
||
.btn-secondary:hover {
|
||
background: linear-gradient(135deg, #475569 0%, #334155 100%);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.btn-success {
|
||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||
color: white;
|
||
}
|
||
|
||
.btn-success:hover {
|
||
background: linear-gradient(135deg, #059669 0%, #047857 100%);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.btn-warning {
|
||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||
color: white;
|
||
}
|
||
|
||
.btn-warning:hover {
|
||
background: linear-gradient(135deg, #d97706 0%, #b45309 100%);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.btn-danger {
|
||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||
color: white;
|
||
}
|
||
|
||
.btn-danger:hover {
|
||
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.btn-info {
|
||
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);
|
||
color: white;
|
||
}
|
||
|
||
.btn-info:hover {
|
||
background: linear-gradient(135deg, #0891b2 0%, #0e7490 100%);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.btn:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
transform: none !important;
|
||
}
|
||
|
||
.main-action {
|
||
text-align: center;
|
||
margin: 20px 0;
|
||
}
|
||
|
||
.main-action .btn {
|
||
font-size: 16px;
|
||
padding: 15px 30px;
|
||
box-shadow: 0 8px 25px rgba(245, 158, 11, 0.4);
|
||
width: 100%;
|
||
}
|
||
|
||
.status-section {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 15px;
|
||
margin-bottom: 20px;
|
||
justify-content: center;
|
||
}
|
||
|
||
.status-indicator {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 20px;
|
||
background: linear-gradient(135deg, #1e3a5f 0%, #2d4a6b 100%);
|
||
border-radius: 25px;
|
||
border: 1px solid rgba(0, 255, 255, 0.2);
|
||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.status-dot {
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
animation: pulse 2s infinite;
|
||
}
|
||
|
||
.status-dot.running {
|
||
background: #ed8936;
|
||
box-shadow: 0 0 10px rgba(237, 137, 54, 0.5);
|
||
}
|
||
|
||
.status-dot.idle {
|
||
background: #48bb78;
|
||
box-shadow: 0 0 10px rgba(72, 187, 120, 0.5);
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0% { opacity: 1; transform: scale(1); }
|
||
50% { opacity: 0.7; transform: scale(1.1); }
|
||
100% { opacity: 1; transform: scale(1); }
|
||
}
|
||
|
||
.output-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 15px;
|
||
color: #22c55e;
|
||
}
|
||
|
||
.output-title {
|
||
font-size: 1.1em;
|
||
font-weight: 600;
|
||
text-shadow: 0 0 10px rgba(34, 197, 94, 0.3);
|
||
}
|
||
|
||
.output-header .status-indicator {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 12px;
|
||
background: rgba(0, 0, 0, 0.3);
|
||
border-radius: 15px;
|
||
border: 1px solid rgba(34, 197, 94, 0.4);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.output-header .status-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
animation: pulse 2s infinite;
|
||
}
|
||
|
||
.output-header .status-dot.running {
|
||
background: #f59e0b;
|
||
box-shadow: 0 0 8px rgba(245, 158, 11, 0.6);
|
||
}
|
||
|
||
.output-header .status-dot.idle {
|
||
background: #10b981;
|
||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.6);
|
||
}
|
||
|
||
.output-controls {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
|
||
.output-btn {
|
||
background: linear-gradient(135deg, #4b5563 0%, #374151 100%);
|
||
color: #22c55e;
|
||
border: 1px solid rgba(34, 197, 94, 0.4);
|
||
padding: 5px 10px;
|
||
border-radius: 5px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.output-btn:hover {
|
||
background: linear-gradient(135deg, #374151 0%, #1f2937 100%);
|
||
box-shadow: 0 0 10px rgba(34, 197, 94, 0.3);
|
||
}
|
||
|
||
.output-content {
|
||
background: #000;
|
||
border-radius: 10px;
|
||
padding: 15px;
|
||
height: calc(100% - 60px);
|
||
overflow-y: auto;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 13px;
|
||
line-height: 1.4;
|
||
color: #22c55e;
|
||
white-space: pre-line;
|
||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||
word-break: break-all;
|
||
word-wrap: break-word;
|
||
}
|
||
|
||
.function-group {
|
||
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
||
padding: 30px;
|
||
border-radius: 20px;
|
||
margin-bottom: 30px;
|
||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.function-group h4 {
|
||
color: #60a5fa;
|
||
margin-bottom: 25px;
|
||
font-size: 1.3em;
|
||
font-weight: 600;
|
||
text-shadow: 0 0 15px rgba(96, 165, 250, 0.3);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.function-group h4::before {
|
||
content: '🔧';
|
||
font-size: 1.2em;
|
||
}
|
||
|
||
/* SDM 加密區塊特殊圖示 */
|
||
.function-group:first-child h4::before {
|
||
content: '🔐';
|
||
}
|
||
|
||
/* 無 SDM 加密區塊特殊圖示 */
|
||
.function-group:nth-child(2) h4::before {
|
||
content: '📄';
|
||
}
|
||
|
||
.function-group .button-group {
|
||
margin-bottom: 0;
|
||
gap: 12px;
|
||
}
|
||
|
||
.function-group .btn {
|
||
padding: 12px 25px;
|
||
font-size: 14px;
|
||
min-width: 140px;
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
.main-layout {
|
||
grid-template-columns: 1fr;
|
||
gap: 20px;
|
||
}
|
||
|
||
.control-panel {
|
||
height: auto;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.select-group {
|
||
flex-direction: column !important;
|
||
gap: 10px !important;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.container {
|
||
margin: 10px;
|
||
border-radius: 15px;
|
||
}
|
||
|
||
.header h1 {
|
||
font-size: 2em;
|
||
}
|
||
|
||
.main-layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.select-group {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.button-group {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.btn {
|
||
width: 100%;
|
||
}
|
||
|
||
.api-input-group {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.api-input-group input {
|
||
min-width: auto;
|
||
}
|
||
|
||
.output-controls {
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.output-btn {
|
||
font-size: 11px;
|
||
padding: 4px 8px;
|
||
}
|
||
}
|
||
|
||
/* 浮動視窗樣式 */
|
||
.modal-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
display: none;
|
||
justify-content: center;
|
||
align-items: center;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.modal {
|
||
background: rgba(15, 23, 42, 0.95);
|
||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||
border-radius: 20px;
|
||
padding: 35px;
|
||
min-width: 400px;
|
||
max-width: 500px;
|
||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.6);
|
||
backdrop-filter: blur(20px);
|
||
}
|
||
|
||
/* 文字編輯浮動視窗特殊樣式 */
|
||
.modal[style*="min-width: 600px"] {
|
||
min-width: 600px;
|
||
max-width: 800px;
|
||
max-height: 80vh;
|
||
}
|
||
|
||
.modal h3 {
|
||
color: #60a5fa;
|
||
margin-bottom: 25px;
|
||
text-align: center;
|
||
font-weight: 600;
|
||
font-size: 1.4em;
|
||
}
|
||
|
||
.modal-input-group {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.modal-input-group label {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
color: #e0e0e0;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.modal-input-group input {
|
||
width: 100%;
|
||
padding: 14px 18px;
|
||
border: 2px solid rgba(148, 163, 184, 0.3);
|
||
border-radius: 12px;
|
||
font-size: 14px;
|
||
background: rgba(15, 23, 42, 0.5);
|
||
color: #f1f5f9;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.modal-input-group input:focus {
|
||
outline: none;
|
||
border-color: #3b82f6;
|
||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||
}
|
||
|
||
.modal-input-group textarea {
|
||
width: 100%;
|
||
padding: 18px;
|
||
border: 2px solid rgba(148, 163, 184, 0.3);
|
||
border-radius: 12px;
|
||
font-size: 14px;
|
||
background: rgba(15, 23, 42, 0.5);
|
||
color: #f1f5f9;
|
||
box-sizing: border-box;
|
||
font-family: 'Courier New', monospace;
|
||
resize: vertical;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.modal-input-group textarea:focus {
|
||
outline: none;
|
||
border-color: #3b82f6;
|
||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||
}
|
||
|
||
.modal-buttons {
|
||
display: flex;
|
||
gap: 15px;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.modal-buttons .btn {
|
||
min-width: 80px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="header">
|
||
<h1>🔧 NFC 生產工具</h1>
|
||
<p>專業的 NFC 卡片生產與認證系統</p>
|
||
</div>
|
||
|
||
<div class="nav-bar">
|
||
<a href="/" class="nav-link active">主頁面</a>
|
||
<a href="/manual_test" class="nav-link">手動測試</a>
|
||
</div>
|
||
|
||
<div class="content">
|
||
<!-- 選擇區域 -->
|
||
<div class="top-section">
|
||
<h3>🎯 選擇設定</h3>
|
||
<div class="select-group">
|
||
<div class="select-item">
|
||
<label for="key-select">選擇 Key</label>
|
||
<select id="key-select"></select>
|
||
</div>
|
||
<div class="select-item">
|
||
<label for="url-select">選擇 URL</label>
|
||
<div style="display: flex; flex-direction: row; gap: 12px; align-items: center;">
|
||
<select id="url-select"></select>
|
||
<button class="btn btn-secondary" id="edit-url-btn" style="padding: 10px 15px; font-size: 12px;">✏️ 編輯</button>
|
||
</div>
|
||
</div>
|
||
<div class="select-item" style="display: flex; flex-direction: column;">
|
||
<label for="name-select">選擇NFC名稱</label>
|
||
<div style="display: flex; flex-direction: row; gap: 12px; align-items: center;">
|
||
<div class="custom-dropdown" style="position: relative; display: inline-block; min-width: 120px;">
|
||
<div class="dropdown-header" id="dropdown-header" style="padding: 12px 18px; border: 2px solid rgba(148, 163, 184, 0.3); border-radius: 12px; font-size: 14px; background: rgba(15, 23, 42, 0.5); color: #f1f5f9; cursor: pointer; display: flex; justify-content: space-between; align-items: center;">
|
||
<span id="selected-name">請選擇NFC名稱</span>
|
||
<span style="font-size: 12px;">▼</span>
|
||
</div>
|
||
<div class="dropdown-content" id="dropdown-content" style="display: none; position: absolute; top: 100%; left: 0; right: 0; background: rgba(15, 23, 42, 0.95); border: 1px solid rgba(148, 163, 184, 0.2); border-radius: 12px; box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4); z-index: 1000; max-height: 200px; overflow-y: auto;">
|
||
<!-- 選項將在這裡動態生成 -->
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-primary" id="add-name-btn" type="button">新增</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主要佈局:執行結果 + 控制面板 -->
|
||
<div class="main-layout">
|
||
<!-- 執行結果 -->
|
||
<div class="output-section">
|
||
<div class="output-header">
|
||
<div class="output-title">📊 執行結果</div>
|
||
<div class="status-indicator">
|
||
<div class="status-dot idle" id="statusDot"></div>
|
||
<span id="statusText">閒置</span>
|
||
</div>
|
||
<div class="status-indicator" id="wsStatusIndicator" style="margin-left: 10px;">
|
||
<div class="status-dot idle" id="wsStatusDot"></div>
|
||
<span id="wsStatusText">WebSocket 未連接</span>
|
||
</div>
|
||
<div class="output-controls">
|
||
<button class="output-btn" onclick="scrollToBottom()">⬇️ 到底部</button>
|
||
<button class="output-btn" onclick="scrollToTop()">⬆️ 到頂部</button>
|
||
<button class="output-btn" onclick="clearOutput()">🗑️ 清除結果</button>
|
||
</div>
|
||
</div>
|
||
<div class="output-content" id="output"></div>
|
||
</div>
|
||
|
||
<!-- 控制面板 -->
|
||
<div class="control-panel">
|
||
<h3>🎮 主要操作</h3>
|
||
|
||
<!-- SDM 加密區塊 -->
|
||
<div class="function-group">
|
||
<h4>🔐 SDM 加密</h4>
|
||
<div class="button-group">
|
||
<button class="btn btn-success" id="run-btn">📝 生產新SDM</button>
|
||
<button class="btn btn-primary" id="check-nfc-btn">🔍 檢驗NFC</button>
|
||
<button class="btn btn-warning" id="readall-btn">📖 讀取UID</button>
|
||
<button class="btn btn-secondary" id="verify-btn">🔐 CMAC認證</button>
|
||
<button class="btn btn-info" id="test-nfc-btn">🧪 完整測試NFC</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 無 SDM 加密區塊 -->
|
||
<div class="function-group">
|
||
<h4>📄 無 SDM 加密</h4>
|
||
<div class="button-group">
|
||
<button class="btn btn-info" id="writendef-btn">✏️ 寫入 NDEF</button>
|
||
<button class="btn btn-info" id="readndef-btn">📖 讀取 NDEF</button>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- API 設定 -->
|
||
<div class="api-section">
|
||
<h3>🌐 API 設定</h3>
|
||
<div class="api-input-group">
|
||
<label for="api-url-input">資料API URL:</label>
|
||
<input type="text" id="api-url-input" placeholder="https://tatalotest.gsct.tw/gsct/api/nfc/verify">
|
||
<button class="btn btn-secondary" id="set-api-btn">設定資料API URL</button>
|
||
</div>
|
||
<div class="api-input-group">
|
||
<label for="api-token-input">API Token:</label>
|
||
<input type="password" id="api-token-input" placeholder="eyJhbGciOiJIUzI1NiJ9...">
|
||
<button class="btn btn-secondary" id="set-token-btn">設定 API Token</button>
|
||
</div>
|
||
<div class="api-status">
|
||
<span id="api-status">未設定</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 新增NFC名稱浮動視窗 -->
|
||
<div class="modal-overlay" id="addNameModal">
|
||
<div class="modal">
|
||
<h3>➕ 新增NFC名稱</h3>
|
||
<div class="modal-input-group">
|
||
<label for="new-name-input">NFC名稱</label>
|
||
<input type="text" id="new-name-input" placeholder="請輸入NFC名稱">
|
||
</div>
|
||
<div class="modal-buttons">
|
||
<button class="btn btn-secondary" id="cancel-add-btn">取消</button>
|
||
<button class="btn btn-primary" id="confirm-add-btn">確認新增</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 編輯URL浮動視窗 -->
|
||
<div class="modal-overlay" id="editUrlModal">
|
||
<div class="modal">
|
||
<h3>✏️ 編輯URL</h3>
|
||
<div class="modal-input-group">
|
||
<label for="url-select-modal">選擇URL編號</label>
|
||
<select id="url-select-modal" style="width: 100%; padding: 14px 18px; border: 2px solid rgba(148, 163, 184, 0.3); border-radius: 12px; font-size: 14px; background: rgba(15, 23, 42, 0.5); color: #f1f5f9; box-sizing: border-box;">
|
||
<option value="">請選擇URL編號</option>
|
||
</select>
|
||
</div>
|
||
<div class="modal-input-group">
|
||
<label for="url-content-input">URL內容</label>
|
||
<input type="text" id="url-content-input" placeholder="請輸入URL內容">
|
||
</div>
|
||
<div class="modal-buttons">
|
||
<button class="btn btn-secondary" id="cancel-edit-url-btn">取消</button>
|
||
<button class="btn btn-danger" id="delete-url-btn">刪除</button>
|
||
<button class="btn btn-primary" id="save-url-btn">儲存</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 文字編輯URL浮動視窗 -->
|
||
<div class="modal-overlay" id="textEditUrlModal">
|
||
<div class="modal" style="min-width: 600px; max-width: 800px; max-height: 80vh;">
|
||
<h3>📝 編輯 URLs.txt</h3>
|
||
<div class="modal-input-group">
|
||
<label for="urls-textarea">URLs 內容(每行一個URL)</label>
|
||
<p style="color: #94a3b8; font-size: 12px; margin: 5px 0;">注意:前三行為系統保留,此處顯示從第四行開始的URL</p>
|
||
<textarea id="urls-textarea" style="width: 100%; height: 400px; padding: 18px; border: 2px solid rgba(148, 163, 184, 0.3); border-radius: 12px; font-size: 14px; background: rgba(15, 23, 42, 0.5); color: #f1f5f9; box-sizing: border-box; font-family: 'Courier New', monospace; resize: vertical; line-height: 1.4;" placeholder="請輸入URLs,每行一個URL"></textarea>
|
||
</div>
|
||
<div class="modal-buttons">
|
||
<button class="btn btn-secondary" id="cancel-text-edit-btn">取消</button>
|
||
<button class="btn btn-primary" id="save-text-edit-btn">儲存</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
|
||
<script>
|
||
const keySelect = document.getElementById('key-select');
|
||
const urlSelect = document.getElementById('url-select');
|
||
const outputDiv = document.getElementById('output');
|
||
const runBtn = document.getElementById('run-btn');
|
||
const readAllBtn = document.getElementById('readall-btn');
|
||
const reloadBtn = document.getElementById('reload-btn');
|
||
const clearBtn = document.getElementById('clear-btn');
|
||
const verifyBtn = document.getElementById('verify-btn');
|
||
const checkNfcBtn = document.getElementById('check-nfc-btn');
|
||
const testNfcBtn = document.getElementById('test-nfc-btn');
|
||
const apiUrlInput = document.getElementById('api-url-input');
|
||
const setApiBtn = document.getElementById('set-api-btn');
|
||
const apiTokenInput = document.getElementById('api-token-input');
|
||
const setTokenBtn = document.getElementById('set-token-btn');
|
||
const apiStatus = document.getElementById('api-status');
|
||
const dropdownHeader = document.getElementById('dropdown-header');
|
||
const selectedNameSpan = document.getElementById('selected-name');
|
||
const dropdownContent = document.getElementById('dropdown-content');
|
||
const addNameBtn = document.getElementById('add-name-btn');
|
||
const statusDot = document.getElementById('statusDot');
|
||
const statusText = document.getElementById('statusText');
|
||
|
||
// 浮動視窗相關元素
|
||
const addNameModal = document.getElementById('addNameModal');
|
||
const newNameInput = document.getElementById('new-name-input');
|
||
const cancelAddBtn = document.getElementById('cancel-add-btn');
|
||
const confirmAddBtn = document.getElementById('confirm-add-btn');
|
||
|
||
// 編輯URL相關元素
|
||
const editUrlBtn = document.getElementById('edit-url-btn');
|
||
const editUrlModal = document.getElementById('editUrlModal');
|
||
const urlSelectModal = document.getElementById('url-select-modal');
|
||
const urlContentInput = document.getElementById('url-content-input');
|
||
const cancelEditUrlBtn = document.getElementById('cancel-edit-url-btn');
|
||
const deleteUrlBtn = document.getElementById('delete-url-btn');
|
||
const saveUrlBtn = document.getElementById('save-url-btn');
|
||
|
||
// 文字編輯URL相關元素
|
||
const textEditUrlModal = document.getElementById('textEditUrlModal');
|
||
const urlsTextarea = document.getElementById('urls-textarea');
|
||
const cancelTextEditBtn = document.getElementById('cancel-text-edit-btn');
|
||
const saveTextEditBtn = document.getElementById('save-text-edit-btn');
|
||
|
||
|
||
let lastUid = '';
|
||
let lastSdm = '';
|
||
let lastKey = '';
|
||
let lastName = '';
|
||
let currentNames = [];
|
||
|
||
function loadKeys() {
|
||
fetch('/api/keys').then(r=>r.json()).then(data=>{
|
||
if(data.success && data.keys.length > 0){
|
||
keySelect.innerHTML = data.keys.map((k, i)=>`<option value="${i+1}">${k}</option>`).join('');
|
||
}else{
|
||
keySelect.innerHTML = '<option>無資料</option>';
|
||
}
|
||
});
|
||
}
|
||
|
||
function loadUrls() {
|
||
fetch('/api/urls').then(r=>r.json()).then(data=>{
|
||
if(data.success && data.urls.length > 0){
|
||
urlSelect.innerHTML = data.urls.map((u, i)=>{
|
||
// 限制URL顯示長度為100字元
|
||
const displayText = u.length > 100 ? u.substring(0, 100) + '...' : u;
|
||
return `<option value="${i+1}">${i+1}. ${displayText}</option>`;
|
||
}).join('');
|
||
}else{
|
||
urlSelect.innerHTML = '<option>無資料</option>';
|
||
}
|
||
});
|
||
}
|
||
|
||
function loadNames() {
|
||
fetch('/api/get_names').then(r=>r.json()).then(data=>{
|
||
currentNames = data.names || [];
|
||
// 清空並重新填充自定義下拉式選單
|
||
dropdownContent.innerHTML = '';
|
||
|
||
// 添加空白選項
|
||
const blankOption = document.createElement('div');
|
||
blankOption.className = 'dropdown-option';
|
||
blankOption.style.padding = '10px 15px';
|
||
blankOption.style.borderBottom = '1px solid rgba(148, 163, 184, 0.2)';
|
||
blankOption.style.cursor = 'pointer';
|
||
blankOption.style.display = 'flex';
|
||
blankOption.style.justifyContent = 'space-between';
|
||
blankOption.style.alignItems = 'center';
|
||
blankOption.setAttribute('data-name', '');
|
||
|
||
const blankSpan = document.createElement('span');
|
||
blankSpan.textContent = '(空白)';
|
||
blankSpan.style.flex = '1';
|
||
blankSpan.style.color = '#888';
|
||
blankSpan.style.fontStyle = 'italic';
|
||
|
||
blankOption.appendChild(blankSpan);
|
||
dropdownContent.appendChild(blankOption);
|
||
|
||
// 空白選項點擊事件
|
||
blankOption.onclick = () => {
|
||
selectedNameSpan.textContent = '請選擇NFC名稱';
|
||
dropdownContent.style.display = 'none';
|
||
fetch('/api/set_name', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({name: ''})
|
||
});
|
||
};
|
||
|
||
// 空白選項懸停效果
|
||
blankOption.onmouseenter = () => {
|
||
blankOption.style.backgroundColor = 'rgba(96, 165, 250, 0.1)';
|
||
};
|
||
blankOption.onmouseleave = () => {
|
||
blankOption.style.backgroundColor = 'transparent';
|
||
};
|
||
|
||
if (currentNames.length === 0) {
|
||
const emptyOption = document.createElement('div');
|
||
emptyOption.style.padding = '10px 15px';
|
||
emptyOption.style.color = '#888';
|
||
emptyOption.style.fontStyle = 'italic';
|
||
emptyOption.textContent = '暫無NFC名稱';
|
||
dropdownContent.appendChild(emptyOption);
|
||
} else {
|
||
currentNames.forEach(name => {
|
||
const option = document.createElement('div');
|
||
option.className = 'dropdown-option';
|
||
option.style.padding = '10px 15px';
|
||
option.style.borderBottom = '1px solid rgba(148, 163, 184, 0.2)';
|
||
option.style.cursor = 'pointer';
|
||
option.style.display = 'flex';
|
||
option.style.justifyContent = 'space-between';
|
||
option.style.alignItems = 'center';
|
||
option.setAttribute('data-name', name);
|
||
|
||
const nameSpan = document.createElement('span');
|
||
nameSpan.textContent = name;
|
||
nameSpan.style.flex = '1';
|
||
|
||
const deleteBtn = document.createElement('button');
|
||
deleteBtn.innerHTML = '❌';
|
||
deleteBtn.style.background = 'none';
|
||
deleteBtn.style.border = 'none';
|
||
deleteBtn.style.color = '#ff4444';
|
||
deleteBtn.style.cursor = 'pointer';
|
||
deleteBtn.style.padding = '2px 6px';
|
||
deleteBtn.style.borderRadius = '3px';
|
||
deleteBtn.style.fontSize = '12px';
|
||
deleteBtn.title = '刪除';
|
||
|
||
// 刪除按鈕事件
|
||
deleteBtn.onclick = (e) => {
|
||
e.stopPropagation(); // 防止觸發選項點擊
|
||
if(confirm(`確定要刪除「${name}」嗎?`)) {
|
||
fetch('/api/delete_name', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({name})
|
||
}).then(r=>r.json()).then(data=>{
|
||
if(data.success) {
|
||
loadNames();
|
||
// 如果刪除的是當前選中的項目,清空選擇
|
||
if(selectedNameSpan.textContent === name) {
|
||
selectedNameSpan.textContent = '請選擇NFC名稱';
|
||
fetch('/api/set_name', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({name: ''})
|
||
});
|
||
}
|
||
} else {
|
||
alert('刪除失敗: ' + data.message);
|
||
}
|
||
});
|
||
}
|
||
};
|
||
|
||
option.appendChild(nameSpan);
|
||
option.appendChild(deleteBtn);
|
||
dropdownContent.appendChild(option);
|
||
|
||
// 選項點擊事件
|
||
option.onclick = () => {
|
||
selectedNameSpan.textContent = name;
|
||
dropdownContent.style.display = 'none';
|
||
fetch('/api/set_name', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({name})
|
||
});
|
||
};
|
||
|
||
// 選項懸停效果
|
||
option.onmouseenter = () => {
|
||
option.style.backgroundColor = 'rgba(96, 165, 250, 0.1)';
|
||
};
|
||
option.onmouseleave = () => {
|
||
option.style.backgroundColor = 'transparent';
|
||
};
|
||
});
|
||
}
|
||
|
||
// 設定當前選中的名稱 - 預設為空白
|
||
selectedNameSpan.textContent = '請選擇NFC名稱';
|
||
});
|
||
}
|
||
|
||
|
||
|
||
function setStatus(isRunning) {
|
||
if (isRunning) {
|
||
statusText.textContent = '執行中';
|
||
statusDot.className = 'status-dot running';
|
||
runBtn.disabled = true;
|
||
readAllBtn.disabled = true;
|
||
verifyBtn.disabled = true;
|
||
} else {
|
||
statusText.textContent = '閒置';
|
||
statusDot.className = 'status-dot idle';
|
||
runBtn.disabled = false;
|
||
readAllBtn.disabled = false;
|
||
verifyBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function scrollToBottom() {
|
||
outputDiv.scrollTop = outputDiv.scrollHeight;
|
||
}
|
||
|
||
// 添加帶顏色的輸出行
|
||
function addColoredLine(text) {
|
||
// 創建一個臨時 div 來處理 ANSI 顏色代碼
|
||
const tempDiv = document.createElement('div');
|
||
tempDiv.innerHTML = parseAnsiColors(text);
|
||
outputDiv.appendChild(tempDiv);
|
||
}
|
||
|
||
// 解析 ANSI 顏色代碼
|
||
function parseAnsiColors(text) {
|
||
// 使用更簡單的方法處理 ANSI 顏色代碼
|
||
let result = text;
|
||
|
||
// 處理常見的 ANSI 顏色代碼
|
||
result = result.replace(/\x1b\[0;31m/g, '<span style="color: #ff6b6b;">'); // 紅色
|
||
result = result.replace(/\x1b\[0;32m/g, '<span style="color: #51cf66;">'); // 綠色
|
||
result = result.replace(/\x1b\[0;33m/g, '<span style="color: #ffd43b;">'); // 黃色
|
||
result = result.replace(/\x1b\[0;34m/g, '<span style="color: #74c0fc;">'); // 藍色
|
||
result = result.replace(/\x1b\[1;33m/g, '<span style="color: #ffd43b; font-weight: bold;">'); // 粗體黃色
|
||
result = result.replace(/\x1b\[1;31m/g, '<span style="color: #ff6b6b; font-weight: bold;">'); // 粗體紅色
|
||
result = result.replace(/\x1b\[1;32m/g, '<span style="color: #51cf66; font-weight: bold;">'); // 粗體綠色
|
||
result = result.replace(/\x1b\[1;34m/g, '<span style="color: #74c0fc; font-weight: bold;">'); // 粗體藍色
|
||
result = result.replace(/\x1b\[0m/g, '</span>'); // 重置顏色
|
||
|
||
// 處理未閉合的 span 標籤
|
||
const openSpans = (result.match(/<span/g) || []).length;
|
||
const closeSpans = (result.match(/<\/span>/g) || []).length;
|
||
for (let i = 0; i < openSpans - closeSpans; i++) {
|
||
result += '</span>';
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
function scrollToTop() {
|
||
outputDiv.scrollTop = 0;
|
||
}
|
||
|
||
// 顯示新增名稱浮動視窗
|
||
function showAddNameModal() {
|
||
addNameModal.style.display = 'flex';
|
||
newNameInput.value = '';
|
||
newNameInput.focus();
|
||
}
|
||
|
||
// 隱藏新增名稱浮動視窗
|
||
function hideAddNameModal() {
|
||
addNameModal.style.display = 'none';
|
||
newNameInput.value = '';
|
||
}
|
||
|
||
// 顯示編輯URL浮動視窗
|
||
function showEditUrlModal() {
|
||
editUrlModal.style.display = 'flex';
|
||
loadUrlsForModal();
|
||
urlSelectModal.value = '';
|
||
urlContentInput.value = '';
|
||
}
|
||
|
||
// 隱藏編輯URL浮動視窗
|
||
function hideEditUrlModal() {
|
||
editUrlModal.style.display = 'none';
|
||
urlSelectModal.value = '';
|
||
urlContentInput.value = '';
|
||
}
|
||
|
||
// 載入URL選項到編輯視窗
|
||
function loadUrlsForModal() {
|
||
fetch('/api/urls').then(r=>r.json()).then(data=>{
|
||
if(data.success && data.urls.length > 0){
|
||
urlSelectModal.innerHTML = '<option value="">請選擇URL編號</option>' +
|
||
data.urls.map((url, i) => {
|
||
// 顯示URL的前100個字元,如果超過則加上...
|
||
const displayText = url.length > 100 ? url.substring(0, 100) + '...' : url;
|
||
return `<option value="${i+1}">${i+1}. ${displayText}</option>`;
|
||
}).join('');
|
||
} else {
|
||
urlSelectModal.innerHTML = '<option value="">無URL資料</option>';
|
||
}
|
||
});
|
||
}
|
||
|
||
// 顯示文字編輯URL浮動視窗
|
||
function showTextEditUrlModal() {
|
||
textEditUrlModal.style.display = 'flex';
|
||
loadUrlsToTextarea();
|
||
}
|
||
|
||
// 隱藏文字編輯URL浮動視窗
|
||
function hideTextEditUrlModal() {
|
||
textEditUrlModal.style.display = 'none';
|
||
urlsTextarea.value = '';
|
||
}
|
||
|
||
// 載入URLs到文字編輯區域(從第四行開始)
|
||
function loadUrlsToTextarea() {
|
||
fetch('/api/urls').then(r=>r.json()).then(data=>{
|
||
if(data.success && data.urls.length > 0){
|
||
// 只顯示從第四行開始的URL(索引3開始)
|
||
const editableUrls = data.urls.slice(3);
|
||
urlsTextarea.value = editableUrls.join('\n');
|
||
} else {
|
||
urlsTextarea.value = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
// 初始載入
|
||
loadKeys();
|
||
loadUrls();
|
||
loadNames(); // 新增 loadNames 呼叫
|
||
setStatus(false);
|
||
|
||
// 載入 API URL
|
||
fetch('/api/get_api_url').then(r=>r.json()).then(data=>{
|
||
apiUrlInput.value = data.api_url || '';
|
||
});
|
||
|
||
// 載入自定義名稱
|
||
// fetch('/api/get_name').then(r=>r.json()).then(data=>{
|
||
// nameInput.value = data.name || '';
|
||
// });
|
||
|
||
// 設定 API URL
|
||
setApiBtn.onclick = () => {
|
||
const apiUrl = apiUrlInput.value.trim();
|
||
if(!apiUrl){
|
||
alert('請輸入資料API URL');
|
||
return;
|
||
}
|
||
fetch('/api/set_api_url', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({api_url: apiUrl})
|
||
}).then(r=>r.json()).then(data=>{
|
||
if(data.success){
|
||
alert('資料API URL 設定成功');
|
||
updateApiStatus();
|
||
}else{
|
||
alert('設定失敗: ' + data.message);
|
||
}
|
||
});
|
||
};
|
||
|
||
// 設定 API Token
|
||
setTokenBtn.onclick = () => {
|
||
const apiToken = apiTokenInput.value.trim();
|
||
if(!apiToken){
|
||
alert('請輸入 API Token');
|
||
return;
|
||
}
|
||
fetch('/api/set_api_token', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({api_token: apiToken})
|
||
}).then(r=>r.json()).then(data=>{
|
||
if(data.success){
|
||
alert('API Token 設定成功');
|
||
updateApiStatus();
|
||
}else{
|
||
alert('設定失敗: ' + data.message);
|
||
}
|
||
});
|
||
};
|
||
|
||
// 更新 API 狀態顯示
|
||
function updateApiStatus() {
|
||
Promise.all([
|
||
fetch('/api/get_api_url').then(r=>r.json()),
|
||
fetch('/api/get_api_token').then(r=>r.json())
|
||
]).then(([urlData, tokenData]) => {
|
||
const hasUrl = urlData.api_url && urlData.api_url.trim() !== '';
|
||
const hasToken = tokenData.api_token && tokenData.api_token.trim() !== '';
|
||
|
||
if (hasUrl && hasToken) {
|
||
apiStatus.textContent = '✅ API 已完整設定';
|
||
apiStatus.style.color = '#10b981';
|
||
} else if (hasUrl) {
|
||
apiStatus.textContent = '⚠️ 缺少 API Token';
|
||
apiStatus.style.color = '#f59e0b';
|
||
} else if (hasToken) {
|
||
apiStatus.textContent = '⚠️ 缺少資料API URL';
|
||
apiStatus.style.color = '#f59e0b';
|
||
} else {
|
||
apiStatus.textContent = '❌ API 未設定';
|
||
apiStatus.style.color = '#ef4444';
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
// 清除輸出函數
|
||
function clearOutput() {
|
||
outputDiv.textContent = '';
|
||
}
|
||
|
||
// SocketIO
|
||
const socket = io();
|
||
|
||
// WebSocket 連接狀態管理
|
||
let isConnected = false;
|
||
let reconnectAttempts = 0;
|
||
const maxReconnectAttempts = 5;
|
||
|
||
// 更新 WebSocket 狀態指示器
|
||
function updateWebSocketStatus(connected, message) {
|
||
const wsStatusDot = document.getElementById('wsStatusDot');
|
||
const wsStatusText = document.getElementById('wsStatusText');
|
||
|
||
if (connected) {
|
||
wsStatusDot.className = 'status-dot idle';
|
||
wsStatusText.textContent = 'WebSocket 已連接';
|
||
} else {
|
||
wsStatusDot.className = 'status-dot running';
|
||
wsStatusText.textContent = message || 'WebSocket 未連接';
|
||
}
|
||
}
|
||
|
||
// 監聽連接事件
|
||
socket.on('connect', () => {
|
||
console.log('WebSocket 已連接');
|
||
isConnected = true;
|
||
reconnectAttempts = 0;
|
||
updateWebSocketStatus(true);
|
||
addColoredLine('🔗 WebSocket 已連接');
|
||
outputDiv.scrollTop = outputDiv.scrollHeight;
|
||
});
|
||
|
||
socket.on('disconnect', () => {
|
||
console.log('WebSocket 已斷開');
|
||
isConnected = false;
|
||
updateWebSocketStatus(false, 'WebSocket 已斷開');
|
||
addColoredLine('❌ WebSocket 已斷開');
|
||
outputDiv.scrollTop = outputDiv.scrollHeight;
|
||
});
|
||
|
||
socket.on('connect_error', (error) => {
|
||
console.error('WebSocket 連接錯誤:', error);
|
||
updateWebSocketStatus(false, 'WebSocket 連接錯誤');
|
||
addColoredLine(`❌ WebSocket 連接錯誤: ${error.message}`);
|
||
outputDiv.scrollTop = outputDiv.scrollHeight;
|
||
});
|
||
|
||
// 監聽 script_output,解析 UID/SDM/key
|
||
socket.on('script_output', data => {
|
||
console.log('收到 script_output:', data);
|
||
addColoredLine(data.line);
|
||
outputDiv.scrollTop = outputDiv.scrollHeight;
|
||
setStatus(true);
|
||
|
||
// 解析 UID
|
||
if(data.line.startsWith('UID:')){
|
||
lastUid = data.line.replace('UID:', '').trim();
|
||
}
|
||
if(data.line.startsWith('SDM 讀取計數器:')){
|
||
lastSdm = data.line.replace('SDM 讀取計數器:', '').trim();
|
||
}
|
||
// 更新lastName為當前選中的名稱
|
||
lastName = selectedNameSpan.textContent === '請選擇NFC名稱' ? '' : selectedNameSpan.textContent;
|
||
// 解析 oldkey 行號
|
||
if(data.line.startsWith('找到可用 oldkey:第')){
|
||
const m = data.line.match(/找到可用 oldkey:第(\d+)行/);
|
||
if(m){
|
||
const keyIdx = parseInt(m[1], 10) - 1;
|
||
// 取得 key 值
|
||
if(keySelect.options[keyIdx]){
|
||
lastKey = keySelect.options[keyIdx].text;
|
||
}
|
||
}
|
||
}
|
||
// 儲存當前名稱
|
||
lastName = nameInput.value.trim();
|
||
// 若回應 200/400/401/402 或 ID已存在/ID新增成功,恢復閒置狀態
|
||
if(data.line === '200' || data.line === '400' || data.line === '401' || data.line === '402' ||
|
||
data.line.includes('ID已存在') || data.line.includes('ID新增成功') ||
|
||
data.line.includes('更新ctr次數') || data.line.includes('更新key及ctr次數')){
|
||
setStatus(false);
|
||
}
|
||
});
|
||
|
||
socket.on('script_finished', data => {
|
||
console.log('收到 script_finished:', data);
|
||
addColoredLine(`\n=== ${data.message} ===`);
|
||
outputDiv.scrollTop = outputDiv.scrollHeight;
|
||
setStatus(false);
|
||
});
|
||
|
||
// 定期檢查連接狀態
|
||
setInterval(() => {
|
||
if (!isConnected && reconnectAttempts < maxReconnectAttempts) {
|
||
console.log('嘗試重新連接 WebSocket...');
|
||
reconnectAttempts++;
|
||
socket.connect();
|
||
}
|
||
}, 5000);
|
||
|
||
// 按鈕事件
|
||
runBtn.onclick = () => {
|
||
outputDiv.innerHTML = '';
|
||
const key = keySelect.value;
|
||
const url = urlSelect.value;
|
||
const name = selectedNameSpan.textContent === '請選擇NFC名稱' ? '' : selectedNameSpan.textContent;
|
||
setStatus(true);
|
||
fetch('/api/run', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({key, url, name})
|
||
}).then(r=>r.json()).then(data=>{
|
||
if(!data.success){
|
||
addColoredLine(data.message);
|
||
setStatus(false);
|
||
}
|
||
});
|
||
};
|
||
|
||
readAllBtn.onclick = () => {
|
||
outputDiv.innerHTML = '';
|
||
setStatus(true);
|
||
fetch('/api/read_nfc', {
|
||
method: 'POST'
|
||
}).then(r=>r.json()).then(data=>{
|
||
if(!data.success){
|
||
addColoredLine(data.message);
|
||
setStatus(false);
|
||
}
|
||
});
|
||
};
|
||
|
||
verifyBtn.onclick = () => {
|
||
outputDiv.innerHTML = '';
|
||
setStatus(true);
|
||
const key = keySelect.value;
|
||
const name = selectedNameSpan.textContent === '請選擇NFC名稱' ? '' : selectedNameSpan.textContent;
|
||
fetch('/api/verify', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({key, name})
|
||
}).then(r=>r.json()).then(data=>{
|
||
if(!data.success){
|
||
addColoredLine(data.message);
|
||
setStatus(false);
|
||
}
|
||
});
|
||
};
|
||
|
||
checkNfcBtn.onclick = () => {
|
||
outputDiv.innerHTML = '';
|
||
setStatus(true);
|
||
const key = keySelect.value;
|
||
const name = selectedNameSpan.textContent === '請選擇NFC名稱' ? '' : selectedNameSpan.textContent;
|
||
fetch('/api/check_nfc', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({key, name})
|
||
}).then(r=>r.json()).then(data=>{
|
||
if(!data.success){
|
||
addColoredLine(data.message);
|
||
setStatus(false);
|
||
}
|
||
});
|
||
};
|
||
|
||
testNfcBtn.onclick = () => {
|
||
// 顯示確認對話框
|
||
if (confirm('NFC內資料清空,會寫入預設資料確認是否正常,你還要繼續嗎?')) {
|
||
outputDiv.innerHTML = '';
|
||
setStatus(true);
|
||
fetch('/api/test_nfc', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'}
|
||
}).then(r=>r.json()).then(data=>{
|
||
if(!data.success){
|
||
addColoredLine(data.message);
|
||
setStatus(false);
|
||
}
|
||
});
|
||
}
|
||
};
|
||
|
||
|
||
|
||
|
||
|
||
addNameBtn.onclick = () => {
|
||
showAddNameModal();
|
||
};
|
||
|
||
// 編輯URL按鈕事件
|
||
editUrlBtn.onclick = () => {
|
||
showTextEditUrlModal();
|
||
};
|
||
|
||
// 寫入 NDEF 按鈕事件
|
||
document.getElementById('writendef-btn').onclick = () => {
|
||
const urlIndex = urlSelect.value;
|
||
if (!urlIndex) {
|
||
alert('請先選擇 URL');
|
||
return;
|
||
}
|
||
outputDiv.textContent = '';
|
||
setStatus(true);
|
||
fetch('/api/manual_writendef', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({url_index: urlIndex})
|
||
}).then(r=>r.json()).then(data=>{
|
||
if(!data.success){
|
||
outputDiv.textContent = data.message;
|
||
setStatus(false);
|
||
}
|
||
});
|
||
};
|
||
|
||
// 讀取 NDEF 按鈕事件
|
||
document.getElementById('readndef-btn').onclick = () => {
|
||
outputDiv.textContent = '';
|
||
setStatus(true);
|
||
fetch('/api/manual_readndef', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'}
|
||
}).then(r=>r.json()).then(data=>{
|
||
if(!data.success){
|
||
outputDiv.textContent = data.message;
|
||
setStatus(false);
|
||
}
|
||
});
|
||
};
|
||
|
||
|
||
|
||
// 下拉式選單切換
|
||
dropdownHeader.onclick = () => {
|
||
if (dropdownContent.style.display === 'none' || dropdownContent.style.display === '') {
|
||
dropdownContent.style.display = 'block';
|
||
} else {
|
||
dropdownContent.style.display = 'none';
|
||
}
|
||
};
|
||
|
||
// 點擊外部關閉下拉式選單
|
||
document.addEventListener('click', (e) => {
|
||
if (!dropdownHeader.contains(e.target) && !dropdownContent.contains(e.target)) {
|
||
dropdownContent.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
// URL選擇變更事件
|
||
urlSelectModal.onchange = () => {
|
||
const selectedIndex = urlSelectModal.value;
|
||
if (selectedIndex) {
|
||
fetch('/api/get_url_by_index', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({index: selectedIndex})
|
||
}).then(r=>r.json()).then(data=>{
|
||
if(data.success) {
|
||
urlContentInput.value = data.url;
|
||
} else {
|
||
alert('載入URL失敗: ' + data.message);
|
||
urlContentInput.value = '';
|
||
}
|
||
});
|
||
} else {
|
||
urlContentInput.value = '';
|
||
}
|
||
};
|
||
|
||
|
||
|
||
|
||
|
||
// 取消新增按鈕
|
||
cancelAddBtn.onclick = () => {
|
||
hideAddNameModal();
|
||
};
|
||
|
||
// 確認新增按鈕
|
||
confirmAddBtn.onclick = () => {
|
||
const name = newNameInput.value.trim();
|
||
if(!name) {
|
||
alert('請輸入NFC名稱');
|
||
return;
|
||
}
|
||
if(currentNames.includes(name)) {
|
||
alert('此名稱已存在');
|
||
return;
|
||
}
|
||
|
||
fetch('/api/add_name', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({name})
|
||
}).then(r=>r.json()).then(data=>{
|
||
if(data.success) {
|
||
hideAddNameModal();
|
||
loadNames();
|
||
// 不自動選中新新增的名稱,保持空白
|
||
} else {
|
||
alert('新增失敗: ' + data.message);
|
||
}
|
||
});
|
||
};
|
||
|
||
// 按ESC鍵關閉浮動視窗
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape' && addNameModal.style.display === 'flex') {
|
||
hideAddNameModal();
|
||
}
|
||
if (e.key === 'Escape' && editUrlModal.style.display === 'flex') {
|
||
hideEditUrlModal();
|
||
}
|
||
if (e.key === 'Escape' && textEditUrlModal.style.display === 'flex') {
|
||
hideTextEditUrlModal();
|
||
}
|
||
|
||
});
|
||
|
||
// 點擊浮動視窗背景關閉浮動視窗
|
||
addNameModal.addEventListener('click', (e) => {
|
||
if (e.target === addNameModal) {
|
||
hideAddNameModal();
|
||
}
|
||
});
|
||
|
||
editUrlModal.addEventListener('click', (e) => {
|
||
if (e.target === editUrlModal) {
|
||
hideEditUrlModal();
|
||
}
|
||
});
|
||
|
||
textEditUrlModal.addEventListener('click', (e) => {
|
||
if (e.target === textEditUrlModal) {
|
||
hideTextEditUrlModal();
|
||
}
|
||
});
|
||
|
||
|
||
|
||
// 編輯URL視窗按鈕事件
|
||
cancelEditUrlBtn.onclick = () => {
|
||
hideEditUrlModal();
|
||
};
|
||
|
||
deleteUrlBtn.onclick = () => {
|
||
const selectedIndex = urlSelectModal.value;
|
||
if (!selectedIndex) {
|
||
alert('請先選擇要刪除的URL');
|
||
return;
|
||
}
|
||
if (confirm('確定要刪除此URL嗎?')) {
|
||
fetch('/api/delete_url_by_index', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({index: selectedIndex})
|
||
}).then(r=>r.json()).then(data=>{
|
||
if(data.success) {
|
||
alert('URL刪除成功');
|
||
hideEditUrlModal();
|
||
loadUrls(); // 重新載入URL選項
|
||
} else {
|
||
alert('刪除失敗: ' + data.message);
|
||
}
|
||
});
|
||
}
|
||
};
|
||
|
||
saveUrlBtn.onclick = () => {
|
||
const selectedIndex = urlSelectModal.value;
|
||
const newUrl = urlContentInput.value.trim();
|
||
|
||
if (!selectedIndex) {
|
||
alert('請先選擇要編輯的URL');
|
||
return;
|
||
}
|
||
if (!newUrl) {
|
||
alert('URL內容不得為空');
|
||
return;
|
||
}
|
||
|
||
fetch('/api/update_url_by_index', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({index: selectedIndex, url: newUrl})
|
||
}).then(r=>r.json()).then(data=>{
|
||
if(data.success) {
|
||
alert('URL更新成功');
|
||
hideEditUrlModal();
|
||
loadUrls(); // 重新載入URL選項
|
||
} else {
|
||
alert('更新失敗: ' + data.message);
|
||
}
|
||
});
|
||
};
|
||
|
||
// 文字編輯URL視窗按鈕事件
|
||
cancelTextEditBtn.onclick = () => {
|
||
hideTextEditUrlModal();
|
||
};
|
||
|
||
saveTextEditBtn.onclick = () => {
|
||
const urlsContent = urlsTextarea.value.trim();
|
||
|
||
if (!urlsContent) {
|
||
alert('URLs內容不得為空');
|
||
return;
|
||
}
|
||
|
||
// 先獲取前三行,然後合併編輯的內容
|
||
fetch('/api/urls').then(r=>r.json()).then(data=>{
|
||
if(data.success && data.urls.length >= 3){
|
||
// 保留前三行
|
||
const firstThreeUrls = data.urls.slice(0, 3);
|
||
// 編輯的內容(從第四行開始)
|
||
const editedUrls = urlsContent.split('\n').filter(line => line.trim());
|
||
// 合併所有URL
|
||
const allUrls = [...firstThreeUrls, ...editedUrls];
|
||
|
||
fetch('/api/save_urls_file', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({urls_content: allUrls.join('\n')})
|
||
}).then(r=>r.json()).then(data=>{
|
||
if(data.success) {
|
||
alert(`URLs文件保存成功!共保存了 ${data.urls_count} 個URL`);
|
||
hideTextEditUrlModal();
|
||
loadUrls(); // 重新載入URL選項
|
||
} else {
|
||
alert('保存失敗: ' + data.message);
|
||
}
|
||
});
|
||
} else {
|
||
alert('無法讀取現有URLs,請重試');
|
||
}
|
||
});
|
||
};
|
||
|
||
// 移除 deleteNameBtn, 相關事件
|
||
|
||
// 頁面載入時初始化
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// 載入 API 設定
|
||
loadApiSettings();
|
||
// 更新 API 狀態
|
||
updateApiStatus();
|
||
});
|
||
|
||
// 載入 API 設定
|
||
function loadApiSettings() {
|
||
// 載入 API URL
|
||
fetch('/api/get_api_url').then(r=>r.json()).then(data=>{
|
||
if(data.api_url) {
|
||
apiUrlInput.value = data.api_url;
|
||
}
|
||
});
|
||
|
||
// 載入 API Token
|
||
fetch('/api/get_api_token').then(r=>r.json()).then(data=>{
|
||
if(data.api_token) {
|
||
apiTokenInput.value = data.api_token;
|
||
}
|
||
});
|
||
}
|
||
</script>
|
||
</body>
|
||
</html> |