diff --git a/src/css/main.css b/src/css/main.css new file mode 100644 index 0000000..36af611 --- /dev/null +++ b/src/css/main.css @@ -0,0 +1,2055 @@ +/* CSS Variables for Theme */ +:root { + --primary-500: #3b82f6; + --primary-600: #2563eb; + --primary-700: #1d4ed8; + --primary-400: #60a5fa; + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-300: #d1d5db; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-600: #4b5563; + --gray-700: #374151; + --gray-800: #1f2937; + --gray-900: #111827; + --white: #ffffff; + --black: #000000; + --green-500: #10b981; + --green-600: #059669; + --green-700: #047857; + --green-800: #065f46; + --green-900: #064e3b; + --red-500: #ef4444; + --red-400: #f87171; +} + +.dark { + --bg-primary: var(--gray-900); + --bg-secondary: var(--gray-800); + --bg-tertiary: var(--gray-850); + --text-primary: var(--white); + --text-secondary: var(--gray-300); + --text-tertiary: var(--gray-400); + --border-color: var(--gray-700); +} + +:root:not(.dark) { + --bg-primary: var(--white); + --bg-secondary: var(--gray-50); + --bg-tertiary: var(--gray-100); + --text-primary: var(--black); + --text-secondary: var(--gray-700); + --text-tertiary: var(--gray-500); + --border-color: var(--gray-200); +} + +/* Reset and Base Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; + /* Prevent white flash by setting default background */ + background-color: #ffffff; +} + +html.dark { + background-color: #111827; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + transition: background-color 0.3s ease, color 0.3s ease; + /* Prevent white flash by setting default background */ + background-color: #ffffff; +} + +.dark body { + background-color: #111827; +} + +/* Header */ +header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 50; + background-color: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + height: 72px; + min-height: 72px; + border-bottom: 1px solid var(--border-color); +} + +.dark header { + background-color: rgba(17, 24, 39, 0.8); +} + +.header-container { + max-width: 1280px; + margin: 0 auto; + padding: 0 1rem; + height: 100%; + display: flex; + justify-content: space-between; + align-items: center; +} + +.logo { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; + text-decoration: none; + color: var(--text-primary); +} + +.logo svg { + width: 20px; + height: 20px; + color: var(--primary-500); +} + +.logo span { + font-weight: bold; + font-size: 1.125rem; + white-space: nowrap; +} + +nav { + display: none; + align-items: center; + gap: 1.5rem; + flex-grow: 1; + justify-content: center; +} + +nav a { + color: var(--text-primary); + text-decoration: none; + font-size: 1rem; + white-space: nowrap; + transition: color 0.3s; + padding: 0.25rem 0; +} + +nav a:hover { + color: var(--primary-500); +} + +.dark nav a:hover { + color: var(--primary-400); +} + +.more-menu { + position: relative; +} + +.more-menu-group { + position: relative; +} + +.more-menu button { + background: none; + border: none; + color: var(--text-primary); + cursor: pointer; + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 1rem; + white-space: nowrap; + transition: color 0.3s; + padding: 0.25rem 0; +} + +.more-menu button:hover { + color: var(--primary-500); +} + +.dark .more-menu button:hover { + color: var(--primary-400); +} + +.more-menu svg { + width: 16px; + height: 16px; + margin-left: 0.25rem; + transition: transform 0.2s; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.more-menu-group:hover svg { + transform: rotate(180deg); +} + +.dropdown-menu { + position: absolute; + top: 100%; + left: 0; + margin-top: 0.5rem; + width: 192px; + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + opacity: 0; + visibility: hidden; + transition: all 0.2s; + z-index: 50; +} + +.more-menu-group:hover .dropdown-menu { + opacity: 1; + visibility: visible; +} + +.dropdown-menu a { + display: block; + padding: 0.75rem 1rem; + color: var(--text-primary); + text-decoration: none; + transition: background-color 0.2s, color 0.2s; +} + +.dropdown-menu a:first-child { + border-top-left-radius: 0.5rem; + border-top-right-radius: 0.5rem; +} + +.dropdown-menu a:last-child { + border-bottom-left-radius: 0.5rem; + border-bottom-right-radius: 0.5rem; +} + +.dropdown-menu a:hover { + background-color: var(--bg-secondary); + color: var(--primary-500); +} + +.dark .dropdown-menu a:hover { + color: var(--primary-400); +} + +.header-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-shrink: 0; +} + +/* Language Switcher */ +.language-switcher { + position: relative; +} + +.language-switcher-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: none; + border: 1px solid var(--border-color); + border-radius: 0.5rem; + color: var(--text-primary); + cursor: pointer; + font-size: 0.875rem; + transition: all 0.2s; +} + +.language-switcher-btn:hover { + background-color: var(--bg-secondary); + border-color: var(--primary-500); +} + +.language-switcher-btn svg { + width: 16px; + height: 16px; + transition: transform 0.2s; +} + +.language-switcher-btn:focus { + outline: none; + border-color: var(--primary-500); +} + +.language-dropdown { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + min-width: 180px; + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + opacity: 0; + visibility: hidden; + transform: translateY(-10px); + transition: all 0.2s; + z-index: 100; + overflow: hidden; +} + +.language-dropdown.active { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.language-option { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + color: var(--text-primary); + text-decoration: none; + transition: background-color 0.2s, color 0.2s; + cursor: pointer; +} + +.language-option:hover { + background-color: var(--bg-secondary); + color: var(--primary-500); +} + +.language-option.active { + background-color: var(--bg-secondary); + color: var(--primary-500); + font-weight: 500; +} + +.language-flag { + font-size: 1.25rem; + line-height: 1; +} + +.language-name { + flex: 1; +} + +/* Dark Mode Toggle */ +.theme-toggle { + position: relative; + display: inline-flex; + height: 28px; + width: 56px; + align-items: center; + border-radius: 9999px; + background-color: var(--gray-200); + transition: background-color 0.3s; + cursor: pointer; + border: none; +} + +.dark .theme-toggle { + background-color: var(--primary-600); +} + +.theme-toggle-slider { + position: absolute; + left: 4px; + top: 50%; + transform: translateY(-50%); + height: 20px; + width: 20px; + border-radius: 50%; + background-color: var(--white); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: transform 0.3s; +} + +.dark .theme-toggle-slider { + transform: translateY(-50%) translateX(28px); +} + +.theme-icon { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + z-index: 10; +} + +.theme-icon.sun { + left: 4px; + color: #f59e0b; + opacity: 1; +} + +.dark .theme-icon.sun { + opacity: 0; +} + +.theme-icon.moon { + right: 6px; + color: var(--gray-700); + opacity: 0; +} + +.dark .theme-icon.moon { + opacity: 1; + color: var(--gray-900); +} + +.mobile-menu-btn { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.75rem; + background: none; + border: none; + color: var(--text-primary); + cursor: pointer; + font-size: 0.875rem; + white-space: nowrap; +} + +.mobile-menu { + display: none; + position: absolute; + top: 100%; + left: 0; + width: 100%; + background-color: var(--bg-primary); + padding: 1rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + border-top: 1px solid var(--border-color); +} + +.mobile-menu.active { + display: block; +} + +.mobile-menu a { + display: block; + padding: 0.75rem 0; + color: var(--text-primary); + text-decoration: none; + transition: color 0.3s; +} + +.mobile-menu a:hover { + color: var(--primary-500); +} + +/* Main Content */ +main { + padding-top: 72px; +} + +.container { + max-width: 1280px; + margin: 0 auto; + padding: 0 1rem; +} + +section { + padding: 4rem 0; +} + +/* Hero Section */ +.hero { + padding: 1rem 0 5rem; + background-color: var(--bg-primary); +} + +.hero-card { + max-width: 1024px; + margin: 0 auto; + background-color: var(--bg-primary); + border-radius: 1rem; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + overflow: hidden; + /* On mobile, allow select dropdowns to overflow */ +} + +@media (max-width: 768px) { + /* Ensure html and body allow overflow for native select dropdowns */ + html, + body { + overflow-x: hidden; + overflow-y: auto; + } + + /* Ensure all parent containers allow overflow for native select dropdowns */ + .hero-card, + .hero-header, + .hero-content, + .hero-controls, + .container, + section.hero, + main { + overflow: visible !important; + /* Prevent clipping of native dropdowns */ + clip-path: none; + mask: none; + } + + /* Ensure select dropdowns can overflow on mobile */ + .state-select-wrapper, + .prefecture-select-wrapper, + .province-select-wrapper, + .county-select-wrapper { + overflow: visible !important; + position: relative; + z-index: 1; + /* Prevent any clipping */ + contain: none; + isolation: auto; + clip-path: none; + mask: none; + } + + /* Ensure select element dropdown can overflow */ + .state-select, + .prefecture-select, + .province-select, + .county-select { + position: relative; + z-index: 1000; + /* Ensure native dropdown can render properly */ + transform: none !important; + will-change: auto; + /* Ensure select is not clipped */ + clip-path: none; + mask: none; + } + + /* Prevent any transform or filter that might affect native dropdown positioning */ + .hero-card, + .hero-header, + .hero-content, + .hero-controls, + .state-select-wrapper { + transform: none !important; + filter: none !important; + perspective: none !important; + clip-path: none !important; + mask: none !important; + } + + /* Ensure select is positioned correctly for native dropdown */ + .state-select-wrapper { + /* Native select dropdowns need space, but we don't want to break layout */ + /* The browser will handle positioning, we just ensure no clipping */ + } +} + +.hero-header { + padding: 1.5rem 2.5rem; + background-color: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); +} + +.hero-content { + display: flex; + flex-direction: column; + gap: 2rem; + align-items: center; +} + +.hero-text { + width: 100%; + flex: 1; +} + +.hero-text h1 { + font-size: 1.5rem; + font-weight: bold; + color: var(--text-primary); + line-height: 1.2; + margin-bottom: 1rem; + text-align: center; + /* 防止中英文或长串字符在移动端撑破布局 */ + word-break: break-word; + overflow-wrap: break-word; + max-width: 100%; +} + +.gradient-text { + background: linear-gradient(135deg, var(--primary-500), var(--primary-400)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.hero-text p { + font-size: 1rem; + color: var(--text-secondary); + opacity: 0.8; + margin-bottom: 1rem; + text-align: center; + /* 防止说明文字过长时在移动端挤压布局 */ + word-break: break-word; + overflow-wrap: break-word; + max-width: 100%; +} + +.features-list { + display: flex; + gap: 1.5rem; + flex-wrap: wrap; +} + +.feature-item { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.feature-item svg { + width: 20px; + height: 20px; + color: var(--primary-500); +} + +.feature-item span { + color: var(--text-secondary); +} + +.hero-controls { + width: 100%; + flex: 1; +} + +.state-select-wrapper { + margin-bottom: 1rem; +} + +.state-select-wrapper label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 0.25rem; +} + +.state-select { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--border-color); + border-radius: 0.5rem; + background-color: var(--bg-primary); + color: var(--text-primary); + font-size: 1rem; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.state-select:focus { + border-color: var(--primary-500); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1); +} + +/* 可选工具开关样式 */ +.optional-tools { + display: flex; + gap: 1rem; + margin-bottom: 1rem; + padding: 1rem; + background: var(--bg-secondary); + border-radius: 0.5rem; + border: 1px solid var(--border-color); + overflow: visible; +} + +.tool-option { + flex: 1; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem; + background: var(--bg-primary); + border-radius: 0.375rem; + position: relative; + overflow: visible; +} + +.tool-label { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary); + cursor: pointer; + user-select: none; + flex: 1; + min-width: 0; + margin-right: 0.75rem; + line-height: 1.4; +} + +/* 滑动开关:在桌面端也稍微缩短轨道 */ +.toggle-switch { + position: relative; + display: inline-block; + width: 26px; /* 原来 32px,缩短一些,让更紧凑 */ + height: 20px; /* 与下方圆点高度匹配 */ + cursor: pointer; + flex-shrink: 0; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +/* 小屏手机下的可选工具开关自适应优化 */ +@media (max-width: 640px) { + .optional-tools { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + } + + .tool-option { + padding: 0.85rem 0.9rem; + } + + .tool-label { + font-size: 0.95rem; + line-height: 1.4; + } + + /* 小屏设备下仍然保持与桌面一致的比例,这里只保留必要调整 */ +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + /* 让轨道比背景更亮一些,增加对比度 */ + background-color: #e5e7eb; + transition: 0.25s ease-in-out; + border-radius: 24px; + box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.25); +} + +.dark .toggle-slider { + /* 深色模式下也保持较亮的轨道,避免只看到一个白点 */ + background-color: #4b5563; + box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.6); +} + +/* 圆点:配合 26x20 轨道,改为 16x16,让两端更贴近 */ +.toggle-slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 2px; + bottom: 2px; + background-color: #ffffff; + transition: 0.25s ease-in-out; + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); +} + +.toggle-switch input:checked + .toggle-slider { + /* 选中时轨道变为浅蓝色,并加上描边,状态更明显 */ + background-color: rgba(59, 130, 246, 0.35); + box-shadow: inset 0 0 0 2px rgba(59, 130, 246, 0.9); +} + +.toggle-switch input:checked + .toggle-slider:before { + /* 26 - 2(左) - 16(圆点) - 2(右) = 6,向右移动 8px 让视觉上更贴边 */ + transform: translateX(8px); + /* 选中时滑块本身变为实心蓝色,更醒目 */ + background-color: var(--primary-500); +} + +.toggle-switch input:focus + .toggle-slider { + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); +} + +.generate-btn { + width: 100%; + padding: 1rem 1.5rem; + background-color: var(--primary-600); + color: white; + border: none; + border-radius: 0.5rem; + font-weight: 500; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.3s; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.generate-btn:hover { + background-color: var(--primary-700); +} + +.generate-btn:disabled { + background-color: var(--gray-500); + cursor: not-allowed; + opacity: 0.7; +} + +.generate-btn svg { + width: 20px; + height: 20px; +} + +/* Results Section */ +.results-section { + padding: 1.5rem 2.5rem; + background-color: var(--bg-primary); +} + +.results-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.results-header h3 { + font-size: 1.5rem; + font-weight: bold; + color: var(--text-primary); +} + +.results-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.btn { + padding: 0.5rem 1rem; + border-radius: 0.5rem; + font-weight: 600; + font-size: 0.875rem; + cursor: pointer; + transition: all 0.3s; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + border: none; +} + +.btn-primary { + background-color: var(--primary-600); + color: white; +} + +.btn-primary:hover { + background-color: var(--primary-700); +} + +.btn-green { + background-color: var(--green-800); + color: white; +} + +.btn-green:hover { + background-color: var(--green-900); +} + +.btn svg { + width: 20px; + height: 20px; +} + +.address-result { + background-color: var(--bg-secondary); + border-radius: 0.5rem; + padding: 0.75rem; + min-height: 200px; +} + +.address-result.empty { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); +} + +.address-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.address-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.address-row-full { + grid-template-columns: 1fr; +} + +/* 中文站:邮编 + 临时短信 组合行(桌面端左右两半,移动端竖排并调整顺序) */ +.address-row-zip-sms { + display: flex; + gap: 0.75rem; +} + +.address-row-zip-sms .address-card { + flex: 1 1 0; +} + +@media (max-width: 768px) { + .address-row-zip-sms { + flex-direction: column; + } + + .address-row-zip-sms .sms-card { + order: 1; + } + + .address-row-zip-sms .zip-card { + order: 2; + } +} + +.address-card { + background-color: var(--bg-primary); + padding: 0.75rem; + border-radius: 0.5rem; + border: 1px solid var(--border-color); + transition: all 0.2s; +} + +.address-card:hover { + border-color: var(--primary-500); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.address-card .flex { + display: flex; + align-items: center; + justify-content: space-between; +} + +.address-card label { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-tertiary); +} + +.address-card .text-base { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); +} + +.copy-status { + display: flex; + align-items: center; + transition: color 0.2s; +} + +.copy-status svg { + width: 16px; + height: 16px; +} + +.address-card:hover .copy-status svg { + color: var(--primary-500); +} + +/* Saved Addresses Section */ +.saved-section { + max-width: 1024px; + margin: 2rem auto 0; + background-color: var(--bg-primary); + border-radius: 1rem; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + padding: 1.5rem 2.5rem; +} + +.saved-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.saved-header h3 { + font-size: 1.5rem; + font-weight: bold; + color: var(--text-primary); +} + +.export-menu-wrapper { + position: relative; +} + +.export-menu { + position: absolute; + right: 0; + top: 100%; + margin-top: 0.5rem; + width: 160px; + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + display: none; + z-index: 10; +} + +.export-menu.active { + display: block; +} + +.export-menu button { + width: 100%; + text-align: left; + padding: 0.5rem 1rem; + font-size: 0.875rem; + color: var(--text-secondary); + background: none; + border: none; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + transition: background-color 0.2s; +} + +.export-menu button:hover { + background-color: var(--bg-secondary); +} + +.address-table { + width: 100%; + background-color: var(--bg-primary); + border-radius: 0.75rem; + overflow: hidden; +} + +.table-header { + padding: 0.75rem 1.5rem; + background-color: var(--bg-secondary); +} + +.table-header-row { + display: flex; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.table-header-row > div { + padding: 0.5rem; +} + +.table-body { + border-top: 1px solid var(--border-color); +} + +.table-row { + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + transition: background-color 0.2s; +} + +.table-row:hover { + background-color: var(--bg-secondary); +} + +.table-cell { + font-size: 0.875rem; + color: var(--text-secondary); + padding: 0.5rem; +} + +.table-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; +} + +.table-actions button { + background: none; + border: none; + color: var(--gray-400); + cursor: pointer; + padding: 0.25rem; + transition: color 0.2s; +} + +.table-actions button:hover { + color: var(--primary-500); +} + +.table-actions button.delete:hover { + color: var(--red-500); +} + +.empty-state { + padding: 2rem; + text-align: center; + color: var(--text-tertiary); +} + +/* Modal */ +.modal-overlay { + display: none; + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 100; + backdrop-filter: blur(4px); +} + +.modal-overlay.active { + display: block; +} + +.modal { + display: none; + position: fixed; + inset: 0; + z-index: 101; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.modal.active { + display: flex; + pointer-events: auto; +} + +.modal.active { + display: flex; +} + +.modal-content { + width: 100%; + max-width: 520px; + background-color: var(--bg-primary); + border-radius: 1rem; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + border: 1px solid var(--border-color); + margin: 1.5rem; + pointer-events: auto; + max-height: 90vh; + overflow-y: auto; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-color); +} + +.modal-header h3 { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); +} + +.modal-close { + padding: 0.75rem; + background-color: var(--bg-secondary); + border: 2px solid var(--border-color); + color: var(--text-primary); + cursor: pointer; + border-radius: 0.5rem; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + min-width: 44px; + min-height: 44px; + font-size: 1.5rem; + font-weight: bold; + line-height: 1; +} + +.modal-close:hover { + background-color: var(--red-500); + color: white; + border-color: var(--red-500); + transform: scale(1.05); +} + +.modal-close:active { + transform: scale(0.95); +} + +.modal-close svg { + width: 24px; + height: 24px; + flex-shrink: 0; +} + +/* If using text × symbol */ +.modal-close:not(:has(svg)) { + font-size: 1.75rem; + padding: 0.5rem 0.875rem; +} + +.modal-body { + padding: 1rem; +} + +.modal-body label { + display: block; + font-size: 0.875rem; + color: var(--text-tertiary); + margin-bottom: 0.5rem; +} + +.modal-body textarea, +.modal-body input { + width: 100%; + font-size: 0.875rem; + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 0.75rem; + color: var(--text-primary); + resize: none; +} + +.modal-body textarea { + height: 160px; + overflow-y: auto; +} + +.modal-footer { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + padding: 0.5rem 1rem 1rem; +} + +/* Responsive */ +@media (min-width: 768px) { + nav { + display: flex; + } + + .mobile-menu-btn { + display: none; + } + + .hero-content { + flex-direction: row; + } + + .hero-text h1 { + font-size: 2.25rem; + } + + .results-header h3, + .saved-header h3 { + font-size: 1.875rem; + } + + .modal { + align-items: flex-start; + } + + .modal-content { + margin-top: 5rem; + } +} + +@media (min-width: 1024px) { + .hero-text h1 { + font-size: 2.5rem; + } +} + +/* Utility Classes */ +.hidden { + display: none !important; +} + +@media (min-width: 768px) { + .hidden.md\:flex { + display: flex !important; + } + + .hidden.md\:block { + display: block !important; + } +} + +.text-center { + text-align: center; +} + +.mt-2 { + margin-top: 0.5rem; +} + +.mb-4 { + margin-bottom: 1rem; +} + +/* Footer Styles */ +.site-footer { + background-color: var(--gray-900); + color: var(--white); + padding: 3rem 0 1.5rem; + margin-top: 4rem; +} + +.footer-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 1.5rem; +} + +.footer-content { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 2rem; + margin-bottom: 2rem; +} + +.footer-section h3 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 1rem; + color: var(--white); +} + +.footer-logo { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.footer-logo svg { + width: 24px; + height: 24px; + color: var(--primary-500); +} + +.footer-logo span { + font-size: 1.25rem; + font-weight: bold; + color: var(--white); +} + +.footer-description { + font-size: 0.875rem; + color: rgba(255, 255, 255, 0.7); + line-height: 1.6; + max-width: 300px; +} + +.footer-section ul { + list-style: none; + padding: 0; + margin: 0; +} + +.footer-section ul li { + margin-bottom: 0.5rem; +} + +.footer-section ul li a { + color: rgba(255, 255, 255, 0.7); + text-decoration: none; + font-size: 0.875rem; + transition: color 0.2s; +} + +.footer-section ul li a:hover { + color: var(--primary-400); +} + +.footer-bottom { + border-top: 1px solid rgba(255, 255, 255, 0.1); + padding-top: 1.5rem; + text-align: center; +} + +.footer-bottom p { + color: rgba(255, 255, 255, 0.7); + font-size: 0.875rem; + margin: 0; +} + +/* Friendship Links Styles */ +#friendship-links { + background-color: var(--gray-800); + padding: 2rem 0; + border-top: 1px solid rgba(255, 255, 255, 0.1); + margin-top: 0; +} + +.friendship-links-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; +} + +.friendship-links-title { + font-size: 1rem; + font-weight: 600; + color: var(--white); + margin-bottom: 1rem; + text-align: center; +} + +.friendship-links-list { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 1rem; + align-items: center; +} + +.friendship-link { + color: rgba(255, 255, 255, 0.7); + text-decoration: none; + font-size: 0.875rem; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + transition: all 0.2s ease; + border: 1px solid rgba(255, 255, 255, 0.1); + background-color: rgba(255, 255, 255, 0.05); +} + +.friendship-link:hover { + color: var(--white); + background-color: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + transform: translateY(-2px); +} + +/* Mobile Friendship Links */ +@media (max-width: 768px) { + #friendship-links { + padding: 1.5rem 0; + } + + .friendship-links-list { + gap: 0.75rem; + } + + .friendship-link { + font-size: 0.8125rem; + padding: 0.5rem 0.75rem; + } +} + +/* FAQ Section Styles */ +#faq-section { + max-width: 1200px; + margin: 2rem auto; + padding: 0 1rem; +} + +.faq-container { + width: 100%; +} + +.faq-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; +} + +.faq-card { + background-color: var(--bg-primary); + border-radius: 1rem; + padding: 1.5rem; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + border: 1px solid var(--border-color); +} + +.faq-card-wide { + grid-column: span 3; +} + +.faq-title { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 1rem; +} + +.faq-content { + font-size: 0.875rem; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 1rem; +} + +.faq-list { + list-style: none; + padding: 0; + margin: 0; +} + +.faq-list li { + font-size: 0.875rem; + color: var(--text-secondary); + line-height: 1.8; + padding-left: 1.5rem; + position: relative; + margin-bottom: 0.5rem; +} + +.faq-list li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--primary-500); + font-weight: bold; +} + +.postcode-system { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + margin-top: 1rem; +} + +.postcode-item { + padding: 1rem; + background-color: var(--bg-secondary); + border-radius: 0.5rem; + border: 1px solid var(--border-color); +} + +.postcode-item-title { + font-size: 0.9375rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.5rem; +} + +.postcode-item-content { + font-size: 0.8125rem; + color: var(--text-secondary); + line-height: 1.6; + margin: 0; +} + +/* Mobile FAQ */ +@media (max-width: 1024px) { + .faq-grid { + grid-template-columns: repeat(2, 1fr); + } + + .faq-card-wide { + grid-column: span 2; + } + + .postcode-system { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .faq-grid { + grid-template-columns: 1fr; + } + + .faq-card-wide { + grid-column: span 1; + } + + #faq-section { + margin: 1.5rem auto; + padding: 0 1rem; + } + + .faq-card { + padding: 1rem; + } +} + +/* Mobile Footer */ +@media (max-width: 768px) { + .footer-content { + grid-template-columns: 1fr; + gap: 1.5rem; + } + + .footer-description { + max-width: 100%; + } +} + +/* Mobile Optimizations */ +@media (max-width: 768px) { + /* Improve touch targets */ + .btn, button { + min-height: 44px; + min-width: 44px; + } + + /* Better spacing for mobile */ + .container { + padding-left: 1rem; + padding-right: 1rem; + } + + /* Improve address cards on mobile - only for generation pages */ + .results-section .address-card { + padding: 1rem; + } + + /* Vertical layout for address cards on mobile (generation pages only) */ + .results-section .address-card .flex { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + position: relative; + } + + /* Label row with copy button - keep them on same line */ + .results-section .address-card .flex-shrink-0 { + flex-shrink: 0; + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + margin-right: 0; + } + + /* Content below label */ + .results-section .address-card .flex-1 { + min-width: 0; + width: 100%; + margin-right: 0; + margin-top: 0; + } + + .results-section .address-card .text-base { + word-break: break-word; + overflow-wrap: break-word; + line-height: 1.5; + font-size: 0.9375rem; + text-align: left; + width: 100%; + } + + /* Email field optimization */ + .results-section .address-card[data-field="email"] .flex-1 { + width: 100%; + min-width: 0; + margin-right: 0; + } + + .results-section .address-card[data-field="email"] .text-base { + text-align: left; + word-break: break-word; + overflow-wrap: break-word; + max-width: 100%; + } + + /* Copy button stays in original position (next to label) */ + .results-section .address-card .copy-status { + flex-shrink: 0; + margin-left: auto; + margin-top: 0; + min-width: 24px; + padding: 0.25rem; + } + + .results-section .address-card .copy-status svg { + width: 18px; + height: 18px; + } + + /* Label styling */ + .results-section .address-card label { + font-size: 0.8125rem; + white-space: nowrap; + margin-right: 0.5rem; + flex-shrink: 0; + } + + .address-row { + grid-template-columns: 1fr; + gap: 0.5rem; + } + + /* Better modal on mobile */ + .modal-content { + margin: 1rem; + max-height: calc(100vh - 2rem); + } + + /* Improve form inputs on mobile */ + input, select, textarea { + font-size: 16px; /* Prevents zoom on iOS */ + } + + /* Better header on mobile */ + header { + padding: 0.75rem 1rem; + } + + /* Improve saved addresses table on mobile */ + .address-table { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + /* Better spacing */ + .hero-card { + padding: 1rem; + } + + .results-section, .saved-section { + padding: 1rem; + } + + /* 中文站:移动端结果区和保存区标题改为上下布局,按钮换到下面 */ + .results-header, + .saved-header { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .results-actions { + width: 100%; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.5rem; + } + + .saved-header > div { + width: 100%; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.5rem; + } + + /* Hide language text on very small screens, show only flag */ + @media (max-width: 360px) { + .language-switcher-btn span:first-child { + display: none; + } + .language-switcher-btn { + padding: 0.5rem; + min-width: 40px; + } + + /* Further optimize for very small screens (generation pages only) */ + .results-section .address-card { + padding: 0.75rem; + } + + .results-section .address-card label { + font-size: 0.75rem; + margin-right: 0; + } + + .results-section .address-card .text-base { + font-size: 0.875rem; + } + } +} + +.friendship-link { + font-size: 0.8125rem; + padding: 0.5rem 0.75rem; +} + +/* FAQ Section Styles */ +#faq-section { + max-width: 1200px; + margin: 2rem auto; + padding: 0 1rem; +} + +.faq-container { + width: 100%; +} + +.faq-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; +} + +.faq-card { + background-color: var(--bg-primary); + border-radius: 1rem; + padding: 1.5rem; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + border: 1px solid var(--border-color); +} + +.faq-card-wide { + grid-column: span 3; +} + +.faq-title { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 1rem; +} + +.faq-content { + font-size: 0.875rem; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 1rem; +} + +.faq-list { + list-style: none; + padding: 0; + margin: 0; +} + +.faq-list li { + font-size: 0.875rem; + color: var(--text-secondary); + line-height: 1.8; + padding-left: 1.5rem; + position: relative; + margin-bottom: 0.5rem; +} + +.faq-list li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--primary-500); + font-weight: bold; +} + +.postcode-system { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + margin-top: 1rem; +} + +.postcode-item { + padding: 1rem; + background-color: var(--bg-secondary); + border-radius: 0.5rem; + border: 1px solid var(--border-color); +} + +.postcode-item-title { + font-size: 0.9375rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.5rem; +} + +.postcode-item-content { + font-size: 0.8125rem; + color: var(--text-secondary); + line-height: 1.6; + margin: 0; +} + +/* Mobile FAQ */ +@media (max-width: 1024px) { + .faq-grid { + grid-template-columns: repeat(2, 1fr); + } + + .faq-card-wide { + grid-column: span 2; + } + + .postcode-system { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .faq-grid { + grid-template-columns: 1fr; + } + + .faq-card-wide { + grid-column: span 1; + } + + #faq-section { + margin: 1.5rem auto; + padding: 0 1rem; + } + + .faq-card { + padding: 1rem; + } +} + +/* Mobile Footer */ +@media (max-width: 768px) { + .footer-content { + grid-template-columns: 1fr; + gap: 1.5rem; + } + + .footer-description { + max-width: 100%; + } +} + +/* Mobile Optimizations */ +@media (max-width: 768px) { + /* Improve touch targets */ + .btn, button { + min-height: 44px; + min-width: 44px; + } + + /* Better spacing for mobile */ + .container { + padding-left: 1rem; + padding-right: 1rem; + } + + /* Improve address cards on mobile - only for generation pages */ + .results-section .address-card { + padding: 1rem; + } + + /* Vertical layout for address cards on mobile (generation pages only) */ + .results-section .address-card .flex { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + position: relative; + } + + /* Label row with copy button - keep them on same line */ + .results-section .address-card .flex-shrink-0 { + flex-shrink: 0; + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + margin-right: 0; + } + + /* Content below label */ + .results-section .address-card .flex-1 { + min-width: 0; + width: 100%; + margin-right: 0; + margin-top: 0; + } + + .results-section .address-card .text-base { + word-break: break-word; + overflow-wrap: break-word; + line-height: 1.5; + font-size: 0.9375rem; + text-align: left; + width: 100%; + } + + /* Email field optimization */ + .results-section .address-card[data-field="email"] .flex-1 { + width: 100%; + min-width: 0; + margin-right: 0; + } + + .results-section .address-card[data-field="email"] .text-base { + text-align: left; + word-break: break-word; + overflow-wrap: break-word; + max-width: 100%; + } + + /* Copy button stays in original position (next to label) */ + .results-section .address-card .copy-status { + flex-shrink: 0; + margin-left: auto; + margin-top: 0; + min-width: 24px; + padding: 0.25rem; + } + + .results-section .address-card .copy-status svg { + width: 18px; + height: 18px; + } + + /* Label styling */ + .results-section .address-card label { + font-size: 0.8125rem; + white-space: nowrap; + margin-right: 0.5rem; + flex-shrink: 0; + } + + .address-row { + grid-template-columns: 1fr; + gap: 0.5rem; + } + + /* Better modal on mobile */ + .modal-content { + margin: 1rem; + max-height: calc(100vh - 2rem); + } + + /* Improve form inputs on mobile */ + input, select, textarea { + font-size: 16px; /* Prevents zoom on iOS */ + } + + /* Better header on mobile */ + header { + padding: 0.75rem 1rem; + } + + /* Improve saved addresses table on mobile */ + .address-table { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + /* Better spacing */ + .hero-card { + padding: 1rem; + } + + .results-section, .saved-section { + padding: 1rem; + } +} + + diff --git a/src/js/address-generator.js b/src/js/address-generator.js new file mode 100644 index 0000000..6c71f0d --- /dev/null +++ b/src/js/address-generator.js @@ -0,0 +1,1539 @@ +// Address Generator + +import { randomElement, randomInt, generatePhoneNumber, generateEmail, formatAddress } from './utils.js'; +import { getConfig } from './config.js'; + +function ensureNameArray(maybeList) { + if (Array.isArray(maybeList)) return maybeList; + if (!maybeList) return []; + // Support { male: [...], female: [...] } shape by flattening + if (typeof maybeList === 'object') { + const out = []; + if (Array.isArray(maybeList.male)) out.push(...maybeList.male); + if (Array.isArray(maybeList.female)) out.push(...maybeList.female); + return out; + } + return []; +} + +// Data cache to reduce server requests +const dataCache = new Map(); +const CACHE_PREFIX = 'address_data_cache_'; +const CACHE_VERSION = 'v1'; // Increment this to invalidate old cache +const CACHE_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours in milliseconds + +// Load data from JSON file with caching (memory + localStorage) +async function loadData(filePath) { + try { + // Check memory cache first (fastest) + if (dataCache.has(filePath)) { + return dataCache.get(filePath); + } + + // Check localStorage cache (survives page refresh) + const cacheKey = CACHE_PREFIX + filePath; + try { + const cachedData = localStorage.getItem(cacheKey); + if (cachedData) { + const parsed = JSON.parse(cachedData); + // Check if cache is still valid (not expired) + if (parsed.timestamp && (Date.now() - parsed.timestamp) < CACHE_EXPIRY) { + // Restore to memory cache for faster access + dataCache.set(filePath, parsed.data); + return parsed.data; + } else { + // Cache expired, remove it + localStorage.removeItem(cacheKey); + } + } + } catch (e) { + // If localStorage fails (e.g., private mode), continue to fetch + console.warn('localStorage cache read failed:', e); + } + + // Get user configuration + const config = getConfig(); + const fileName = filePath.split('/').pop(); + + // Build paths array based on configuration + const paths = []; + + // If user has configured a custom dataBasePath, use it first + if (config.dataBasePath) { + // Ensure trailing slash + const basePath = config.dataBasePath.endsWith('/') ? config.dataBasePath : config.dataBasePath + '/'; + paths.push(basePath + fileName); + } + + // If autoDetectPaths is enabled (default), add automatic path detection + // This preserves the original behavior for mockaddress.com + if (config.autoDetectPaths !== false) { + const currentPath = window.location.pathname; + + // Try multiple possible paths + // Priority: relative path (../data/) first to avoid cross-language references + paths.push( + `../data/${fileName}`, // Relative: go up one level, then into data (works for all language versions) + `data/${fileName}`, // Relative to current directory (fallback) + filePath // Original path (fallback) + ); + + // Add language-specific absolute paths if we're in a language subdirectory + const pathParts = currentPath.split('/').filter(p => p && p !== 'index.html' && p !== ''); + if (pathParts.length >= 1 && ['en', 'ru', 'es', 'pt'].includes(pathParts[0])) { + // We're in a language subdirectory, add language-specific absolute path + const lang = pathParts[0]; + paths.splice(paths.length - 2, 0, `/${lang}/data/${fileName}`); // Insert before fallback paths + } else { + // We're in root (Chinese version), add root absolute path + paths.splice(paths.length - 2, 0, `/data/${fileName}`); // Insert before fallback paths + } + } + + let lastError = null; + for (const path of paths) { + try { + const response = await fetch(path, { + // Add cache control to help browser cache + cache: 'default' + }); + if (response.ok) { + const data = await response.json(); + // Store in memory cache for current session + dataCache.set(filePath, data); + + // Also store in localStorage for persistence across page refreshes + try { + const cacheKey = CACHE_PREFIX + filePath; + const cacheData = { + data: data, + timestamp: Date.now(), + version: CACHE_VERSION + }; + localStorage.setItem(cacheKey, JSON.stringify(cacheData)); + } catch (e) { + // If localStorage fails (e.g., quota exceeded, private mode), continue + console.warn('localStorage cache write failed:', e); + } + + return data; + } else { + // Store the error but continue trying + lastError = `HTTP ${response.status} for ${path}`; + } + } catch (e) { + // Store the error but continue trying + lastError = e.message; + continue; + } + } + + // If all paths failed, log error but still throw to allow error handling + console.error(`Failed to load ${filePath}. Tried paths:`, paths); + console.error(`Last error:`, lastError); + throw new Error(`Failed to load ${filePath} from any path`); + } catch (error) { + console.error(`Error loading data from ${filePath}:`, error); + throw error; + } +} + +// Generate US address +export async function generateUSAddress(selectedState = 'RANDOM') { + try { + const usData = await loadData('data/usData.json'); + const namesData = await loadData('data/namesData.json'); + + // Select state + let stateCode = selectedState; + if (selectedState === 'RANDOM') { + const states = Object.keys(usData.states); + stateCode = randomElement(states); + } + + const state = usData.states[stateCode]; + if (!state) { + throw new Error(`State ${stateCode} not found`); + } + + // Generate name - decide gender first, then select name + const nameGroup = namesData.nameGroups.western; + const gender = Math.random() > 0.5 ? 'Male' : 'Female'; // English: Male/Female + // Select name based on gender - use gender-specific name lists if available + let firstName; + if (nameGroup.first.male && nameGroup.first.female) { + // Use gender-specific name lists + firstName = randomElement(gender === 'Male' ? nameGroup.first.male : nameGroup.first.female); + } else { + // Fallback: use all names if gender classification not available + firstName = randomElement(nameGroup.first); + } + const lastName = randomElement(nameGroup.last); + + // Generate phone + const areaCode = randomElement(state.area_codes); + const phone = generatePhoneNumber(areaCode); + + // Generate email + const email = generateEmail(firstName, lastName); + + // Generate address components - use common US street names (these exist in most US cities) + const streetNumber = randomInt(100, 9999); + const streetNames = [ + 'Main Street', 'Oak Avenue', 'Park Road', 'Maple Drive', 'Elm Street', + 'Washington Avenue', 'Lincoln Street', 'Jefferson Drive', 'Madison Road', + 'Franklin Avenue', 'Church Street', 'Market Street', 'Broadway', + 'First Street', 'Second Street', 'Third Avenue', 'Fourth Street', + 'Fifth Street', 'Sixth Street', 'Seventh Street', 'Eighth Street', + 'Ninth Street', 'Tenth Street', 'Pine Street', 'Cedar Avenue', + 'Spring Street', 'Summer Street', 'Winter Street', 'Lake Avenue', + 'River Road', 'Hill Street', 'Valley Drive', 'Forest Avenue', + 'Garden Street', 'Rose Avenue', 'Sunset Boulevard', 'Sunrise Drive', + 'College Avenue', 'University Drive', 'School Street', 'Library Lane' + ]; + const streetName = randomElement(streetNames); + const street = `${streetNumber} ${streetName}`; + + // Generate city - use state-specific cities if available, otherwise use fallback + let city; + if (state.cities && state.cities.length > 0) { + // Use cities from the state's city list + city = randomElement(state.cities); + } else { + // Fallback: use common US city names (should not happen if data is complete) + const fallbackCities = [ + 'New York', 'Los Angeles', 'Chicago', 'Houston', 'Phoenix', + 'Philadelphia', 'San Antonio', 'San Diego', 'Dallas', 'San Jose', + 'Austin', 'Jacksonville', 'Fort Worth', 'Columbus', 'Charlotte', + 'San Francisco', 'Indianapolis', 'Seattle', 'Denver', 'Washington' + ]; + city = randomElement(fallbackCities); + } + + // Generate zip code - use state-specific zip range if available, otherwise use fallback + let zip; + if (state.zip_range && state.zip_range.min && state.zip_range.max) { + // Use zip code range from the state's zip_range + zip = randomInt(state.zip_range.min, state.zip_range.max).toString(); + } else { + // Fallback: use random 5-digit zip code (should not happen if data is complete) + zip = randomInt(10000, 99999).toString(); + } + + // Full address should be in English only + const stateNameEn = state.name.en || state.name.zh; + const fullAddress = `${street}, ${city}, ${stateNameEn} ${zip}`; + + return { + firstName, + lastName, + gender, + phone, + email, + street, + city, + state: stateNameEn, // 显示英文州名 + stateCode, + zip, + fullAddress, + country: '美国' + }; + } catch (error) { + console.error('Error generating US address:', error); + throw error; + } +} + +// Generate Hong Kong address +// selectedRegion: 'RANDOM' | 'HK' | 'KL' | 'NT' +// isEnglish: true -> 生成英文姓名和英文地址;false -> 保持中文 +export async function generateHKAddress(selectedRegion = 'RANDOM', isEnglish = false) { + try { + const hkData = await loadData('data/hkData.json'); + const namesData = await loadData('data/namesData.json'); + + // Filter districts based on selected region + let availableDistricts = {}; + if (selectedRegion === 'RANDOM') { + // Use all districts + availableDistricts = hkData.districts; + } else if (hkData.districts[selectedRegion]) { + // Use only the selected region + availableDistricts[selectedRegion] = hkData.districts[selectedRegion]; + } else { + // Fallback to all districts if invalid selection + availableDistricts = hkData.districts; + } + + // Select random district and area from filtered districts + const districts = Object.keys(availableDistricts); + if (districts.length === 0) { + throw new Error('No districts available for selected region'); + } + + const districtKey = randomElement(districts); + const district = availableDistricts[districtKey]; + const area = randomElement(district.areas); + + // Generate name + let firstName; + let lastName; + let gender; + + if (isEnglish && hkData.names && hkData.names.en) { + // 英文模式:使用 hkData 内置的英文化姓名(香港拼音格式) + // 为了符合Apple ID等注册要求,名字组合成2-3个字(更符合香港人传统姓名习惯) + const nameGroupEn = hkData.names.en; + const isMale = Math.random() > 0.5; + gender = isMale ? 'Male' : 'Female'; // 修复:性别逻辑正确对应 + const firstPool = isMale ? nameGroupEn.first.male : nameGroupEn.first.female; + + // 组合2-3个字的名字(70%概率双字,30%概率三字) + if (firstPool && firstPool.length > 0) { + const nameCount = Math.random() < 0.7 ? 2 : 3; // 70%双字,30%三字 + const selectedNames = []; + const availableNames = [...firstPool]; // 复制数组避免修改原数组 + + for (let i = 0; i < nameCount && availableNames.length > 0; i++) { + const selected = randomElement(availableNames); + selectedNames.push(selected); + // 移除已选的名字,避免重复 + const index = availableNames.indexOf(selected); + if (index > -1) availableNames.splice(index, 1); + } + + firstName = selectedNames.join(' '); // 用空格连接,如 "Wing Man" 或 "Wing Man Kai" + } else { + firstName = randomElement(firstPool || []); + } + + lastName = randomElement(nameGroupEn.last || []); + + // 如果数据异常,兜底 + if (!firstName || !lastName) { + const fallback = namesData.nameGroups.western; + gender = Math.random() > 0.5 ? 'Male' : 'Female'; + firstName = randomElement( + gender === 'Male' ? fallback.first.male : fallback.first.female + ); + lastName = randomElement(fallback.last); + } + } else { + // 中文模式:沿用原有中文姓名逻辑 + const nameGroup = namesData.nameGroups.chinese; + gender = Math.random() > 0.5 ? '男' : '女'; // 中文:男/女 + firstName = randomElement(gender === '男' ? nameGroup.first.male : nameGroup.first.female); + lastName = randomElement(nameGroup.last); + } + + // Generate phone (Hong Kong format: +852 XXXX XXXX) + const phone = `+852 ${randomInt(2000, 9999)} ${randomInt(1000, 9999)}`; + + // Hong Kong has no official postal code; use fixed placeholder for forms + const zip = '000000'; + + // Generate email - use English names for email to avoid Chinese characters + const englishNameGroup = namesData.nameGroups.western || namesData.nameGroups.asian; + let emailFirstName, emailLastName; + if (englishNameGroup && englishNameGroup.first && englishNameGroup.last) { + const firstList = ensureNameArray(englishNameGroup.first); + const lastList = ensureNameArray(englishNameGroup.last); + emailFirstName = randomElement(firstList); + emailLastName = randomElement(lastList); + } else { + // Fallback: generate random English username + const randomNames = ['john', 'mary', 'david', 'sarah', 'michael', 'emily', 'james', 'lisa', 'robert', 'anna']; + emailFirstName = randomElement(randomNames); + emailLastName = randomElement(randomNames); + } + const email = generateEmail(emailFirstName, emailLastName); + + // Generate address + const floor = randomInt(1, 50); + const unit = randomInt(1, 20); + + let street; + let building; + let address; + let city; + let districtName; + let fullAddress; + + if (isEnglish) { + street = randomElement(area.streets.en); + building = randomElement(area.buildings.en); + // 英文地址格式:Flat 12, 32/F, Building Name, Street Name + address = `Flat ${unit}, ${floor}/F, ${building}, ${street}`; + city = area.name_en; + districtName = district.name.en; + fullAddress = `${address}, ${city}, ${districtName}, Hong Kong`; + // 英文模式下,性别字段统一英文化 + gender = gender === '男' ? 'Male' : gender === '女' ? 'Female' : gender; + } else { + street = randomElement(area.streets.zh); + building = randomElement(area.buildings.zh); + address = `${street} ${building} ${floor}樓 ${unit}室`; + city = area.name_zh; + districtName = district.name.zh; + fullAddress = `${address}, ${city}, ${districtName}`; + } + + return { + firstName, + lastName, + gender, + phone, + email, + street: address, + city, // 区域作为城市(中英文根据模式切换) + county: districtName, // 区作为区县 + district: districtName, // 保留原字段以兼容 + area: city, // 保留原字段以兼容 + fullAddress, + zip, + country: isEnglish ? '香港 Hong Kong' : '香港' + }; + } catch (error) { + console.error('Error generating HK address:', error); + throw error; + } +} + +// Generate UK address +export async function generateUKAddress(selectedRegion = 'RANDOM') { + try { + const ukData = await loadData('data/ukData.json'); + const namesData = await loadData('data/namesData.json'); + + // Filter regions based on selected region + let availableRegions = {}; + if (selectedRegion === 'RANDOM') { + // Use all regions + availableRegions = ukData.regions; + } else if (ukData.regions[selectedRegion]) { + // Use only the selected region + availableRegions[selectedRegion] = ukData.regions[selectedRegion]; + } else { + // Fallback to all regions if invalid selection + availableRegions = ukData.regions; + } + + // Select random region from filtered regions + const regions = Object.keys(availableRegions); + if (regions.length === 0) { + throw new Error('No regions available for selected region'); + } + + const regionKey = randomElement(regions); + const region = availableRegions[regionKey]; + + // Generate name - decide gender first, then select name + const nameGroup = namesData.nameGroups.western; + const gender = Math.random() > 0.5 ? 'Male' : 'Female'; // English: Male/Female + // Select name based on gender - use gender-specific name lists if available + let firstName; + if (nameGroup.first.male && nameGroup.first.female) { + // Use gender-specific name lists + firstName = randomElement(gender === 'Male' ? nameGroup.first.male : nameGroup.first.female); + } else { + // Fallback: use all names if gender classification not available + firstName = randomElement(nameGroup.first); + } + const lastName = randomElement(nameGroup.last); + + // Generate phone + const phoneCode = randomElement(region.phone_codes); + const phone = `0${phoneCode} ${randomInt(1000, 9999)} ${randomInt(100000, 999999)}`; + + // Generate email + const email = generateEmail(firstName, lastName); + + // Generate address + const streetNumbers = [randomInt(1, 999), randomInt(1, 999)]; + const streetName = randomElement([ + 'High Street', 'Church Road', 'Park Avenue', 'Main Road', 'London Road', + 'Victoria Street', 'King Street', 'Queen Street', 'Market Street', + 'Station Road', 'Mill Lane', 'Bridge Street', 'New Street', 'Old Street', + 'Castle Street', 'Church Street', 'School Lane', 'Garden Street', 'Hill Road', + 'Oak Avenue', 'Elm Street', 'Maple Drive', 'Cedar Road', 'Pine Street', + 'Rose Lane', 'Lily Street', 'Orchard Road', 'Meadow Way', 'River Street' + ]); + const street = `${streetNumbers[0]}${streetNumbers[1] > 0 ? '-' + streetNumbers[1] : ''} ${streetName}`; + + // Generate city - use region-specific cities if available, otherwise use fallback + let city; + if (region.cities && region.cities.length > 0) { + // Use cities from the region's city list + city = randomElement(region.cities); + } else { + // Fallback: use common UK city names (should not happen if data is complete) + const fallbackCities = [ + 'London', 'Manchester', 'Birmingham', 'Liverpool', 'Leeds', + 'Glasgow', 'Edinburgh', 'Bristol', 'Cardiff', 'Belfast' + ]; + city = randomElement(fallbackCities); + } + + // UK postcode format: SW1A 1AA - use region-specific postcode areas if available + let postcodeArea; + if (region.postcode_areas && region.postcode_areas.length > 0) { + // Use postcode areas from the region's list + postcodeArea = randomElement(region.postcode_areas); + } else { + // Fallback: use common UK postcode areas (should not happen if data is complete) + postcodeArea = randomElement(['SW', 'NW', 'SE', 'NE', 'W', 'E', 'N', 'S']); + } + const postcode = `${postcodeArea}${randomInt(1, 9)}${randomElement(['A', 'B', 'C'])} ${randomInt(1, 9)}${randomElement(['A', 'B', 'C'])}${randomElement(['A', 'B', 'C'])}`; + + // Use English region name for full address and region field + const regionNameEn = region.name.en || region.name.zh; + const fullAddress = `${street}, ${city}, ${postcode}, ${regionNameEn}`; + + return { + firstName, + lastName, + gender, + phone, + email, + street, + city, + postcode, + region: regionNameEn, // 显示英文地区名 + fullAddress, + country: '英国' + }; + } catch (error) { + console.error('Error generating UK address:', error); + throw error; + } +} + +// Generate Canada address +export async function generateCAAddress(selectedProvince = 'RANDOM') { + try { + const caData = await loadData('data/caData.json'); + const namesData = await loadData('data/namesData.json'); + + // Filter provinces based on selected province + let availableProvinces = {}; + if (selectedProvince === 'RANDOM') { + // Use all provinces + availableProvinces = caData.provinces; + } else if (caData.provinces[selectedProvince]) { + // Use only the selected province + availableProvinces[selectedProvince] = caData.provinces[selectedProvince]; + } else { + // Fallback to all provinces if invalid selection + availableProvinces = caData.provinces; + } + + // Select random province from filtered provinces + const provinces = Object.keys(availableProvinces); + if (provinces.length === 0) { + throw new Error('No provinces available for selected province'); + } + + const provinceKey = randomElement(provinces); + const province = availableProvinces[provinceKey]; + + // Generate name - decide gender first, then select name + const nameGroup = namesData.nameGroups.western; + const gender = Math.random() > 0.5 ? 'Male' : 'Female'; // English: Male/Female + // Select name based on gender - use gender-specific name lists if available + let firstName; + if (nameGroup.first.male && nameGroup.first.female) { + // Use gender-specific name lists + firstName = randomElement(gender === 'Male' ? nameGroup.first.male : nameGroup.first.female); + } else { + // Fallback: use all names if gender classification not available + firstName = randomElement(nameGroup.first); + } + const lastName = randomElement(nameGroup.last); + + // Generate phone + const areaCode = randomElement(province.area_codes); + const phone = `(${areaCode}) ${randomInt(200, 999)}-${randomInt(1000, 9999)}`; + + // Generate email + const email = generateEmail(firstName, lastName); + + // Generate address + const streetNumber = randomInt(100, 9999); + const streetName = randomElement([ + 'Main Street', 'Oak Avenue', 'Park Road', 'Maple Drive', 'Elm Street', + 'King Street', 'Queen Street', 'Church Street', 'Market Street', + 'First Street', 'Second Street', 'Third Avenue', 'Fourth Street', + 'Bay Street', 'Yonge Street', 'University Avenue', 'College Street', + 'Dundas Street', 'Bloor Street', 'Queen Street', 'King Street', + 'River Road', 'Lake Avenue', 'Hill Street', 'Valley Drive', + 'Forest Avenue', 'Garden Street', 'Rose Lane', 'Pine Street' + ]); + const street = `${streetNumber} ${streetName}`; + + // Generate city - use province-specific cities if available, otherwise use fallback + let city; + if (province.cities && province.cities.length > 0) { + // Use cities from the province's city list + city = randomElement(province.cities); + } else { + // Fallback: use common Canadian city names (should not happen if data is complete) + const fallbackCities = [ + 'Toronto', 'Vancouver', 'Montreal', 'Calgary', 'Ottawa', + 'Edmonton', 'Winnipeg', 'Quebec City', 'Hamilton', 'Halifax' + ]; + city = randomElement(fallbackCities); + } + + // Canadian postal code format: A1A 1A1 - use province-specific prefixes if available + let postcodePrefix; + if (province.postcode_prefixes && province.postcode_prefixes.length > 0) { + // Use postcode prefixes from the province's list + postcodePrefix = randomElement(province.postcode_prefixes); + } else { + // Fallback: use common Canadian postal code prefixes (should not happen if data is complete) + postcodePrefix = randomElement(['A', 'B', 'C', 'E', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'R', 'S', 'T', 'V', 'X', 'Y']); + } + const postcode = `${postcodePrefix}${randomInt(0, 9)}${randomElement(['A', 'B', 'C', 'E', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'R', 'S', 'T', 'V', 'W', 'X', 'Y', 'Z'])} ${randomInt(0, 9)}${randomElement(['A', 'B', 'C', 'E', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'R', 'S', 'T', 'V', 'W', 'X', 'Y', 'Z'])}${randomInt(0, 9)}`; + + // Use English province name for full address and province field + const provinceNameEn = province.name.en || province.name.zh; + const fullAddress = `${street}, ${city}, ${provinceNameEn} ${postcode}`; + + return { + firstName, + lastName, + gender, + phone, + email, + street, + city, + postcode, + province: provinceNameEn, // 显示英文省份名 + fullAddress, + country: '加拿大' + }; + } catch (error) { + console.error('Error generating CA address:', error); + throw error; + } +} + +// Generate Japan address +export async function generateJPAddress(selectedPrefecture = 'RANDOM') { + try { + const jpData = await loadData('data/jpData.json'); + const jpNamesData = await loadData('data/jpNamesData.json'); + const namesData = await loadData('data/namesData.json'); + + // Filter prefectures based on selected prefecture + let availablePrefectures = {}; + if (selectedPrefecture === 'RANDOM') { + // Use all prefectures + availablePrefectures = jpData.prefectures || {}; + } else if (jpData.prefectures && jpData.prefectures[selectedPrefecture]) { + // Use only the selected prefecture + availablePrefectures[selectedPrefecture] = jpData.prefectures[selectedPrefecture]; + } else { + // Fallback to all prefectures if invalid selection + availablePrefectures = jpData.prefectures || {}; + } + + // Select random prefecture from filtered prefectures + const prefectures = Object.keys(availablePrefectures); + if (prefectures.length === 0) { + throw new Error('No prefectures available for selected prefecture'); + } + + const prefectureKey = randomElement(prefectures); + const prefecture = availablePrefectures[prefectureKey]; + + // Generate name using Japanese names database (kanji, hiragana, katakana) + // Japanese surnames (姓) are almost always in kanji, rarely in hiragana/katakana + const surnameScriptTypes = ['kanji', 'kanji', 'kanji', 'kanji', 'kanji', 'kanji', 'kanji', 'kanji', 'kanji', 'hiragana']; // 90% kanji, 10% hiragana + const surnameScriptType = randomElement(surnameScriptTypes); + const lastNames = jpNamesData.surnames[surnameScriptType] || jpNamesData.surnames.kanji; + const lastName = randomElement(lastNames); + + // Japanese first names (名) can be in kanji, hiragana, or katakana + const firstNameScriptTypes = ['kanji', 'kanji', 'kanji', 'hiragana', 'katakana']; // 60% kanji, 20% hiragana, 20% katakana + const firstNameScriptType = randomElement(firstNameScriptTypes); + + const isMale = Math.random() > 0.5; + + // Get first name based on gender and script type + let firstName; + if (isMale) { + const maleNames = jpNamesData.firstNames.male[firstNameScriptType] || jpNamesData.firstNames.male.kanji; + firstName = randomElement(maleNames); + } else { + const femaleNames = jpNamesData.firstNames.female[firstNameScriptType] || jpNamesData.firstNames.female.kanji; + firstName = randomElement(femaleNames); + } + + // Gender in Japanese + const gender = isMale ? '男性' : '女性'; + + // Generate phone (Japan format: 0X-XXXX-XXXX) - use prefecture-specific phone codes if available + let phoneCode; + if (prefecture && prefecture.phone_codes && prefecture.phone_codes.length > 0) { + // Use phone codes from the prefecture's list + const selectedCode = randomElement(prefecture.phone_codes); + // Remove leading 0 if present and format + phoneCode = selectedCode.toString().replace(/^0+/, ''); + } else { + // Fallback: use random phone code (should not happen if data is complete) + phoneCode = randomInt(3, 9).toString(); + } + const phone = `0${phoneCode}-${randomInt(1000, 9999)}-${randomInt(1000, 9999)}`; + + // Generate email - use English names for email to avoid non-ASCII characters + const englishNameGroup = namesData.nameGroups.western || namesData.nameGroups.asian; + let emailFirstName, emailLastName; + if (englishNameGroup && englishNameGroup.first && englishNameGroup.last) { + const firstList = ensureNameArray(englishNameGroup.first); + const lastList = ensureNameArray(englishNameGroup.last); + // Use Japanese-style names from asian group if available + const japaneseRomajiNames = firstList.filter(n => + ['Hiroshi', 'Takeshi', 'Akira', 'Satoshi', 'Kenji', 'Taro', 'Jiro', 'Ichiro', 'Yuki', 'Ai', 'Emi', 'Yui', 'Rina', 'Miki', 'Saki', 'Nana', 'Kana', 'Mana', 'Hanako', 'Misaki', 'Sakura', 'Aya', 'Rei', 'Mai', 'Eri', 'Yuka'].includes(n) + ); + const japaneseRomajiLastNames = lastList.filter(n => + ['Sato', 'Suzuki', 'Takahashi', 'Tanaka', 'Watanabe', 'Ito', 'Yamamoto', 'Nakamura', 'Kobayashi', 'Kato', 'Yoshida', 'Yamada', 'Sasaki', 'Yamaguchi', 'Matsumoto', 'Inoue', 'Kimura', 'Hayashi', 'Shimizu', 'Yamazaki', 'Mori', 'Abe', 'Ikeda', 'Hashimoto', 'Ishikawa', 'Maeda', 'Fujita', 'Ogawa', 'Goto', 'Okada'].includes(n) + ); + + if (japaneseRomajiNames.length > 0 && japaneseRomajiLastNames.length > 0) { + emailFirstName = randomElement(japaneseRomajiNames); + emailLastName = randomElement(japaneseRomajiLastNames); + } else { + emailFirstName = randomElement(firstList); + emailLastName = randomElement(lastList); + } + } else { + // Fallback: generate random English username + const randomNames = ['john', 'mary', 'david', 'sarah', 'michael', 'emily', 'james', 'lisa', 'robert', 'anna']; + emailFirstName = randomElement(randomNames); + emailLastName = randomElement(randomNames); + } + const email = generateEmail(emailFirstName, emailLastName); + + // Generate address using real Japanese address data + // Use kanji for addresses (most common in real addresses) + const addressData = jpData.address_data || {}; + + // City - use prefecture-specific cities if available, otherwise use fallback + let city; + if (prefecture && prefecture.cities && prefecture.cities.length > 0) { + // Use cities from the prefecture's city list + city = randomElement(prefecture.cities); + } else { + // Fallback: use common Japanese city names (should not happen if data is complete) + const fallbackCities = addressData.cities?.kanji || ['東京', '大阪', '京都', '横浜', '名古屋', '札幌', '福岡', '神戸', '仙台', '広島', '千葉', '埼玉', '新潟', '静岡', '浜松', '岡山', '熊本', '鹿児島', '長崎', '大分']; + city = randomElement(fallbackCities); + } + + // Ward/丁目 (use kanji) + const wards = addressData.wards?.kanji || ['1丁目', '2丁目', '3丁目', '4丁目', '5丁目', '6丁目', '7丁目', '8丁目', '9丁目', '10丁目']; + const ward = randomElement(wards); + + // Street name (use kanji from real street data) + const streets = addressData.streets?.kanji || ['中央', '本町', '新町', '大通', '駅前', '公園', '桜', '松', '竹', '梅', '富士', '山', '川', '海', '森', '田', '橋', '坂', '谷', '原']; + const streetName = randomElement(streets); + + // District (区/市) - optional, sometimes included + const includeDistrict = Math.random() > 0.7; // 30% chance to include district + let district = ''; + if (includeDistrict && addressData.districts?.kanji) { + district = randomElement(addressData.districts.kanji) + ''; + } + + const streetNumber = randomInt(1, 50); + const buildingNumber = randomInt(1, 20); + + // Build address: [district] [street] [ward] [number]番[building]号 + let address; + if (district) { + address = `${district}${streetName}${ward}${streetNumber}番${buildingNumber}号`; + } else { + address = `${streetName}${ward}${streetNumber}番${buildingNumber}号`; + } + + // Japanese postal code format: 123-4567 (使用都道府県的邮编前缀) + let postcodePrefix = '100'; + if (prefecture && prefecture.postal_prefix && prefecture.postal_prefix.length > 0) { + postcodePrefix = randomElement(prefecture.postal_prefix); + } + const postcode = `${postcodePrefix}-${randomInt(1000, 9999)}`; + + // 使用日文都道府県名称 + const prefectureName = prefecture ? (prefecture.name.ja || prefecture.name.zh) : '東京都'; + const fullAddress = `〒${postcode} ${prefectureName}${city}${address}`; + + return { + firstName, + lastName, + gender, + phone, + email, + street: address, + city, + prefecture: prefectureName, + postcode, + fullAddress, + country: '日本' + }; + } catch (error) { + console.error('Error generating JP address:', error); + throw error; + } +} + +// Generate India address +export async function generateINAddress(selectedState = 'RANDOM') { + try { + const inData = await loadData('data/inData.json'); + const namesData = await loadData('data/namesData.json'); + + // Filter states based on selected state + let availableStates = {}; + if (selectedState === 'RANDOM') { + // Use all states + availableStates = inData.states; + } else if (inData.states[selectedState]) { + // Use only the selected state + availableStates[selectedState] = inData.states[selectedState]; + } else { + // Fallback to all states if invalid selection + availableStates = inData.states; + } + + // Select random state from filtered states + const states = Object.keys(availableStates); + if (states.length === 0) { + throw new Error('No states available for selected state'); + } + + const stateKey = randomElement(states); + const state = availableStates[stateKey]; + + // Generate name (Indian) - decide gender first, then select name + const nameGroup = namesData.nameGroups.indian; + const gender = Math.random() > 0.5 ? 'Male' : 'Female'; // English: Male/Female + // Select name based on gender - use gender-specific name lists if available + let firstName; + if (nameGroup.first.male && nameGroup.first.female) { + // Use gender-specific name lists + firstName = randomElement(gender === 'Male' ? nameGroup.first.male : nameGroup.first.female); + } else { + // Fallback: use all names if gender classification not available + firstName = randomElement(nameGroup.first); + } + const lastName = randomElement(nameGroup.last); + + // Generate phone (India format: +91 XXXXXXXXXX) - use state-specific area codes if available + let phoneNumber; + if (state.area_codes && state.area_codes.length > 0) { + // Use area codes from the state's list (format: XXXX, convert to phone number) + const areaCode = randomElement(state.area_codes); + // Indian mobile numbers start with 6-9, generate remaining digits + const mobilePrefix = randomElement([6, 7, 8, 9]); + const remainingDigits = randomInt(10000000, 99999999); + phoneNumber = `${mobilePrefix}${remainingDigits}`; + } else { + // Fallback: use random phone number (should not happen if data is complete) + phoneNumber = randomInt(6000000000, 9999999999).toString(); + } + const phone = `+91 ${phoneNumber}`; + + // Generate email + const email = generateEmail(firstName, lastName); + + // Generate address + const streetNumber = randomInt(1, 999); + const streetName = randomElement([ + 'Main Road', 'Gandhi Street', 'Nehru Road', 'Park Street', 'Market Road', + 'Church Street', 'Temple Street', 'School Road', 'Hospital Road', + 'MG Road', 'Station Road', 'Airport Road', 'Highway Road', 'Ring Road', + 'First Street', 'Second Street', 'Third Street', 'Fourth Street', + 'Gandhi Nagar', 'Nehru Nagar', 'Rajiv Nagar', 'Indira Nagar', + 'College Road', 'University Road', 'Library Road', 'Museum Road' + ]); + const street = `${streetNumber}, ${streetName}`; + + // Generate city - use state-specific cities if available, otherwise use fallback + let city; + if (state.cities && state.cities.length > 0) { + // Use cities from the state's city list + city = randomElement(state.cities); + } else { + // Fallback: use common Indian city names (should not happen if data is complete) + const fallbackCities = [ + 'Mumbai', 'Delhi', 'Bangalore', 'Hyderabad', 'Chennai', + 'Kolkata', 'Pune', 'Ahmedabad', 'Jaipur', 'Surat' + ]; + city = randomElement(fallbackCities); + } + + // Indian PIN code (6 digits) - use state-specific PIN range if available + let pin; + if (state.pin_range && state.pin_range.min && state.pin_range.max) { + // Use PIN code range from the state's pin_range + pin = randomInt(state.pin_range.min, state.pin_range.max).toString(); + } else { + // Fallback: use random 6-digit PIN code (should not happen if data is complete) + pin = randomInt(100000, 999999).toString(); + } + + // Use English state name for full address and state field + const stateNameEn = state.name.en || state.name.zh; + const fullAddress = `${street}, ${city}, ${stateNameEn} ${pin}`; + + return { + firstName, + lastName, + gender, + phone, + email, + street, + city, + state: stateNameEn, // 显示英文邦名 + pin, + fullAddress, + country: '印度' + }; + } catch (error) { + console.error('Error generating IN address:', error); + throw error; + } +} + +// Generate Taiwan address +export async function generateTWAddress(selectedCounty = 'RANDOM') { + try { + const namesData = await loadData('data/namesData.json'); + + // Try to load Taiwan data + let twData = null; + let selectedCountyData = null; + try { + twData = await loadData('data/twData.json'); + if (twData && twData.counties) { + // Filter counties based on selected county + let availableCounties = {}; + if (selectedCounty === 'RANDOM') { + // Use all counties + availableCounties = twData.counties; + } else if (twData.counties[selectedCounty]) { + // Use only the selected county + availableCounties[selectedCounty] = twData.counties[selectedCounty]; + } else { + // Fallback to all counties if invalid selection + availableCounties = twData.counties; + } + + // Select random county from filtered counties + const counties = Object.keys(availableCounties); + if (counties.length > 0) { + const countyKey = randomElement(counties); + selectedCountyData = availableCounties[countyKey]; + } + } + } catch (e) { + // If data file doesn't exist, use fallback + console.warn('Taiwan data file not found, using fallback'); + } + + // Generate name (Chinese) - decide gender first, then select name + const nameGroup = namesData.nameGroups.chinese; + const gender = Math.random() > 0.5 ? '男' : '女'; // 中文:男/女 + const firstName = randomElement(gender === '男' ? nameGroup.first.male : nameGroup.first.female); + const lastName = randomElement(nameGroup.last); + + // Generate phone (Taiwan format: 09XX-XXX-XXX) - use county-specific area codes if available + let phoneAreaCode; + if (selectedCountyData && selectedCountyData.phone_area_codes && selectedCountyData.phone_area_codes.length > 0) { + // Use phone area codes from the county's list + phoneAreaCode = randomElement(selectedCountyData.phone_area_codes); + } else { + // Fallback: use random area code (should not happen if data is complete) + phoneAreaCode = randomInt(2, 9); + } + const phone = `09${phoneAreaCode}${randomInt(10, 99)}-${randomInt(100, 999)}-${randomInt(100, 999)}`; + + // Generate email - use English names for email to avoid Chinese characters + const englishNameGroup = namesData.nameGroups.western || namesData.nameGroups.asian; + let emailFirstName, emailLastName; + if (englishNameGroup && englishNameGroup.first && englishNameGroup.last) { + const firstList = ensureNameArray(englishNameGroup.first); + const lastList = ensureNameArray(englishNameGroup.last); + emailFirstName = randomElement(firstList); + emailLastName = randomElement(lastList); + } else { + // Fallback: generate random English username + const randomNames = ['john', 'mary', 'david', 'sarah', 'michael', 'emily', 'james', 'lisa', 'robert', 'anna']; + emailFirstName = randomElement(randomNames); + emailLastName = randomElement(randomNames); + } + const email = generateEmail(emailFirstName, emailLastName); + + // Generate address + // Use county data if available, otherwise use fallback cities + let city, district; + if (selectedCountyData && selectedCountyData.name) { + city = selectedCountyData.name.zh; + // Use common districts for the selected city + district = randomElement(['中正區', '大同區', '中山區', '松山區', '大安區', '萬華區', '信義區', '士林區', '北投區', '內湖區', '南港區', '文山區']); + } else { + // Fallback cities + const cities = ['台北市', '新北市', '台中市', '台南市', '高雄市', '桃園市']; + city = randomElement(cities); + district = randomElement(['中正區', '大同區', '中山區', '松山區', '大安區', '萬華區']); + } + + const street = randomElement([ + '中山路', '中正路', '民生路', '民權路', '民族路', + '建國路', '復興路', '和平路', '自由路', '成功路', + '忠孝路', '仁愛路', '信義路', '和平路', '光復路', + '中華路', '文化路', '大學路', '公園路', '車站路', + '中央路', '大同路', '大安路', '大業路', '大興路', + '新生路', '新興路', '新市路', '新莊路', '新店路' + ]); + const streetNumber = randomInt(1, 999); + const address = `${city}${district}${street}${streetNumber}號`; + + // Taiwan postal code (5 digits) - use county-specific postcode range if available + let postcode; + if (selectedCountyData && selectedCountyData.postcode_range && selectedCountyData.postcode_range.min && selectedCountyData.postcode_range.max) { + // Use postcode range from the county's postcode_range + postcode = randomInt(selectedCountyData.postcode_range.min, selectedCountyData.postcode_range.max).toString(); + } else { + // Fallback: use random 5-digit postcode (should not happen if data is complete) + postcode = randomInt(10000, 99999).toString(); + } + + const fullAddress = `${address}, 郵遞區號: ${postcode}`; + + return { + firstName, + lastName, + gender, + phone, + email, + street: address, + city, + district, + postcode, + fullAddress, + country: '台灣' + }; + } catch (error) { + console.error('Error generating TW address:', error); + throw error; + } +} + +// Generate tax-free US address (only from tax-free states) +export async function generateTaxFreeAddress(selectedState = 'DE') { + const taxFreeStates = ['AK', 'DE', 'MT', 'NH', 'OR']; + + if (!taxFreeStates.includes(selectedState)) { + selectedState = randomElement(taxFreeStates); + } + + return await generateUSAddress(selectedState); +} + +// Generate identity information +export async function generateIdentityInfo(address) { + try { + // Load names data - use the same loadData function which handles paths correctly + const namesData = await loadData('data/namesData.json'); + if (!namesData || !namesData.nameGroups) { + throw new Error('Names data not available'); + } + + // Use the same name group as the address based on country + let nameGroup; + if (address.country === '香港' || address.country === '台灣') { + nameGroup = namesData.nameGroups.chinese; + } else if (address.country === '印度') { + nameGroup = namesData.nameGroups.indian; + } else if (address.country === '日本') { + nameGroup = namesData.nameGroups.asian || namesData.nameGroups.western; + } else { + // Default to western names for US, UK, Canada, etc. + nameGroup = namesData.nameGroups.western || namesData.nameGroups.asian; + } + if (!nameGroup) { + throw new Error('Name group not found'); + } + + // Resolve gender for identity (name selection + Taiwan ID gender digit) + let isMaleForIdentity; + if (address.gender) { + const g = address.gender; + const isMale = g === 'Male' || g === 'male' || g === 'm' || g === '男' || g === '男性' || + (typeof g === 'string' && (g.includes('Männlich') || g.includes('男'))); + const isFemale = g === 'Female' || g === 'female' || g === 'f' || g === '女' || g === '女性' || + (typeof g === 'string' && (g.includes('Weiblich') || g.includes('女'))); + isMaleForIdentity = isMale ? true : (isFemale ? false : Math.random() > 0.5); + } else { + isMaleForIdentity = Math.random() > 0.5; + } + + // Generate name based on address gender if available + let firstName, lastName; + if (address.gender) { + const genderKey = address.gender.toLowerCase(); + const isMale = genderKey === 'male' || genderKey === 'm' || address.gender === '男' || address.gender === '男性' || + address.gender.includes('Männlich') || address.gender.includes('男'); + const isFemale = genderKey === 'female' || genderKey === 'f' || address.gender === '女' || address.gender === '女性' || + address.gender.includes('Weiblich') || address.gender.includes('女'); + + // Use gender-specific name lists if available + if (nameGroup.first.male && nameGroup.first.female) { + if (isMale) { + firstName = randomElement(nameGroup.first.male); + } else if (isFemale) { + firstName = randomElement(nameGroup.first.female); + } else { + // Fallback: randomly choose from either gender + firstName = randomElement(Math.random() > 0.5 ? nameGroup.first.male : nameGroup.first.female); + } + } else { + // Fallback: use all names if gender classification not available + firstName = randomElement(Array.isArray(nameGroup.first) ? nameGroup.first : []); + } + } else { + // If no gender specified, randomly choose gender and corresponding name + const randomGender = Math.random() > 0.5; + if (nameGroup.first.male && nameGroup.first.female) { + firstName = randomElement(randomGender ? nameGroup.first.male : nameGroup.first.female); + } else { + firstName = randomElement(Array.isArray(nameGroup.first) ? nameGroup.first : []); + } + } + lastName = randomElement(nameGroup.last); + + // Generate date of birth (age between 20 and 50) + const age = randomInt(20, 50); + const birthYear = new Date().getFullYear() - age; + const birthMonth = randomInt(1, 12); + const daysInMonth = new Date(birthYear, birthMonth, 0).getDate(); + const birthDay = randomInt(1, daysInMonth); + const dateOfBirth = `${birthMonth.toString().padStart(2, '0')}/${birthDay.toString().padStart(2, '0')}/${birthYear}`; + + // Generate identity ID based on country + let ssn; + const country = address.country || ''; + + if (country.includes('德国') || country.includes('Germany')) { + // Generate German Steuer-ID (Tax ID): 11 digits, format: XX XXX XXX XXX + const part1 = randomInt(10, 99); + const part2 = randomInt(100, 999); + const part3 = randomInt(100, 999); + const part4 = randomInt(100, 999); + ssn = `${part1} ${part2} ${part3} ${part4}`; + } else if (country.includes('英国') || country.includes('UK') || country.includes('United Kingdom')) { + // Generate UK NINO: Format: AA 12 34 56 A + const letters1 = 'ABCDEFGHJKLMNOPRSTUVWXYZ'; + const letters2 = 'ABCDEFGHJKLMNOPRSTUVWXYZ'; + const letter1 = randomElement(letters1.split('')); + const letter2 = randomElement(letters2.split('')); + const digits = randomInt(100000, 999999); + const letter3 = randomElement(letters2.split('')); + ssn = `${letter1}${letter2} ${digits.toString().slice(0, 2)} ${digits.toString().slice(2, 4)} ${digits.toString().slice(4, 6)} ${letter3}`; + } else if (country.includes('加拿大') || country.includes('Canada')) { + // Generate Canadian SIN: Format: XXX XXX XXX + const sin1 = randomInt(100, 999); + const sin2 = randomInt(100, 999); + const sin3 = randomInt(100, 999); + ssn = `${sin1} ${sin2} ${sin3}`; + } else if (country.includes('日本') || country.includes('Japan')) { + // Generate Japanese My Number: Format: XXXX-XXXX-XXXX + const myNum1 = randomInt(1000, 9999); + const myNum2 = randomInt(1000, 9999); + const myNum3 = randomInt(1000, 9999); + ssn = `${myNum1}-${myNum2}-${myNum3}`; + } else if (country.includes('印度') || country.includes('India')) { + // Generate Indian Aadhaar: Format: XXXX XXXX XXXX + const aadhaar1 = randomInt(1000, 9999); + const aadhaar2 = randomInt(1000, 9999); + const aadhaar3 = randomInt(1000, 9999); + ssn = `${aadhaar1} ${aadhaar2} ${aadhaar3}`; + } else if (country.includes('香港') || country.includes('Hong Kong')) { + // Generate Hong Kong ID Card: Format: A123456(7) or AB123456(7) + // 70%概率单字母,30%概率双字母 + const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; + const isDoubleLetter = Math.random() < 0.3; + let prefix; + if (isDoubleLetter) { + const letter1 = randomElement(letters.split('')); + const letter2 = randomElement(letters.split('')); + prefix = `${letter1}${letter2}`; + } else { + prefix = randomElement(letters.split('')); + } + const digits = randomInt(100000, 999999).toString(); + const checkDigit = randomInt(0, 9); + ssn = `${prefix}${digits}(${checkDigit})`; + } else if (country.includes('台灣') || country.includes('台湾') || country.includes('Taiwan')) { + // Generate Taiwan ID Card: Format: A123456789 + // 1st: letter (birthplace), 2nd: gender (1=Male, 2=Female), 3rd-9th: sequence + const letters = 'ABCDEFGHJKLMNPQRSTUVXY'; + const firstLetter = randomElement(letters.split('')); + const genderDigit = isMaleForIdentity ? '1' : '2'; // 1=男, 2=女 + const sequenceDigits = randomInt(10000000, 99999999).toString(); + ssn = `${firstLetter}${genderDigit}${sequenceDigits}`; + } else if (country.includes('新加坡') || country.includes('Singapore')) { + // Generate Singapore NRIC: Format: S1234567D (prefix + 7 digits + check letter) + // S=citizen pre-2000, T=citizen 2000+, G/F=PR. Prefix should match birth year + let prefix; + if (birthYear < 2000) { + prefix = 'S'; // Born before 2000 + } else { + prefix = 'T'; // Born in 2000 or later + } + // Small chance to be PR (G prefix) regardless of year + if (Math.random() < 0.1) { + prefix = 'G'; // Permanent Resident + } + const digits = randomInt(1000000, 9999999).toString(); + const checkLetters = 'ABCDEFGHIZJ'; + const checkLetter = randomElement(checkLetters.split('')); + ssn = `${prefix}${digits}${checkLetter}`; + } else { + // Default: US SSN format (XXX-XX-XXXX) + // SSN Area Number (first 3 digits) should match the state if available + let ssnAreaNumber; + if (address.stateCode && address.country === '美国') { + try { + const usData = await loadData('data/usData.json'); + const state = usData.states[address.stateCode]; + if (state && state.ssn_area_range && state.ssn_area_range.min && state.ssn_area_range.max) { + // Use state-specific SSN area number range + ssnAreaNumber = randomInt(state.ssn_area_range.min, state.ssn_area_range.max); + } else { + // Fallback: use random area number (avoid 000, 666, and 900-999) + do { + ssnAreaNumber = randomInt(1, 899); + } while (ssnAreaNumber === 666 || ssnAreaNumber < 1); + } + } catch (e) { + // If loading fails, use fallback + do { + ssnAreaNumber = randomInt(1, 899); + } while (ssnAreaNumber === 666 || ssnAreaNumber < 1); + } + } else { + // For non-US addresses or if stateCode is not available, use random area number + do { + ssnAreaNumber = randomInt(1, 899); + } while (ssnAreaNumber === 666 || ssnAreaNumber < 1); + } + + // Generate Group Number (middle 2 digits, cannot be 00) + const groupNumber = randomInt(1, 99); + + // Generate Serial Number (last 4 digits, cannot be 0000) + const serialNumber = randomInt(1, 9999); + + // Format SSN: XXX-XX-XXXX + ssn = `${ssnAreaNumber.toString().padStart(3, '0')}-${groupNumber.toString().padStart(2, '0')}-${serialNumber.toString().padStart(4, '0')}`; + } + + // Generate occupation (random job title) + const occupations = [ + 'Software Engineer', 'Teacher', 'Doctor', 'Nurse', 'Engineer', + 'Accountant', 'Lawyer', 'Manager', 'Sales Representative', 'Designer', + 'Marketing Specialist', 'Consultant', 'Analyst', 'Administrator', 'Director', + 'Developer', 'Architect', 'Coordinator', 'Supervisor', 'Assistant' + ]; + const occupation = randomElement(occupations); + + return { + firstName, + lastName, + dateOfBirth, + age, + ssn, + occupation + }; + } catch (error) { + console.error('Error generating identity info:', error); + throw error; + } +} + +// Generate credit card information +export async function generateCreditCardInfo() { + // Card types with their prefixes + const cardTypes = [ + { name: 'Visa', prefixes: ['4'] }, + { name: 'MasterCard', prefixes: ['51', '52', '53', '54', '55'] }, + { name: 'American Express', prefixes: ['34', '37'] }, + { name: 'Discover', prefixes: ['6011'] } + ]; + + // Select random card type + const selectedCardType = randomElement(cardTypes); + const prefix = randomElement(selectedCardType.prefixes); + + // Generate random credit card number (16 digits, Luhn algorithm) + function generateLuhnNumber(prefix, length = 16) { + let cardNumber = prefix; + // Generate remaining digits (excluding prefix and check digit) + const remainingDigits = length - prefix.length - 1; + for (let i = 0; i < remainingDigits; i++) { + cardNumber += randomInt(0, 9).toString(); + } + + // Calculate check digit using Luhn algorithm + let sum = 0; + let double = false; + for (let i = cardNumber.length - 1; i >= 0; i--) { + let digit = parseInt(cardNumber[i]); + if (double) { + digit *= 2; + if (digit > 9) digit -= 9; + } + sum += digit; + double = !double; + } + const checkDigit = (10 - (sum % 10)) % 10; + cardNumber += checkDigit.toString(); + + return cardNumber; + } + + // Generate card number (16 digits for most cards, 15 for Amex) + const cardLength = selectedCardType.name === 'American Express' ? 15 : 16; + const cardNumber = generateLuhnNumber(prefix, cardLength); + + // Format as XXXX XXXX XXXX XXXX (or XXXX XXXXXX XXXXX for Amex) + const formattedCardNumber = cardNumber.match(/.{1,4}/g).join(' '); + + // Generate expiration date (future date, 1-5 years from now) + const currentYear = new Date().getFullYear(); + const expYear = currentYear + randomInt(1, 5); + const expMonth = randomInt(1, 12); + const expirationDate = `${expMonth.toString().padStart(2, '0')}/${expYear.toString().slice(-2)}`; + + // Generate CVV (3 digits for most cards, 4 for Amex) + const cvvLength = selectedCardType.name === 'American Express' ? 4 : 3; + const cvv = randomInt(Math.pow(10, cvvLength - 1), Math.pow(10, cvvLength) - 1).toString(); + + // Generate cardholder name (random Western name) + const names = ['John', 'Mary', 'David', 'Sarah', 'Michael', 'Emily', 'James', 'Lisa', 'Robert', 'Anna']; + const surnames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez']; + const cardholderName = `${randomElement(names)} ${randomElement(surnames)}`; + + return { + type: selectedCardType.name, + number: formattedCardNumber, + rawNumber: cardNumber, + expiryDate: expirationDate, + expirationDate: expirationDate, + cvv, + cardholderName + }; +} + +// Generate Singapore address +export async function generateSGAddress(selectedState = 'RANDOM') { + try { + const sgData = await loadData('data/sgData.json'); + const namesData = await loadData('data/namesData.json'); + + let availableStates = {}; + if (selectedState === 'RANDOM') { + availableStates = sgData.states; + } else if (sgData.states[selectedState]) { + availableStates[selectedState] = sgData.states[selectedState]; + } else { + availableStates = sgData.states; + } + + const states = Object.keys(availableStates); + if (states.length === 0) throw new Error('No regions available'); + + const stateKey = randomElement(states); + const state = availableStates[stateKey]; + + const nameGroup = namesData.nameGroups.western; + const genderRaw = Math.random() > 0.5 ? 'Male' : 'Female'; + let firstName; + if (nameGroup.first.male && nameGroup.first.female) { + firstName = randomElement(genderRaw === 'Male' ? nameGroup.first.male : nameGroup.first.female); + } else { + firstName = randomElement(nameGroup.first); + } + const lastName = randomElement(nameGroup.last); + const gender = genderRaw === 'Male' ? 'Male' : 'Female'; + + const mobilePrefix = randomElement(['8', '9']); + const subscriberNumber = randomInt(1000000, 9999999); + const phone = `+65 ${mobilePrefix}${subscriberNumber}`; + + const email = generateEmail(firstName, lastName); + + const cities = Object.keys(state.cities); + if (cities.length === 0) throw new Error(`No cities for region ${stateKey}`); + + const cityName = randomElement(cities); + const cityData = state.cities[cityName]; + + if (!cityData || !cityData.zip || !cityData.streets) { + throw new Error(`Invalid city data for ${cityName}`); + } + + const zip = randomElement(cityData.zip); + const streetName = randomElement(cityData.streets); + + const useBlockFormat = Math.random() > 0.3; + let street; + if (useBlockFormat) { + const blockNum = randomInt(1, 999); + const floor = randomInt(1, 25); + const unit = randomInt(1, 12); + street = `Block ${blockNum}, ${streetName}, #${floor.toString().padStart(2, '0')}-${unit.toString().padStart(2, '0')}`; + } else { + const unitNum = randomInt(1, 200); + const floor = randomInt(1, 30); + const unit = randomInt(1, 8); + street = `${unitNum} ${streetName}, #${floor}-${unit}`; + } + + const stateNameEn = state.name.en || state.name.zh; + const fullAddress = `${street}, Singapore ${zip}`; + + return { + firstName, + lastName, + gender, + phone, + email, + street, + city: cityName, + zip, + postcode: zip, + state: stateNameEn, + stateCode: stateKey, + fullAddress, + country: '新加坡' + }; + } catch (error) { + console.error('Error generating SG address:', error); + throw error; + } +} + +// Generate Germany address +export async function generateDEAddress(selectedState = 'RANDOM') { + try { + const deData = await loadData('data/deData.json'); + const namesData = await loadData('data/namesData.json'); + + // Filter states based on selected state + let availableStates = {}; + if (selectedState === 'RANDOM') { + // Use all states + availableStates = deData.states; + } else if (deData.states[selectedState]) { + // Use only the selected state + availableStates[selectedState] = deData.states[selectedState]; + } else { + // Fallback to all states if invalid selection + availableStates = deData.states; + } + + // Select random state from filtered states + const states = Object.keys(availableStates); + if (states.length === 0) { + throw new Error('No states available for selected state'); + } + + const stateKey = randomElement(states); + const state = availableStates[stateKey]; + + // Generate name - decide gender first, then select name + const nameGroup = namesData.nameGroups.western; + const genderRaw = Math.random() > 0.5 ? 'Male' : 'Female'; + // Select name based on gender - use gender-specific name lists if available + let firstName; + if (nameGroup.first.male && nameGroup.first.female) { + // Use gender-specific name lists + firstName = randomElement(genderRaw === 'Male' ? nameGroup.first.male : nameGroup.first.female); + } else { + // Fallback: use all names if gender classification not available + firstName = randomElement(nameGroup.first); + } + const lastName = randomElement(nameGroup.last); + + // Convert gender to German format for display: Männlich (男) / Weiblich (女) + const gender = genderRaw === 'Male' ? 'Männlich (男)' : 'Weiblich (女)'; + + // Generate phone - German mobile format: +49 1xx xxxxxxx + // Mobile prefixes: 151, 160, 170, 171, 175 (Telekom); 152, 162, 172, 173, 174 (Vodafone); 157, 163, 176, 177, 178 (O2) + const mobilePrefixes = ['151', '160', '170', '171', '175', '152', '162', '172', '173', '174', '157', '163', '176', '177', '178']; + const prefix = randomElement(mobilePrefixes); + const subscriberNumber = randomInt(1000000, 9999999); + const phone = `+49 ${prefix} ${subscriberNumber}`; + + // Generate email - use generateEmail function (ensures no dot before @) + const email = generateEmail(firstName, lastName); + + // Generate address - select random city from state + // IMPORTANT: Ensure city is selected from the correct state to avoid cross-state mismatches + const cities = Object.keys(state.cities); + if (cities.length === 0) { + throw new Error(`No cities available for state ${stateKey}`); + } + + const cityName = randomElement(cities); + const cityData = state.cities[cityName]; + + // Validate city data structure + if (!cityData || !cityData.zip || !cityData.streets) { + throw new Error(`Invalid city data structure for ${cityName} in state ${stateKey}`); + } + + // Get zip code from city data (ensures zip matches the city) + if (cityData.zip.length === 0) { + throw new Error(`No zip codes available for city ${cityName} in state ${stateKey}`); + } + const zip = randomElement(cityData.zip); + + // Get street name from city data (ensures street matches the city) + if (cityData.streets.length === 0) { + throw new Error(`No streets available for city ${cityName} in state ${stateKey}`); + } + const streetName = randomElement(cityData.streets); + + // Generate house number (1-150, occasionally with letter suffix like 12a) + const houseNumber = randomInt(1, 150); + const suffix = Math.random() < 0.2 ? String.fromCharCode(97 + randomInt(0, 2)) : ''; + const street = `${streetName} ${houseNumber}${suffix}`; + + // Use English state name for full address and state field + const stateNameEn = state.name.en || state.name.zh; + const fullAddress = `${street}, ${zip} ${cityName}, ${stateNameEn}, Germany`; + + return { + firstName, + lastName, + gender, + phone, + email, + street, + city: cityName, + zip, + postcode: zip, + state: stateNameEn, + stateCode: stateKey, + fullAddress, + country: '德国' + }; + } catch (error) { + console.error('Error generating DE address:', error); + throw error; + } +} diff --git a/src/js/config.js b/src/js/config.js new file mode 100644 index 0000000..07ef988 --- /dev/null +++ b/src/js/config.js @@ -0,0 +1,49 @@ +/** + * MockAddress Core 配置模块 + * 允许用户自定义数据路径和其他设置,不影响正式站点 + */ + +// 默认配置(用于正式站点 mockaddress.com) +const defaultConfig = { + // 数据文件基础路径 + // 如果用户想用自己的数据,可以设置为 'my-data/' 或 '/custom/path/data/' + dataBasePath: null, // null 表示使用自动路径检测(正式站点行为) + + // 是否启用自动路径检测(针对多语言目录结构) + // 如果设为 false,则只使用 dataBasePath + autoDetectPaths: true, + + // 自定义数据加载器(可选) + // 如果提供,将优先使用此函数加载数据,而不是默认的 fetch + customDataLoader: null +}; + +// 用户配置(会被 merge 到默认配置) +let userConfig = {}; + +/** + * 初始化配置 + * @param {Object} config - 用户配置对象 + * @example + * MockAddressCore.config({ + * dataBasePath: 'my-data/', + * autoDetectPaths: false + * }); + */ +export function configure(config = {}) { + userConfig = { ...defaultConfig, ...config }; +} + +/** + * 获取当前配置 + */ +export function getConfig() { + return { ...defaultConfig, ...userConfig }; +} + +/** + * 重置配置为默认值 + */ +export function resetConfig() { + userConfig = {}; +} diff --git a/src/js/language-switcher.js b/src/js/language-switcher.js new file mode 100644 index 0000000..e1eb5a8 --- /dev/null +++ b/src/js/language-switcher.js @@ -0,0 +1,319 @@ +/** + * 语言切换模块 + * 处理多语言切换和跳转 + */ + +// 支持的语言配置 +const languages = { + 'zh': { + code: 'zh', + name: '简体中文', + nativeName: '简体中文', + flag: '🇨🇳', + path: '' // 根目录 + }, + 'en': { + code: 'en', + name: 'English', + nativeName: 'English', + flag: '🇬🇧', + path: '/en' + }, + 'ru': { + code: 'ru', + name: 'Русский', + nativeName: 'Русский', + flag: '🇷🇺', + path: '/ru' + }, + 'es': { + code: 'es', + name: 'Español', + nativeName: 'Español', + flag: '🇪🇸', + path: '/es' + }, + 'pt': { + code: 'pt', + name: 'Português', + nativeName: 'Português (BR)', + flag: '🇧🇷', + path: '/pt' + } +}; + +/** + * 获取当前语言代码 + */ +function getCurrentLanguage() { + const path = window.location.pathname; + + // 从 URL 路径判断 + if (path.startsWith('/en/') || path.startsWith('/en')) { + return 'en'; + } else if (path.startsWith('/ru/') || path.startsWith('/ru')) { + return 'ru'; + } else if (path.startsWith('/es/') || path.startsWith('/es')) { + return 'es'; + } else if (path.startsWith('/pt/') || path.startsWith('/pt')) { + return 'pt'; + } + return 'zh'; // 默认中文 +} + +/** + * 获取当前页面路径(去除语言前缀,SEO友好格式) + * 将 /index.html 转换为 /,生成更友好的URL + */ +function getCurrentPagePath() { + const path = window.location.pathname; + const currentLang = getCurrentLanguage(); + + let pagePath = path; + + // 移除语言前缀(如果存在) + if (currentLang !== 'zh') { + const langPrefix = `/${currentLang}`; + if (path.startsWith(langPrefix)) { + pagePath = path.substring(langPrefix.length) || '/'; + } + } + + // 将 /index.html 转换为 / (SEO友好) + // 将 /xxx/index.html 转换为 /xxx/ + if (pagePath.endsWith('/index.html')) { + pagePath = pagePath.replace(/\/index\.html$/, '/'); + } else if (pagePath === '/index.html') { + pagePath = '/'; + } + + // 确保路径以 / 开头 + if (!pagePath.startsWith('/')) { + pagePath = '/' + pagePath; + } + + // 目录页:如果不是根路径,确保以 / 结尾(SEO友好) + // 但文章页/文件页(.html)不能追加 /,否则会变成 xxx.html/ 导致资源相对路径解析错误 + const isHtmlFile = /\.html$/i.test(pagePath); + if (!isHtmlFile && pagePath !== '/' && !pagePath.endsWith('/')) { + pagePath = pagePath + '/'; + } + + return pagePath; +} + +/** + * 切换到指定语言 + */ +function switchLanguage(langCode) { + try { + const targetLang = languages[langCode]; + if (!targetLang) { + console.error(`Unsupported language: ${langCode}`); + return; + } + + const currentPagePath = getCurrentPagePath(); + + // 跳转到对应语言路径(包含博客 /post/ 在内) + const targetPath = targetLang.path + currentPagePath; + + // 防止重复跳转 + if (window.location.href === window.location.origin + targetPath) { + return; + } + + window.location.href = targetPath; + } catch (error) { + console.error('Error switching language:', error); + // 如果出错,至少尝试跳转到目标语言的首页 + const targetLang = languages[langCode]; + if (targetLang) { + window.location.href = targetLang.path + '/'; + } + } +} + +/** + * 将页面内的“站内绝对链接”修正为当前语言目录 + * 例如:在 /en/ 下,把 href="/post/" 自动改为 href="/en/post/" + * + * 注意: + * - 只处理以 "/" 开头的链接(站内绝对路径) + * - 不处理静态资源(.css/.js/.json/.png/...) + * - 不重复添加语言前缀 + */ +export function localizeInternalAbsoluteLinks() { + const currentLang = getCurrentLanguage(); + if (currentLang === 'zh') return; + + const langPrefix = languages[currentLang]?.path || ''; + if (!langPrefix) return; + + const isStaticAsset = (href) => + /\.(css|js|json|png|jpg|jpeg|webp|gif|svg|ico|xml|txt|map)(\?|#|$)/i.test(href); + + document.querySelectorAll('a[href^="/"]').forEach((a) => { + const href = a.getAttribute('href'); + if (!href) return; + + // 跳过协议相对 URL(//example.com) + if (href.startsWith('//')) return; + + // 跳过静态资源 + if (isStaticAsset(href)) return; + + // 已经包含语言前缀则跳过 + if ( + href === langPrefix || + href.startsWith(langPrefix + '/') || + href.startsWith('/en/') || + href.startsWith('/ru/') || + href.startsWith('/es/') || + href.startsWith('/pt/') + ) { + return; + } + + // 处理 "/" 根路径 + const normalized = href === '/' ? '/' : href; + a.setAttribute('href', `${langPrefix}${normalized}`); + }); +} + +// 全局事件监听器标志,确保只添加一次 +let globalClickHandlerAdded = false; + +/** + * 初始化语言切换器 + */ +export function initLanguageSwitcher() { + const switchers = document.querySelectorAll('.language-switcher'); + if (!switchers.length) return; + + // 将切换函数暴露到全局(向后兼容) + window.switchLanguage = switchLanguage; + + // 获取当前语言 + const currentLang = getCurrentLanguage(); + const currentLangData = languages[currentLang]; + + // 添加全局点击事件监听器(只添加一次) + if (!globalClickHandlerAdded) { + globalClickHandlerAdded = true; + document.addEventListener('click', (e) => { + // 关闭所有语言下拉菜单(如果点击的不是语言切换器内部) + const clickedSwitcher = e.target.closest('.language-switcher'); + if (!clickedSwitcher) { + document.querySelectorAll('.language-dropdown.active').forEach((dropdown) => { + dropdown.classList.remove('active'); + }); + } else { + // 如果点击的是语言切换器内部,检查是否点击在下拉菜单外部 + const clickedDropdown = e.target.closest('.language-dropdown'); + const clickedButton = e.target.closest('.language-switcher-btn, #language-switcher-btn'); + if (!clickedDropdown && !clickedButton) { + clickedSwitcher.querySelectorAll('.language-dropdown.active').forEach((dropdown) => { + dropdown.classList.remove('active'); + }); + } + } + }); + } + + switchers.forEach((wrapper) => { + if (wrapper.dataset.langInited === '1') return; + wrapper.dataset.langInited = '1'; + + const langButton = + wrapper.querySelector('#language-switcher-btn') || + wrapper.querySelector('.language-switcher-btn'); + const langDropdown = + wrapper.querySelector('#language-dropdown') || + wrapper.querySelector('.language-dropdown'); + const langButtonText = + wrapper.querySelector('#language-switcher-text') || + wrapper.querySelector('.language-switcher-text'); + + if (!langButton || !langDropdown) return; + + // 更新按钮显示 + if (langButtonText) { + langButtonText.textContent = `${currentLangData.flag} ${currentLangData.nativeName}`; + } + + // 生成语言选项(不使用 inline onclick,避免被其他脚本/策略影响) + langDropdown.innerHTML = Object.values(languages) + .map((lang) => { + const isActive = lang.code === currentLang; + return ` + + ${lang.flag} + ${lang.nativeName} + + `; + }) + .join(''); + + // 切换下拉菜单显示 + langButton.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + const isOpen = langDropdown.classList.contains('active'); + + // 关闭其他下拉菜单 + document + .querySelectorAll('.dropdown-menu.active, .language-dropdown.active') + .forEach((menu) => { + if (menu !== langDropdown) menu.classList.remove('active'); + }); + + langDropdown.classList.toggle('active', !isOpen); + }); + + // 点击语言选项 + langDropdown.addEventListener('click', (e) => { + const a = e.target.closest('a[data-lang]'); + if (!a) return; + e.preventDefault(); + e.stopPropagation(); + + // 防止重复点击 + if (a.classList.contains('switching')) { + return; + } + a.classList.add('switching'); + + const langCode = a.getAttribute('data-lang'); + try { + switchLanguage(langCode); + } catch (error) { + console.error('Error in language switch handler:', error); + a.classList.remove('switching'); + } + }); + }); +} + +// 自动初始化 - 只在 DOM 加载完成后执行 +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + localizeInternalAbsoluteLinks(); + initLanguageSwitcher(); + }); +} else { + // DOM 已经加载完成,立即执行 + localizeInternalAbsoluteLinks(); + initLanguageSwitcher(); +} + + +// 自动初始化 +// 注意:有些页面会在脚本里提前调用 initLanguageSwitcher(),但当时 DOM 可能还没完全准备好。 +// 所以这里始终在 DOMContentLoaded 再跑一遍,确保绑定成功(内部有去重逻辑)。 +initLanguageSwitcher(); +document.addEventListener('DOMContentLoaded', initLanguageSwitcher); + diff --git a/src/js/mac-generator.js b/src/js/mac-generator.js new file mode 100644 index 0000000..c7b2a02 --- /dev/null +++ b/src/js/mac-generator.js @@ -0,0 +1,303 @@ +// MAC Address Generator + +import { randomElement } from './utils.js'; +import { getConfig } from './config.js'; + +// Data cache to reduce server requests +const dataCache = new Map(); +const CACHE_PREFIX = 'mac_data_cache_'; +const CACHE_VERSION = 'v1'; +const CACHE_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours + +// Load OUI data from JSON file with caching (memory + localStorage) +async function loadOuiData() { + const filePath = 'data/macOuiData.json'; + + try { + // Check memory cache first + if (dataCache.has(filePath)) { + return dataCache.get(filePath); + } + + // Check localStorage cache + const cacheKey = CACHE_PREFIX + filePath; + try { + const cachedData = localStorage.getItem(cacheKey); + if (cachedData) { + const parsed = JSON.parse(cachedData); + if (parsed.timestamp && (Date.now() - parsed.timestamp) < CACHE_EXPIRY) { + dataCache.set(filePath, parsed.data); + return parsed.data; + } else { + localStorage.removeItem(cacheKey); + } + } + } catch (e) { + console.warn('localStorage cache read failed:', e); + } + + // Get user configuration + const config = getConfig(); + const fileName = filePath.split('/').pop(); + + // Build paths array based on configuration + const paths = []; + + // If user has configured a custom dataBasePath, use it first + if (config.dataBasePath) { + // Ensure trailing slash + const basePath = config.dataBasePath.endsWith('/') ? config.dataBasePath : config.dataBasePath + '/'; + paths.push(basePath + fileName); + } + + // If autoDetectPaths is enabled (default), add automatic path detection + // This preserves the original behavior for mockaddress.com + if (config.autoDetectPaths !== false) { + const currentPath = window.location.pathname; + + // Try multiple possible paths + // Priority: relative path (../data/) first, then absolute paths + paths.push( + `../data/${fileName}`, // Relative: go up one level, then into data (works for all language versions) + `/data/${fileName}`, // Absolute path from root (for Chinese version) + `data/${fileName}`, // Relative to current directory (fallback) + filePath // Original path (fallback) + ); + + // Add language-specific absolute paths if we're in a language subdirectory + const pathParts = currentPath.split('/').filter(p => p && p !== 'index.html' && p !== ''); + if (pathParts.length >= 1 && ['en', 'ru', 'es', 'pt'].includes(pathParts[0])) { + // We're in a language subdirectory, add language-specific absolute path + const lang = pathParts[0]; + paths.splice(paths.length - 2, 0, `/${lang}/data/${fileName}`); // Insert before fallback paths + } + } + + let lastError = null; + for (const path of paths) { + try { + const response = await fetch(path, { + // Add cache control to help browser cache + cache: 'default' + }); + if (response.ok) { + const data = await response.json(); + dataCache.set(filePath, data); + + // Store in localStorage + try { + const cacheData = { + data: data, + timestamp: Date.now(), + version: CACHE_VERSION + }; + localStorage.setItem(cacheKey, JSON.stringify(cacheData)); + } catch (e) { + console.warn('localStorage cache write failed:', e); + } + + return data; + } else { + lastError = `HTTP ${response.status} for ${path}`; + } + } catch (e) { + // Record error but continue trying other paths + lastError = e.message || e.toString(); + continue; + } + } + + console.error(`Failed to load ${filePath}. Tried paths:`, paths, 'Last error:', lastError); + throw new Error(`Failed to load ${filePath}: ${lastError || 'All paths failed'}`); + } catch (error) { + console.error(`Error loading OUI data:`, error); + throw error; + } +} + +// Generate random byte using crypto.getRandomValues +function randomByte() { + return crypto.getRandomValues(new Uint8Array(1))[0]; +} + +// Convert OUI string (e.g., "00:03:93") to bytes +function ouiStringToBytes(ouiString) { + const parts = ouiString.split(':'); + return new Uint8Array([ + parseInt(parts[0], 16), + parseInt(parts[1], 16), + parseInt(parts[2], 16) + ]); +} + +// Generate MAC address +function generateMACAddress(options = {}) { + const { + vendor = 'random', + format = 'colon', + unicast = true, + laa = false + } = options; + + let bytes = new Uint8Array(6); + + // Generate first 3 bytes (OUI) + if (vendor !== 'random' && options.ouiDb) { + // Search in full OUI database for matching vendor + const matchingOuis = Object.keys(options.ouiDb).filter(oui => { + const vendorName = options.ouiDb[oui]; + // Check if vendor name contains the search term or vice versa + return vendorName.toLowerCase().includes(vendor.toLowerCase()) || + vendor.toLowerCase().includes(vendorName.toLowerCase().split(',')[0]); + }); + + if (matchingOuis.length > 0) { + // Use random OUI from matching vendors + const selectedOui = randomElement(matchingOuis); + const ouiBytes = ouiStringToBytes(selectedOui); + bytes[0] = ouiBytes[0]; + bytes[1] = ouiBytes[1]; + bytes[2] = ouiBytes[2]; + } else { + // Vendor not found, generate random + crypto.getRandomValues(bytes.subarray(0, 3)); + } + } else { + // Completely random + crypto.getRandomValues(bytes.subarray(0, 3)); + } + + // Generate last 3 bytes (device identifier) + crypto.getRandomValues(bytes.subarray(3)); + + // Apply unicast bit (LSB of first byte = 0 for unicast, 1 for multicast) + if (unicast) { + bytes[0] &= 0xFE; // Clear bit 0 + } else { + bytes[0] |= 0x01; // Set bit 0 + } + + // Apply LAA bit (bit 1 of first byte = 1 for locally administered, 0 for globally unique) + if (laa) { + bytes[0] |= 0x02; // Set bit 1 + } else { + bytes[0] &= 0xFD; // Clear bit 1 + } + + return bytes; +} + +// Format MAC address +function formatMACAddress(bytes, format = 'colon') { + const hex = Array.from(bytes).map(b => + b.toString(16).padStart(2, '0').toUpperCase() + ); + + switch (format) { + case 'colon': + return hex.join(':'); + case 'hyphen': + return hex.join('-'); + case 'dot': + return `${hex[0]}${hex[1]}.${hex[2]}${hex[3]}.${hex[4]}${hex[5]}`; + case 'none': + return hex.join(''); + case 'space': + return hex.join(' '); + default: + return hex.join(':'); + } +} + +// Convert MAC to IPv6 Link-Local (EUI-64) +function macToIPv6(bytes) { + const b = [...bytes]; + // Flip U/L bit (bit 7 of first byte) + b[0] ^= 0x02; + + // Insert FFFE in the middle + const eui64 = [b[0], b[1], b[2], 0xFF, 0xFE, b[3], b[4], b[5]]; + + // Convert to IPv6 format + const groups = []; + for (let i = 0; i < 8; i += 2) { + const group = ((eui64[i] << 8) | eui64[i + 1]).toString(16); + groups.push(group); + } + + return 'fe80::' + groups.join(':'); +} + +// Identify vendor from MAC address +function identifyVendor(bytes, ouiDb) { + if (!ouiDb) return null; + + const ouiString = Array.from(bytes.slice(0, 3)) + .map(b => b.toString(16).padStart(2, '0').toUpperCase()) + .join(':'); + + return ouiDb[ouiString] || null; +} + +// Generate MAC address with all options +export async function generateMAC(options = {}) { + try { + const { + count = 1, + vendor = 'random', + format = 'colon', + unicast = true, + laa = false, + showIPv6 = false + } = options; + + // 限制最大生成数量为888 + const actualCount = Math.min(888, Math.max(1, count)); + + // Load OUI data + const ouiDb = await loadOuiData(); + + const results = []; + + for (let i = 0; i < actualCount; i++) { + const bytes = generateMACAddress({ + vendor, + format, + unicast, + laa, + ouiDb + }); + + const mac = formatMACAddress(bytes, format); + const vendorName = identifyVendor(bytes, ouiDb); + const ipv6 = showIPv6 ? macToIPv6(bytes) : null; + + results.push({ + mac, + vendor: vendorName, + ipv6, + bytes: Array.from(bytes), + format, + unicast, + laa + }); + } + + return results; + } catch (error) { + console.error('Error generating MAC address:', error); + throw error; + } +} + +// Get available vendors from OUI database +export async function getAvailableVendors() { + try { + const ouiDb = await loadOuiData(); + const vendors = [...new Set(Object.values(ouiDb))].sort(); + return vendors; + } catch (error) { + console.error('Error getting vendors:', error); + return []; + } +} diff --git a/src/js/storage.js b/src/js/storage.js new file mode 100644 index 0000000..af18496 --- /dev/null +++ b/src/js/storage.js @@ -0,0 +1,282 @@ +// Local Storage Management + +const STORAGE_KEY = 'saved_addresses'; +const RATE_LIMIT_KEY = 'address_generation_rate_limit'; +const MIN_INTERVAL_MS = 2000; // 2秒 +const MAX_PER_HOUR = 88; // 每小时最多88次 + +// Get all saved addresses +export function getSavedAddresses() { + try { + const data = localStorage.getItem(STORAGE_KEY); + return data ? JSON.parse(data) : []; + } catch (error) { + console.error('Error loading saved addresses:', error); + return []; + } +} + +// Extract core address fields for comparison (exclude id, savedAt) +function getAddressCore(address) { + const { id, savedAt, ...core } = address; + return core; +} + +// Save address +export function saveAddress(address) { + try { + const addresses = getSavedAddresses(); + + // Check for duplicates based on core address fields (excluding id and savedAt) + const addressCore = getAddressCore(address); + const addressCoreString = JSON.stringify(addressCore); + const isDuplicate = addresses.some(addr => { + const savedCore = getAddressCore(addr); + return JSON.stringify(savedCore) === addressCoreString; + }); + + if (isDuplicate) { + return { success: false, message: '该地址已保存,请勿重复添加' }; + } + + addresses.push({ + ...address, + id: Date.now().toString(), + savedAt: new Date().toISOString() + }); + + localStorage.setItem(STORAGE_KEY, JSON.stringify(addresses)); + return { success: true, message: '地址已成功保存' }; + } catch (error) { + console.error('Error saving address:', error); + return { success: false, message: '保存地址时出错' }; + } +} + +// Delete address by id +export function deleteAddress(id) { + try { + const addresses = getSavedAddresses(); + const filtered = addresses.filter(addr => addr.id !== id); + localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)); + return { success: true, message: '删除成功' }; + } catch (error) { + console.error('Error deleting address:', error); + return { success: false, message: '删除地址时出错' }; + } +} + +// Clear all addresses +export function clearAllAddresses() { + try { + localStorage.removeItem(STORAGE_KEY); + return { success: true, message: '已清空所有地址' }; + } catch (error) { + console.error('Error clearing addresses:', error); + return { success: false, message: '清空地址时出错' }; + } +} + +// Get saved addresses count +export function getSavedCount() { + return getSavedAddresses().length; +} + +// Export to CSV +export function exportToCSV() { + try { + const addresses = getSavedAddresses(); + + if (addresses.length === 0) { + return { success: false, message: '没有保存的地址' }; + } + + // CSV header + const headers = ['姓名', '性别', '电话', '电子邮件', '完整地址']; + const rows = addresses.map(addr => { + const name = `${addr.firstName || ''} ${addr.lastName || ''}`.trim(); + const gender = addr.gender || ''; + const phone = addr.phone || ''; + const email = addr.email || ''; + const fullAddress = addr.fullAddress || formatAddress(addr); + + return [ + name, + gender, + phone, + email, + fullAddress + ].map(field => `"${String(field).replace(/"/g, '""')}"`).join(','); + }); + + const csv = [headers.map(h => `"${h}"`).join(','), ...rows].join('\n'); + const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `地址列表_${new Date().toISOString().split('T')[0]}.csv`; + link.click(); + URL.revokeObjectURL(url); + + return { success: true, message: 'CSV文件已下载' }; + } catch (error) { + console.error('Error exporting CSV:', error); + return { success: false, message: '导出CSV失败' }; + } +} + +// Export to JSON +export function exportToJSON() { + try { + const addresses = getSavedAddresses(); + + if (addresses.length === 0) { + return { success: false, message: '没有保存的地址' }; + } + + const json = JSON.stringify(addresses, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `地址列表_${new Date().toISOString().split('T')[0]}.json`; + link.click(); + URL.revokeObjectURL(url); + + return { success: true, message: 'JSON文件已下载' }; + } catch (error) { + console.error('Error exporting JSON:', error); + return { success: false, message: '导出JSON失败' }; + } +} + +// Rate limiting functions +function getRateLimitData() { + try { + const data = localStorage.getItem(RATE_LIMIT_KEY); + return data ? JSON.parse(data) : { lastGeneration: 0, hourlyGenerations: [] }; + } catch (error) { + console.error('Error loading rate limit data:', error); + return { lastGeneration: 0, hourlyGenerations: [] }; + } +} + +function saveRateLimitData(data) { + try { + localStorage.setItem(RATE_LIMIT_KEY, JSON.stringify(data)); + } catch (error) { + console.error('Error saving rate limit data:', error); + } +} + +// Clean old generation records (older than 1 hour) +function cleanOldGenerations(generations) { + const oneHourAgo = Date.now() - (60 * 60 * 1000); + return generations.filter(timestamp => timestamp > oneHourAgo); +} + +// Check if generation is allowed +export function checkGenerationRateLimit() { + try { + const now = Date.now(); + const rateLimitData = getRateLimitData(); + + // Clean old records + rateLimitData.hourlyGenerations = cleanOldGenerations(rateLimitData.hourlyGenerations); + + // Check 2 second interval (skip if first time, lastGeneration is 0) + if (rateLimitData.lastGeneration > 0) { + const timeSinceLastGeneration = now - rateLimitData.lastGeneration; + if (timeSinceLastGeneration < MIN_INTERVAL_MS) { + const remainingSeconds = Math.ceil((MIN_INTERVAL_MS - timeSinceLastGeneration) / 1000); + return { + allowed: false, + message: `请等待 ${remainingSeconds} 秒后再生成`, + remainingSeconds: remainingSeconds + }; + } + } + + // Check hourly limit + if (rateLimitData.hourlyGenerations.length >= MAX_PER_HOUR) { + const oldestGeneration = rateLimitData.hourlyGenerations[0]; + const timeUntilOldestExpires = (oldestGeneration + (60 * 60 * 1000)) - now; + const remainingMinutes = Math.ceil(timeUntilOldestExpires / (60 * 1000)); + return { + allowed: false, + message: `每小时最多生成 ${MAX_PER_HOUR} 次,请等待 ${remainingMinutes} 分钟`, + remainingMinutes: remainingMinutes + }; + } + + return { allowed: true }; + } catch (error) { + console.error('Error checking rate limit:', error); + // If there's an error, allow generation to prevent blocking users + return { allowed: true }; + } +} + +// Record generation (used for both generation and save operations) +export function recordGeneration() { + try { + const now = Date.now(); + const rateLimitData = getRateLimitData(); + + // Clean old records + rateLimitData.hourlyGenerations = cleanOldGenerations(rateLimitData.hourlyGenerations); + + // Update last generation time + rateLimitData.lastGeneration = now; + + // Add current generation to hourly list + rateLimitData.hourlyGenerations.push(now); + + // Save updated data + saveRateLimitData(rateLimitData); + + return { + success: true, + remainingInHour: MAX_PER_HOUR - rateLimitData.hourlyGenerations.length + }; + } catch (error) { + console.error('Error recording generation:', error); + // Return success even if recording fails to prevent blocking + return { + success: true, + remainingInHour: MAX_PER_HOUR + }; + } +} + +// Check if save can be done without recording (if it's within 2 seconds of last generation) +export function canSaveWithoutRecording() { + try { + const now = Date.now(); + const rateLimitData = getRateLimitData(); + // If lastGeneration is 0, it's the first time, so save needs to be recorded + if (rateLimitData.lastGeneration === 0) { + return false; + } + const timeSinceLastGeneration = now - rateLimitData.lastGeneration; + + // If save happens within 2 seconds of generation, it's part of the same operation + return timeSinceLastGeneration < MIN_INTERVAL_MS; + } catch (error) { + console.error('Error checking save without recording:', error); + // On error, require recording to be safe + return false; + } +} + +// Helper function to format address +function formatAddress(address) { + if (typeof address === 'string') { + return address; + } + if (address.street && address.city && address.state && address.zip) { + return `${address.street}, ${address.city}, ${address.state} ${address.zip}`; + } + return JSON.stringify(address); +} + diff --git a/src/js/utils.js b/src/js/utils.js new file mode 100644 index 0000000..91f2ce2 --- /dev/null +++ b/src/js/utils.js @@ -0,0 +1,133 @@ +// Utility Functions + +// Copy text to clipboard +export function copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text).then(() => { + return true; + }).catch(() => { + return false; + }); + } else { + // Fallback for older browsers + 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'); + document.body.removeChild(textarea); + return Promise.resolve(true); + } catch (err) { + document.body.removeChild(textarea); + return Promise.resolve(false); + } + } +} + +// Generate random number between min and max (inclusive) +export function randomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +// Get random element from array +export function randomElement(array) { + return array[Math.floor(Math.random() * array.length)]; +} + +// Generate random phone number +export function generatePhoneNumber(areaCode) { + const exchange = randomInt(200, 999); + const number = randomInt(1000, 9999); + return `${areaCode}-${exchange}-${number}`; +} + +// Generate random email +export function generateEmail(firstName, lastName) { + const domains = ['gmail.com', 'yahoo.com', 'outlook.com', 'hotmail.com', 'icloud.com']; + const domain = randomElement(domains); + const randomNum = randomInt(100, 999); + + // Clean and validate names - remove spaces, dots, and ensure non-empty + const cleanFirstName = (firstName || '').toString().trim().toLowerCase().replace(/[.\s]/g, '') || 'user'; + const cleanLastName = (lastName || '').toString().trim().toLowerCase().replace(/[.\s]/g, '') || 'name'; + + // Ensure names are not empty + const firstPart = cleanFirstName || 'user'; + const lastPart = cleanLastName || 'name'; + + // Build email: firstnamelastname123@domain.com (no dot between names) + return `${firstPart}${lastPart}${randomNum}@${domain}`; +} + +// Format address for display +export function formatAddress(address) { + if (typeof address === 'string') { + return address; + } + if (address.street && address.city && address.state && address.zip) { + return `${address.street}, ${address.city}, ${address.state} ${address.zip}`; + } + return JSON.stringify(address); +} + +// Show toast notification +export function showToast(message, type = 'success') { + // Create toast element + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + toast.style.cssText = ` + position: fixed; + top: 100px; + right: 20px; + background-color: ${type === 'success' ? '#10b981' : '#ef4444'}; + color: white; + padding: 1rem 1.5rem; + border-radius: 0.5rem; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + z-index: 1000; + animation: slideIn 0.3s ease; + `; + + document.body.appendChild(toast); + + setTimeout(() => { + toast.style.animation = 'slideOut 0.3s ease'; + setTimeout(() => { + document.body.removeChild(toast); + }, 300); + }, 3000); +} + +// Add CSS animation if not exists +if (!document.getElementById('toast-animations')) { + const style = document.createElement('style'); + style.id = 'toast-animations'; + style.textContent = ` + @keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } + } + @keyframes slideOut { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(100%); + opacity: 0; + } + } + `; + document.head.appendChild(style); +} +