这是我用AI做的一个可以管理账号的网页工具
这是开发时的完整文件(包含历史版本)
文件存在蓝奏云上,登录手机号:15310649220 文件路径:根目录/2026/05/11/账号管理器/完整开发文件(包含历史版本).zip
登录手机号:15310649220 文件路径:根目录/2026/05/11/账号管理器/完整开发文件(包含历史版本).zip
原版完整代码:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=0.9, maximum-scale=0.9, user-scalable=no">
<title>账号管理器</title>
<style>
:root {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--bg-tertiary: #fafafa;
--text-primary: #111111;
--text-secondary: #666666;
--text-tertiary: #999999;
--border: #e5e5e5;
--accent: #0066ff;
--accent-hover: #0052cc;
--danger: #ff3b30;
--success: #34c759;
--warning: #ff9500;
--shadow: rgba(0, 0, 0, 0.08);
--card-bg: #ffffff;
--input-bg: #ffffff;
--modal-overlay: rgba(0, 0, 0, 0.5);
}
[data-theme="dark"] {
--bg-primary: #000000;
--bg-secondary: #1c1c1e;
--bg-tertiary: #2c2c2e;
--text-primary: #ffffff;
--text-secondary: #8e8e93;
--text-tertiary: #636366;
--border: #38383a;
--accent: #0a84ff;
--accent-hover: #409cff;
--danger: #ff453a;
--success: #30d158;
--warning: #ff9f0a;
--shadow: rgba(0, 0, 0, 0.3);
--card-bg: #1c1c1e;
--input-bg: #2c2c2e;
--modal-overlay: rgba(0, 0, 0, 0.8);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
/* Allow text selection in specific elements */
.field-text, .view-value-text, .note-text, .form-input, textarea, input {
-webkit-user-select: text;
user-select: text;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
transition: background 0.3s, color 0.3s;
}
/* Sort Icon Button - Stats Bar Style */
.sort-icon-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-secondary);
border-radius: 20px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
margin-left: auto;
}
.sort-icon-btn:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--bg-tertiary);
}
.sort-icon-btn.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.sort-icon-btn.active:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.sort-text {
font-size: 12px;
font-weight: 600;
}
/* Header */
.header {
position: sticky;
top: 0;
background: var(--bg-primary);
border-bottom: 1px solid var(--border);
z-index: 100;
transition: all 0.3s;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.5px;
}
.logo-icon {
width: 32px;
height: 32px;
background: var(--accent);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.icon-btn {
width: 36px;
height: 36px;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
outline: none;
}
.icon-btn:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.icon-btn.danger:hover {
background: var(--danger);
color: white;
}
/* Search Bar */
.search-section {
max-width: 1200px;
margin: 0 auto;
padding: 20px 24px;
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.search-box {
flex: 1;
min-width: 280px;
position: relative;
}
.search-input {
width: 100%;
padding: 12px 16px 12px 40px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--input-bg);
color: var(--text-primary);
font-size: 15px;
transition: all 0.2s;
outline: none;
}
.search-input:focus {
outline: none;
border-color: var(--accent);
}
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
pointer-events: none;
}
.btn {
padding: 12px 20px;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
white-space: nowrap;
outline: none;
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
transform: translateY(-1px);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--bg-tertiary);
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn-danger:hover {
background: #ff2d55;
transform: translateY(-1px);
}
/* Tags Filter */
.tags-section {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px 20px;
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
position: relative;
}
.tag-label {
font-size: 13px;
color: var(--text-tertiary);
font-weight: 500;
}
.tag {
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: 1px solid var(--border);
background: var(--bg-primary);
color: var(--text-secondary);
}
.tag:hover {
border-color: var(--accent);
color: var(--accent);
}
.tag.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
/* Main Content */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px 40px;
}
.stats-bar {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 24px;
font-size: 13px;
color: var(--text-tertiary);
}
.stat-item {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.stat-value {
color: var(--text-primary);
font-weight: 600;
}
/* Grid Layout */
.accounts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 16px;
}
/* Card Design */
.account-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 16px;
padding: 20px;
transition: all 0.2s;
position: relative;
cursor: pointer;
min-width: 0;
outline: none;
}
.account-card:hover {
border-color: var(--accent);
box-shadow: 0 4px 20px var(--shadow);
transform: translateY(-2px);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 16px;
min-width: 0;
}
.platform-info {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.platform-icon {
width: 44px;
height: 44px;
background: var(--bg-secondary);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 600;
color: var(--accent);
flex-shrink: 0;
}
.platform-meta {
min-width: 0;
flex: 1;
overflow: hidden;
}
.platform-name {
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.platform-url {
font-size: 13px;
color: var(--accent);
text-decoration: none;
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.platform-url:hover {
text-decoration: underline;
}
/* Tags container with flex wrap */
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.card-tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 12px;
font-size: 11px;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.2s;
}
.card-tag:hover {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.card-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s;
flex-shrink: 0;
}
.account-card:hover .card-actions {
opacity: 1;
}
.card-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
outline: none;
}
.card-btn:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.card-btn.delete:hover {
color: var(--danger);
}
/* Fields */
.fields {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.field-label {
font-size: 12px;
color: var(--text-tertiary);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.field-value {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-secondary);
padding: 10px 12px;
border-radius: 10px;
font-size: 14px;
font-family: 'SF Mono', Monaco, monospace;
min-width: 0;
}
.field-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
}
.password-text {
filter: blur(4px);
transition: filter 0.2s;
user-select: none;
}
.password-text.revealed {
filter: none;
user-select: text;
}
.field-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.field-btn {
padding: 4px 8px;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
transition: all 0.2s;
outline: none;
}
.field-btn:hover {
background: var(--bg-primary);
color: var(--accent);
}
.field-btn.copied {
color: var(--success);
}
.note-text {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
padding: 8px 12px;
background: var(--bg-secondary);
border-radius: 8px;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 80px 20px;
color: var(--text-tertiary);
}
.empty-icon {
width: 80px;
height: 80px;
margin: 0 auto 20px;
background: var(--bg-secondary);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
}
.empty-title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.empty-desc {
font-size: 14px;
max-width: 400px;
margin: 0 auto;
line-height: 1.6;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--modal-overlay);
backdrop-filter: blur(8px);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
padding: 20px;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: var(--bg-primary);
border-radius: 20px;
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
animation: modalIn 0.3s ease;
}
@keyframes modalIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.modal-header {
padding: 24px 24px 0;
}
.modal-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 4px;
}
.modal-subtitle {
font-size: 14px;
color: var(--text-secondary);
}
.modal-body {
padding: 20px 24px;
overflow-y: auto;
max-height: calc(90vh - 140px);
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 6px;
}
.form-input {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--input-bg);
color: var(--text-primary);
font-size: 15px;
transition: all 0.2s;
outline: none;
}
.form-input:focus {
outline: none;
border-color: var(--accent);
}
.form-input::placeholder {
color: var(--text-tertiary);
}
textarea.form-input {
min-height: 80px;
resize: vertical;
font-family: inherit;
line-height: 1.5;
}
.input-hint {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 4px;
}
/* Password field with generator */
.password-field-wrapper {
position: relative;
display: flex;
gap: 8px;
align-items: center;
}
.password-input-wrapper {
position: relative;
flex: 1;
}
.password-actions {
display: flex;
gap: 4px;
align-items: center;
}
.icon-action-btn {
width: 36px;
height: 36px;
border: none;
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
outline: none;
flex-shrink: 0;
}
.icon-action-btn:hover {
background: var(--accent);
color: white;
}
.icon-action-btn svg {
width: 18px;
height: 18px;
}
/* Tags input with pipe separator */
.tags-simple-input {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--input-bg);
color: var(--text-primary);
font-size: 15px;
transition: all 0.2s;
outline: none;
}
.tags-simple-input:focus {
outline: none;
border-color: var(--accent);
}
.modal-footer {
padding: 16px 24px 24px;
display: flex;
justify-content: flex-end;
gap: 12px;
border-top: 1px solid var(--border);
}
.btn-text {
padding: 10px 20px;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
font-size: 15px;
font-weight: 500;
border-radius: 10px;
transition: all 0.2s;
outline: none;
min-width: 80px;
text-align: center;
}
.btn-text:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
border-color: var(--text-tertiary);
}
.btn-text.danger {
color: var(--danger);
border-color: var(--danger);
background: rgba(255, 59, 48, 0.05);
}
.btn-text.danger:hover {
background: var(--danger);
color: white;
border-color: var(--danger);
}
/* View Modal Styles */
.view-modal .modal-body {
padding: 24px;
}
.view-field {
margin-bottom: 20px;
}
.view-field:last-child {
margin-bottom: 0;
}
.view-label {
font-size: 12px;
color: var(--text-tertiary);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
margin-bottom: 8px;
}
.view-value-box {
background: var(--bg-secondary);
padding: 14px 16px;
border-radius: 12px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 15px;
color: var(--text-primary);
word-break: break-all;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.view-value-text {
flex: 1;
overflow-wrap: break-word;
}
.view-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.view-btn {
padding: 6px 12px;
border: none;
background: var(--bg-primary);
color: var(--text-secondary);
cursor: pointer;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
outline: none;
}
.view-btn:hover {
background: var(--accent);
color: white;
}
.view-btn.copied {
background: var(--success);
color: white;
}
.view-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.view-tag {
padding: 6px 14px;
background: var(--bg-secondary);
color: var(--text-secondary);
border-radius: 20px;
font-size: 13px;
font-weight: 500;
}
/* View note with resize like edit mode */
.view-note-resizable {
background: var(--bg-secondary);
padding: 14px 16px;
border-radius: 12px;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
white-space: pre-wrap;
width: 100%;
min-height: 80px;
resize: vertical;
border: 1px solid transparent;
font-family: inherit;
overflow: auto;
}
.view-note-resizable:focus {
outline: none;
border-color: var(--accent);
}
.view-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--accent);
text-decoration: none;
font-size: 14px;
margin-top: 8px;
}
.view-link:hover {
text-decoration: underline;
}
/* Toast */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
background: var(--text-primary);
color: var(--bg-primary);
padding: 14px 20px;
border-radius: 12px;
font-size: 14px;
font-weight: 500;
display: none;
align-items: center;
gap: 10px;
box-shadow: 0 10px 30px var(--shadow);
z-index: 2000;
animation: toastIn 0.3s ease;
}
.toast.show {
display: flex;
}
@keyframes toastIn {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Confirm Modal */
.confirm-modal .modal-body {
padding: 24px;
text-align: center;
}
.confirm-icon {
width: 64px;
height: 64px;
margin: 0 auto 16px;
background: var(--danger);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.confirm-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.confirm-desc {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.5;
}
/* Double confirm input */
.confirm-input-group {
margin-top: 20px;
text-align: left;
}
.confirm-input-label {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 8px;
display: block;
}
.confirm-input {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--input-bg);
color: var(--text-primary);
font-size: 15px;
text-align: center;
font-weight: 500;
}
.confirm-input:focus {
outline: none;
border-color: var(--danger);
}
.confirm-input.error {
border-color: var(--danger);
background: rgba(255, 59, 48, 0.1);
}
/* View Modal Tags */
.view-tags-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.view-tag-item {
padding: 6px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 20px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
/* Password Generator Settings */
.generator-settings {
background: var(--bg-secondary);
border-radius: 12px;
padding: 16px;
margin-top: 12px;
border: 1px solid var(--border);
}
.generator-settings-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.setting-row:last-child {
margin-bottom: 0;
}
.setting-label {
font-size: 14px;
color: var(--text-secondary);
}
.setting-control {
display: flex;
align-items: center;
gap: 8px;
}
.length-input {
width: 60px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--input-bg);
color: var(--text-primary);
font-size: 14px;
text-align: center;
}
.checkbox-wrapper {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.checkbox-wrapper input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.checkbox-wrapper span {
font-size: 14px;
color: var(--text-secondary);
}
/* Export/Import Options */
.export-options {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.export-option {
flex: 1;
padding: 16px;
border: 2px solid var(--border);
border-radius: 12px;
cursor: pointer;
text-align: center;
transition: all 0.2s;
}
.export-option:hover {
border-color: var(--accent);
}
.export-option.selected {
border-color: var(--accent);
background: rgba(0, 102, 255, 0.05);
}
.export-option-icon {
width: 48px;
height: 48px;
margin: 0 auto 8px;
background: var(--bg-secondary);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
}
.export-option-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.export-option-desc {
font-size: 12px;
color: var(--text-secondary);
}
/* Duplicate confirm modal */
.duplicate-info {
background: var(--bg-secondary);
border-radius: 12px;
padding: 16px;
margin: 16px 0;
text-align: left;
}
.duplicate-info-item {
display: flex;
gap: 8px;
margin-bottom: 8px;
font-size: 14px;
}
.duplicate-info-item:last-child {
margin-bottom: 0;
}
.duplicate-info-label {
color: var(--text-tertiary);
min-width: 60px;
}
.duplicate-info-value {
color: var(--text-primary);
font-weight: 500;
}
/* Desktop Styles (default) */
@media (min-width: 769px) {
.mobile-only {
display: none !important;
}
}
/* Mobile Styles - 90% zoom */
@media (max-width: 768px) {
html {
zoom: 0.9;
-moz-transform: scale(0.9);
-moz-transform-origin: 0 0;
}
@supports not (zoom: 0.9) {
body {
transform: scale(0.9);
transform-origin: 0 0;
width: 111.11%;
height: 111.11%;
}
}
.desktop-only {
display: none !important;
}
.header-content {
padding: 12px 16px;
}
.search-section {
padding: 16px;
}
.search-section .btn-primary {
display: none;
}
.sort-icon-btn {
padding: 4px 10px;
margin-left: auto;
}
.sort-icon-btn svg {
width: 12px;
height: 12px;
}
.sort-text {
font-size: 11px;
}
.tags-section {
padding: 0 16px 16px;
position: relative;
padding-right: 60px;
}
.mobile-add-btn {
position: absolute;
right: 16px;
top: 0;
width: 36px;
height: 36px;
border: none;
background: var(--accent);
color: white;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
outline: none;
}
.mobile-add-btn:hover {
background: var(--accent-hover);
transform: scale(1.05);
}
.container {
padding: 0 16px 32px;
}
.accounts-grid {
grid-template-columns: 1fr;
}
.card-actions {
opacity: 1;
}
/* Mobile copy fix */
.field-btn {
padding: 8px 12px;
font-size: 13px;
}
/* Ensure card doesn't overflow */
.account-card {
min-width: 0;
max-width: 100%;
}
.fields {
min-width: 0;
}
.field-value {
min-width: 0;
}
.note-text {
max-width: 100%;
}
/* Toast position fix for zoomed viewport */
.toast {
right: 20px;
bottom: 20px;
}
/* Generator settings on mobile */
.generator-settings {
padding: 12px;
}
.setting-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
/* Card Footer with Update Time */
.card-footer {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
}
.update-time {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-tertiary);
}
.update-time svg {
opacity: 0.6;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
</style>
<base target="_blank">
</head>
<body>
<header class="header">
<div class="header-content">
<div class="logo">
<div class="logo-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<rect x="3" y="11" width="18" height="11" rx="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<span>账号管理器</span>
</div>
<div class="header-actions">
<button class="icon-btn" onclick="toggleTheme()" title="切换主题">
<svg id="themeIcon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</button>
<button class="icon-btn" onclick="showExportModal()" title="导出数据">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
<button class="icon-btn" onclick="document.getElementById('importFile').click()" title="导入数据">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
</button>
<button class="icon-btn danger" onclick="confirmClearAll()" title="清空所有数据">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
<input type="file" id="importFile" style="display: none" accept=".json" onchange="importData(this)">
</div>
</div>
</header>
<section class="search-section">
<div class="search-box">
<svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
<input type="text" class="search-input" id="searchInput" placeholder="搜索平台、账号、标签、链接或备注..." oninput="renderAccounts()">
</div>
<button class="btn btn-primary desktop-only" onclick="openModal()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
添加账号
</button>
</section>
<section class="tags-section" id="tagsContainer">
<span class="tag-label">筛选:</span>
<span class="tag active" onclick="filterByTag('all')">全部</span>
</section>
<main class="container">
<div class="stats-bar">
<div class="stat-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
<span>共 <span class="stat-value" id="totalCount">0</span> 个账号</span>
</div>
<div class="stat-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
<span>更新于 <span class="stat-value" id="lastUpdate">-</span></span>
</div>
<button class="sort-icon-btn" id="sortBtn" onclick="toggleSortOrder()" title="切换排序">
<svg id="sortIcon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="12 6 12 18"></polyline>
<polyline points="8 10 12 6 16 10"></polyline>
</svg>
<span class="sort-text" id="sortText">最新</span>
</button>
</div>
<div id="contentArea">
<div class="empty-state" id="emptyState">
<div class="empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="11" width="18" height="11" rx="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<div class="empty-title">暂无账号</div>
<div class="empty-desc">点击右上角"添加账号"开始使用,或导入已有数据</div>
</div>
<div class="accounts-grid" id="accountsGrid" style="display: none;"></div>
</div>
</main>
<!-- Add/Edit Modal - No overlay click close -->
<div class="modal-overlay" id="modal">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title" id="modalTitle">添加账号</h2>
<p class="modal-subtitle">数据仅保存在本地浏览器中</p>
</div>
<form class="modal-body" id="accountForm" onsubmit="saveAccount(event)">
<input type="hidden" id="editId">
<div class="form-group">
<label class="form-label">平台名称 *</label>
<input type="text" class="form-input" id="platform" placeholder="例如:微信、GitHub、支付宝" required>
</div>
<div class="form-group">
<label class="form-label">用户名 *</label>
<input type="text" class="form-input" id="username" placeholder="邮箱/手机号/用户名" required>
</div>
<div class="form-group">
<label class="form-label">密码 *</label>
<div class="password-field-wrapper">
<div class="password-input-wrapper">
<input type="password" class="form-input" id="password" placeholder="密码" required style="padding-right: 40px;">
</div>
<div class="password-actions">
<!-- Generate password button -->
<button type="button" class="icon-action-btn" onclick="generatePassword()" title="生成随机密码">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 9.9-1"></path>
<circle cx="12" cy="16" r="1" fill="currentColor"></circle>
<line x1="8" y1="16" x2="8" y2="16"></line>
<line x1="16" y1="16" x2="16" y2="16"></line>
</svg>
</button>
<!-- Settings button -->
<button type="button" class="icon-action-btn" onclick="toggleGeneratorSettings()" title="密码生成器设置">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M12 1v6m0 6v6m4.22-10.22l4.24-4.24M6.34 6.34L2.1 2.1m17.8 17.8l-4.24-4.24M6.34 17.66l-4.24 4.24M23 12h-6m-6 0H1m20.07-4.93l-4.24 4.24M6.34 6.34l-4.24-4.24"></path>
</svg>
</button>
<!-- Toggle visibility -->
<button type="button" class="icon-action-btn" onclick="togglePasswordInput()" title="显示/隐藏密码">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
</div>
</div>
<!-- Generator Settings Panel -->
<div class="generator-settings" id="generatorSettings" style="display: none;">
<div class="generator-settings-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M12 1v6m0 6v6m4.22-10.22l4.24-4.24M6.34 6.34L2.1 2.1m17.8 17.8l-4.24-4.24M6.34 17.66l-4.24 4.24M23 12h-6m-6 0H1m20.07-4.93l-4.24 4.24M6.34 6.34l-4.24-4.24"></path>
</svg>
密码生成器设置
</div>
<div class="setting-row">
<span class="setting-label">密码长度</span>
<div class="setting-control">
<input type="number" class="length-input" id="pwdLength" value="16" min="4" max="64">
</div>
</div>
<div class="setting-row">
<span class="setting-label">包含大写字母 (A-Z)</span>
<label class="checkbox-wrapper">
<input type="checkbox" id="pwdUppercase" checked>
<span>启用</span>
</label>
</div>
<div class="setting-row">
<span class="setting-label">包含小写字母 (a-z)</span>
<label class="checkbox-wrapper">
<input type="checkbox" id="pwdLowercase" checked>
<span>启用</span>
</label>
</div>
<div class="setting-row">
<span class="setting-label">包含数字 (0-9)</span>
<label class="checkbox-wrapper">
<input type="checkbox" id="pwdNumbers" checked>
<span>启用</span>
</label>
</div>
<div class="setting-row">
<span class="setting-label">包含符号 (!@#$...)</span>
<label class="checkbox-wrapper">
<input type="checkbox" id="pwdSymbols">
<span>启用</span>
</label>
</div>
</div>
</div>
<div class="form-group">
<label class="form-label">网站链接</label>
<input type="text" class="form-input" id="url" placeholder="https://...">
<div class="input-hint">可选,方便快速访问网站,自动补全 http://</div>
</div>
<div class="form-group">
<label class="form-label">标签</label>
<input type="text" class="tags-simple-input" id="tagsInput" placeholder="工作|学习|生活">
<div class="input-hint">使用竖杆 | 分隔多个标签</div>
</div>
<div class="form-group">
<label class="form-label">备注</label>
<textarea class="form-input" id="note" placeholder="记录其他信息,如安全问题、绑定手机、备用邮箱等..."></textarea>
</div>
</form>
<div class="modal-footer">
<button type="button" class="btn-text danger" onclick="confirmDeleteCurrent()" id="deleteEditBtn" style="display: none; margin-right: auto;">删除</button>
<button type="button" class="btn-text" onclick="closeModal()">取消</button>
<button type="button" class="btn btn-primary" onclick="document.getElementById('accountForm').dispatchEvent(new Event('submit'))">保存</button>
</div>
</div>
</div>
<!-- View Modal -->
<div class="modal-overlay" id="viewModal" onclick="closeViewModalOnOverlay(event)">
<div class="modal view-modal">
<div class="modal-header">
<h2 class="modal-title" id="viewModalTitle">查看账号</h2>
<p class="modal-subtitle" id="viewModalSubtitle">查看账号详情</p>
</div>
<div class="modal-body" id="viewModalBody">
<!-- Dynamic content -->
</div>
<div class="modal-footer">
<button type="button" class="btn-text danger" onclick="confirmDeleteFromView()" style="margin-right: auto;">删除</button>
<button type="button" class="btn-text" onclick="closeViewModal()">关闭</button>
<button type="button" class="btn btn-primary" id="viewEditBtn">编辑</button>
</div>
</div>
</div>
<!-- Export Modal -->
<div class="modal-overlay" id="exportModal" onclick="closeExportModalOnOverlay(event)">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">导出数据</h2>
<p class="modal-subtitle">选择导出方式</p>
</div>
<div class="modal-body">
<div class="export-options">
<div class="export-option selected" onclick="selectExportType('plain')" id="exportPlain">
<div class="export-option-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
</svg>
</div>
<div class="export-option-title">普通导出</div>
<div class="export-option-desc">明文 JSON 格式</div>
</div>
<div class="export-option" onclick="selectExportType('encrypted')" id="exportEncrypted">
<div class="export-option-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<div class="export-option-title">加密导出</div>
<div class="export-option-desc">AES-256 加密</div>
</div>
</div>
<div id="exportPasswordSection" style="display: none;">
<div class="form-group">
<label class="form-label">设置导出密码 *</label>
<input type="password" class="form-input" id="exportPassword" placeholder="输入密码用于加密数据">
<div class="input-hint">请牢记此密码,导入时需要使用</div>
</div>
<div class="form-group">
<label class="form-label">确认密码 *</label>
<input type="password" class="form-input" id="exportPasswordConfirm" placeholder="再次输入密码">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-text" onclick="closeExportModal()">取消</button>
<button type="button" class="btn btn-primary" onclick="executeExport()">导出</button>
</div>
</div>
</div>
<!-- Import Password Modal -->
<div class="modal-overlay" id="importPasswordModal">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">输入导入密码</h2>
<p class="modal-subtitle">此数据文件已加密,需要密码才能解密</p>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">解密密码 *</label>
<input type="password" class="form-input" id="importPassword" placeholder="输入导出时设置的密码">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-text" onclick="cancelImport()">取消</button>
<button type="button" class="btn btn-primary" onclick="executeImportWithPassword()">导入</button>
</div>
</div>
</div>
<!-- Duplicate Confirm Modal -->
<div class="modal-overlay" id="duplicateModal">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">发现重复账号</h2>
<p class="modal-subtitle">该账号信息已存在</p>
</div>
<div class="modal-body">
<div class="confirm-desc">以下账号的所有信息与您要添加的账号完全相同:</div>
<div class="duplicate-info" id="duplicateInfo">
<!-- Dynamic content -->
</div>
<div class="confirm-desc" style="margin-top: 16px; color: var(--warning);">
<strong>提示:</strong>选择"覆盖"将用新信息替换原有账号(保留原ID和创建时间)。
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-text" onclick="cancelDuplicate()">取消添加</button>
<button type="button" class="btn btn-danger" onclick="forceAddDuplicate()">强制添加(覆盖)</button>
</div>
</div>
</div>
<!-- Confirm Clear Modal with Double Confirm -->
<div class="modal-overlay" id="confirmClearModal" onclick="closeConfirmClearModalOnOverlay(event)">
<div class="modal confirm-modal">
<div class="modal-body">
<div class="confirm-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</div>
<div class="confirm-title">确认清空所有数据?</div>
<div class="confirm-desc">此操作不可恢复,所有账号数据将被永久删除。<br>建议先导出数据备份。</div>
<div class="confirm-input-group">
<label class="confirm-input-label">请输入 <strong>DELETE</strong> 确认清空:</label>
<input type="text" class="confirm-input" id="clearConfirmInput" placeholder="输入 DELETE">
</div>
</div>
<div class="modal-footer" style="justify-content: center;">
<button type="button" class="btn-text" onclick="closeConfirmClearModal()">取消</button>
<button type="button" class="btn btn-danger" onclick="executeClearAll()" id="confirmClearBtn" disabled>确认清空</button>
</div>
</div>
</div>
<!-- Simple Confirm Modal for single delete -->
<div class="modal-overlay" id="confirmModal" onclick="closeConfirmModalOnOverlay(event)">
<div class="modal confirm-modal">
<div class="modal-body">
<div class="confirm-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</div>
<div class="confirm-title" id="simpleConfirmTitle">确认删除?</div>
<div class="confirm-desc" id="simpleConfirmDesc">此操作不可恢复。</div>
</div>
<div class="modal-footer" style="justify-content: center;">
<button type="button" class="btn-text" onclick="closeConfirmModal()">取消</button>
<button type="button" class="btn btn-danger" onclick="executeSimpleConfirm()">确认删除</button>
</div>
</div>
</div>
<div class="toast" id="toast">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
<span id="toastMsg">操作成功</span>
</div>
<script>
// Data
let accounts = JSON.parse(localStorage.getItem('pwd_accounts') || '[]');
let currentFilter = 'all';
let editingId = null;
let viewingId = null;
let simpleConfirmCallback = null;
let duplicateCallback = null;
let pendingExportType = 'plain';
let pendingImportData = null;
let pendingImportFile = null;
// Password Generator Settings
const defaultPwdSettings = {
length: 16,
uppercase: true,
lowercase: true,
numbers: true,
symbols: false
};
// Check if mobile
function isMobile() {
return window.innerWidth <= 768;
}
// Parse tags from pipe-separated string
function parseTags(tagsStr) {
if (!tagsStr) return [];
return tagsStr.split('|').map(t => t.trim()).filter(t => t.length > 0);
}
// Format tags to pipe-separated string
function formatTags(tags) {
if (!tags || tags.length === 0) return '';
return tags.join(' | ');
}
// Auto-fix URL
function fixUrl(url) {
if (!url) return '';
url = url.trim();
if (!url) return '';
if (!/^https?:\/\//i.test(url)) {
url = 'http://' + url;
}
return url;
}
// Check if account is exactly the same
function isAccountEqual(acc1, acc2) {
const normalizeTags = (tags) => {
if (!tags) return '';
return [...tags].sort().join('|');
};
return acc1.platform === acc2.platform &&
acc1.username === acc2.username &&
acc1.password === acc2.password &&
(acc1.url || '') === (acc2.url || '') &&
normalizeTags(acc1.tags) === normalizeTags(acc2.tags) &&
(acc1.note || '') === (acc2.note || '');
}
// Check for duplicate account
function findDuplicate(newAcc, excludeId = null) {
return accounts.find(acc => {
if (excludeId && acc.id === excludeId) return false;
return isAccountEqual(acc, newAcc);
});
}
// Simple XOR encryption for demo (in production use Web Crypto API)
async function encryptData(data, password) {
const encoder = new TextEncoder();
const dataBytes = encoder.encode(JSON.stringify(data));
const keyBytes = encoder.encode(password);
const encrypted = new Uint8Array(dataBytes.length);
for (let i = 0; i < dataBytes.length; i++) {
encrypted[i] = dataBytes[i] ^ keyBytes[i % keyBytes.length];
}
// Convert to base64
const base64 = btoa(String.fromCharCode(...encrypted));
return {
encrypted: true,
data: base64,
salt: Date.now().toString()
};
}
async function decryptData(encryptedObj, password) {
try {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const keyBytes = encoder.encode(password);
// Decode base64
const encryptedBytes = Uint8Array.from(atob(encryptedObj.data), c => c.charCodeAt(0));
const decrypted = new Uint8Array(encryptedBytes.length);
for (let i = 0; i < encryptedBytes.length; i++) {
decrypted[i] = encryptedBytes[i] ^ keyBytes[i % keyBytes.length];
}
const jsonStr = decoder.decode(decrypted);
return JSON.parse(jsonStr);
} catch (e) {
throw new Error('密码错误或数据损坏');
}
}
// Theme
function initTheme() {
const saved = localStorage.getItem('pwd_theme') || 'light';
document.documentElement.setAttribute('data-theme', saved);
updateThemeIcon(saved);
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('pwd_theme', next);
updateThemeIcon(next);
}
function updateThemeIcon(theme) {
const icon = document.getElementById('themeIcon');
if (theme === 'dark') {
icon.innerHTML = '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>';
} else {
icon.innerHTML = '<circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>';
}
}
// Tags
function getAllTags() {
const tags = new Set();
accounts.forEach(a => a.tags?.forEach(t => tags.add(t)));
return Array.from(tags).sort();
}
function renderTags() {
const container = document.getElementById('tagsContainer');
const allTags = getAllTags();
let html = '<span class="tag-label">筛选:</span>';
html += `<span class="tag ${currentFilter === 'all' ? 'active' : ''}" onclick="filterByTag('all')">全部</span>`;
allTags.forEach(tag => {
html += `<span class="tag ${currentFilter === tag ? 'active' : ''}" onclick="filterByTag('${tag}')">${escapeHtml(tag)}</span>`;
});
// Add mobile button
html += `<button class="mobile-add-btn mobile-only" onclick="openModal()" title="添加账号">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>`;
container.innerHTML = html;
}
function filterByTag(tag) {
currentFilter = tag;
renderTags();
renderAccounts();
}
// Password Generator
function toggleGeneratorSettings() {
const settings = document.getElementById('generatorSettings');
settings.style.display = settings.style.display === 'none' ? 'block' : 'none';
}
function generatePassword() {
const length = parseInt(document.getElementById('pwdLength').value) || 16;
const useUpper = document.getElementById('pwdUppercase').checked;
const useLower = document.getElementById('pwdLowercase').checked;
const useNumbers = document.getElementById('pwdNumbers').checked;
const useSymbols = document.getElementById('pwdSymbols').checked;
let chars = '';
if (useUpper) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
if (useLower) chars += 'abcdefghijklmnopqrstuvwxyz';
if (useNumbers) chars += '0123456789';
if (useSymbols) chars += '!@#$%^&*()_+-=[]{}|;:,.<>?';
if (chars === '') {
showToast('请至少选择一种字符类型');
return;
}
let password = '';
const array = new Uint32Array(length);
crypto.getRandomValues(array);
for (let i = 0; i < length; i++) {
password += chars[array[i] % chars.length];
}
document.getElementById('password').value = password;
document.getElementById('password').type = 'text';
showToast('密码已生成');
}
// Modal & Form - No overlay click close
function openModal(id = null) {
editingId = id;
const modal = document.getElementById('modal');
const title = document.getElementById('modalTitle');
const form = document.getElementById('accountForm');
const deleteBtn = document.getElementById('deleteEditBtn');
// Reset generator settings visibility
document.getElementById('generatorSettings').style.display = 'none';
if (id) {
const acc = accounts.find(a => a.id === id);
title.textContent = '编辑账号';
document.getElementById('editId').value = id;
document.getElementById('platform').value = acc.platform;
document.getElementById('username').value = acc.username;
document.getElementById('password').value = acc.password;
document.getElementById('url').value = acc.url || '';
document.getElementById('tagsInput').value = formatTags(acc.tags);
document.getElementById('note').value = acc.note || '';
deleteBtn.style.display = 'block';
} else {
title.textContent = '添加账号';
form.reset();
document.getElementById('editId').value = '';
deleteBtn.style.display = 'none';
// Set default password settings
document.getElementById('pwdLength').value = defaultPwdSettings.length;
document.getElementById('pwdUppercase').checked = defaultPwdSettings.uppercase;
document.getElementById('pwdLowercase').checked = defaultPwdSettings.lowercase;
document.getElementById('pwdNumbers').checked = defaultPwdSettings.numbers;
document.getElementById('pwdSymbols').checked = defaultPwdSettings.symbols;
}
modal.classList.add('active');
document.getElementById('platform').focus();
}
function closeModal() {
document.getElementById('modal').classList.remove('active');
editingId = null;
}
// Removed: closeModalOnOverlay - now only closes via button
function togglePasswordInput() {
const input = document.getElementById('password');
const btn = event.target.closest('.icon-action-btn');
if (input.type === 'password') {
input.type = 'text';
} else {
input.type = 'password';
}
}
function confirmDeleteCurrent() {
if (editingId) {
const acc = accounts.find(a => a.id === editingId);
showSimpleConfirm(
`确认删除 ${acc.platform}?`,
'此操作不可恢复,账号将被永久删除。',
() => {
deleteAccount(editingId);
closeModal();
}
);
}
}
// View Modal
function openViewModal(id) {
viewingId = id;
const acc = accounts.find(a => a.id === id);
if (!acc) return;
document.getElementById('viewModalTitle').textContent = acc.platform;
document.getElementById('viewModalSubtitle').textContent = '查看账号详情';
let html = '';
// Username
html += `
<div class="view-field">
<div class="view-label">用户名</div>
<div class="view-value-box">
<span class="view-value-text">${escapeHtml(acc.username)}</span>
<div class="view-actions">
<button class="view-btn" onclick="copyFromView('${escapeHtml(acc.username)}', this)">复制</button>
</div>
</div>
</div>
`;
// Password
html += `
<div class="view-field">
<div class="view-label">密码</div>
<div class="view-value-box">
<span class="view-value-text">
<span id="viewPwdText" class="password-text" style="filter: blur(4px); user-select: none;">${escapeHtml(acc.password)}</span>
</span>
<div class="view-actions">
<button class="view-btn" onclick="toggleViewPassword()" id="viewPwdBtn">显示</button>
<button class="view-btn" onclick="copyFromView('${escapeHtml(acc.password)}', this)">复制</button>
</div>
</div>
</div>
`;
// Tags - container style
if (acc.tags && acc.tags.length > 0) {
html += `
<div class="view-field">
<div class="view-label">标签</div>
<div class="view-tags-container">
${acc.tags.map(t => `<span class="view-tag-item">${escapeHtml(t)}</span>`).join('')}
</div>
</div>
`;
}
// URL
if (acc.url) {
html += `
<div class="view-field">
<div class="view-label">网站链接</div>
<a href="${escapeHtml(acc.url)}" target="_blank" class="view-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
${escapeHtml(acc.url)}
</a>
</div>
`;
}
// Note - resizable like edit mode
if (acc.note) {
html += `
<div class="view-field">
<div class="view-label">备注(可拖动调整大小)</div>
<textarea class="view-note-resizable" readonly>${escapeHtml(acc.note)}</textarea>
</div>
`;
}
document.getElementById('viewModalBody').innerHTML = html;
// Set edit button action
document.getElementById('viewEditBtn').onclick = () => {
closeViewModal();
setTimeout(() => openModal(id), 100);
};
document.getElementById('viewModal').classList.add('active');
}
function closeViewModal() {
document.getElementById('viewModal').classList.remove('active');
viewingId = null;
}
function closeViewModalOnOverlay(e) {
if (e.target === e.currentTarget) closeViewModal();
}
function toggleViewPassword() {
const pwd = document.getElementById('viewPwdText');
const btn = document.getElementById('viewPwdBtn');
if (pwd.style.filter === 'blur(4px)') {
pwd.style.filter = 'none';
pwd.style.userSelect = 'text';
btn.textContent = '隐藏';
} else {
pwd.style.filter = 'blur(4px)';
pwd.style.userSelect = 'none';
btn.textContent = '显示';
}
}
function confirmDeleteFromView() {
if (viewingId) {
const acc = accounts.find(a => a.id === viewingId);
showSimpleConfirm(
`确认删除 ${acc.platform}?`,
'此操作不可恢复,账号将被永久删除。',
() => {
deleteAccount(viewingId);
closeViewModal();
}
);
}
}
function copyFromView(text, btn) {
navigator.clipboard.writeText(text).then(() => {
btn.textContent = '已复制';
btn.classList.add('copied');
showToast('已复制到剪贴板');
setTimeout(() => {
btn.textContent = '复制';
btn.classList.remove('copied');
}, 2000);
}).catch(() => {
// Fallback for mobile
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
btn.textContent = '已复制';
btn.classList.add('copied');
showToast('已复制到剪贴板');
setTimeout(() => {
btn.textContent = '复制';
btn.classList.remove('copied');
}, 2000);
} catch (err) {
showToast('复制失败');
}
document.body.removeChild(textArea);
});
}
// Save with duplicate check
function saveAccount(e) {
e.preventDefault();
const platform = document.getElementById('platform').value.trim();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const url = fixUrl(document.getElementById('url').value.trim());
const tags = parseTags(document.getElementById('tagsInput').value);
const note = document.getElementById('note').value.trim();
if (!platform || !username || !password) return;
const newAcc = {
platform,
username,
password,
url,
tags,
note
};
// Check for duplicates when adding new account
if (!editingId) {
const duplicate = findDuplicate(newAcc);
if (duplicate) {
showDuplicateModal(newAcc, duplicate);
return;
}
}
// Save directly if editing or no duplicate
doSaveAccount(newAcc);
}
function doSaveAccount(newAcc, replaceId = null) {
const data = {
id: replaceId || editingId || Date.now().toString(),
...newAcc,
updatedAt: new Date().toISOString()
};
if (editingId || replaceId) {
const idx = accounts.findIndex(a => a.id === (replaceId || editingId));
if (idx >= 0) {
// Preserve original created time if replacing
if (replaceId && !editingId) {
data.updatedAt = accounts[idx].updatedAt;
}
accounts[idx] = data;
}
showToast(replaceId && !editingId ? '账号已覆盖' : '账号已更新');
} else {
accounts.push(data);
showToast('账号已添加');
}
saveData();
closeModal();
renderAccounts();
renderTags();
}
function showDuplicateModal(newAcc, duplicate) {
const infoHtml = `
<div class="duplicate-info-item">
<span class="duplicate-info-label">平台</span>
<span class="duplicate-info-value">${escapeHtml(duplicate.platform)}</span>
</div>
<div class="duplicate-info-item">
<span class="duplicate-info-label">用户名</span>
<span class="duplicate-info-value">${escapeHtml(duplicate.username)}</span>
</div>
<div class="duplicate-info-item">
<span class="duplicate-info-label">密码</span>
<span class="duplicate-info-value">••••••</span>
</div>
${duplicate.url ? `
<div class="duplicate-info-item">
<span class="duplicate-info-label">链接</span>
<span class="duplicate-info-value">${escapeHtml(duplicate.url)}</span>
</div>
` : ''}
${duplicate.tags?.length ? `
<div class="duplicate-info-item">
<span class="duplicate-info-label">标签</span>
<span class="duplicate-info-value">${formatTags(duplicate.tags)}</span>
</div>
` : ''}
${duplicate.note ? `
<div class="duplicate-info-item">
<span class="duplicate-info-label">备注</span>
<span class="duplicate-info-value">${escapeHtml(duplicate.note)}</span>
</div>
` : ''}
`;
document.getElementById('duplicateInfo').innerHTML = infoHtml;
document.getElementById('duplicateModal').classList.add('active');
// Store pending data
duplicateCallback = { newAcc, replaceId: duplicate.id };
}
function cancelDuplicate() {
document.getElementById('duplicateModal').classList.remove('active');
duplicateCallback = null;
}
function forceAddDuplicate() {
if (duplicateCallback) {
const { newAcc, replaceId } = duplicateCallback;
doSaveAccount(newAcc, replaceId);
document.getElementById('duplicateModal').classList.remove('active');
duplicateCallback = null;
}
}
function deleteAccount(id) {
accounts = accounts.filter(a => a.id !== id);
saveData();
renderAccounts();
renderTags();
showToast('账号已删除');
}
// Export Functions
function showExportModal() {
if (!accounts.length) {
showToast('暂无数据可导出');
return;
}
document.getElementById('exportModal').classList.add('active');
selectExportType('plain');
}
function closeExportModal() {
document.getElementById('exportModal').classList.remove('active');
}
function closeExportModalOnOverlay(e) {
if (e.target === e.currentTarget) closeExportModal();
}
function selectExportType(type) {
pendingExportType = type;
document.getElementById('exportPlain').classList.toggle('selected', type === 'plain');
document.getElementById('exportEncrypted').classList.toggle('selected', type === 'encrypted');
const pwdSection = document.getElementById('exportPasswordSection');
pwdSection.style.display = type === 'encrypted' ? 'block' : 'none';
if (type === 'encrypted') {
setTimeout(() => document.getElementById('exportPassword').focus(), 100);
}
}
async function executeExport() {
const type = pendingExportType;
if (type === 'encrypted') {
const pwd = document.getElementById('exportPassword').value;
const pwdConfirm = document.getElementById('exportPasswordConfirm').value;
if (!pwd) {
showToast('请输入导出密码');
return;
}
if (pwd !== pwdConfirm) {
showToast('两次输入的密码不一致');
return;
}
if (pwd.length < 6) {
showToast('密码长度至少6位');
return;
}
try {
const encrypted = await encryptData(accounts, pwd);
downloadFile(JSON.stringify(encrypted, null, 2), `accounts_encrypted_${new Date().toISOString().split('T')[0]}.json`);
showToast('加密数据已导出');
closeExportModal();
} catch (e) {
showToast('加密失败:' + e.message);
}
} else {
const data = JSON.stringify(accounts, null, 2);
downloadFile(data, `accounts_${new Date().toISOString().split('T')[0]}.json`);
showToast('数据已导出');
closeExportModal();
}
}
function downloadFile(content, filename) {
const blob = new Blob([content], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
// Import Functions
async function importData(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
try {
const content = e.target.result;
let data;
try {
data = JSON.parse(content);
} catch (err) {
throw new Error('文件格式错误,不是有效的 JSON');
}
// Check if encrypted
if (data.encrypted && data.data) {
pendingImportData = data;
pendingImportFile = file;
document.getElementById('importPasswordModal').classList.add('active');
document.getElementById('importPassword').value = '';
setTimeout(() => document.getElementById('importPassword').focus(), 100);
return;
}
// Plain import
if (!Array.isArray(data)) {
throw new Error('数据格式错误,应为账号数组');
}
processImportData(data);
} catch (err) {
alert('导入失败:' + err.message);
}
input.value = '';
};
reader.readAsText(file);
}
async function executeImportWithPassword() {
const pwd = document.getElementById('importPassword').value;
if (!pwd) {
showToast('请输入解密密码');
return;
}
try {
const decrypted = await decryptData(pendingImportData, pwd);
if (!Array.isArray(decrypted)) {
throw new Error('解密后数据格式错误');
}
processImportData(decrypted);
document.getElementById('importPasswordModal').classList.remove('active');
pendingImportData = null;
pendingImportFile = null;
} catch (err) {
showToast('解密失败:' + err.message);
}
}
function cancelImport() {
document.getElementById('importPasswordModal').classList.remove('active');
pendingImportData = null;
pendingImportFile = null;
document.getElementById('importFile').value = '';
}
function processImportData(data) {
// Fix URLs in imported data
data.forEach(item => {
if (item.url) {
item.url = fixUrl(item.url);
}
});
let added = 0;
let skipped = 0;
let updated = 0;
const skippedItems = [];
data.forEach(item => {
if (!item.id || !item.platform) return;
// Check for exact duplicate
const existing = accounts.find(a => a.id === item.id);
if (existing) {
// Check if exactly the same
if (isAccountEqual(existing, item)) {
skipped++;
skippedItems.push(item.platform);
return;
}
// Different - update if newer
if (new Date(item.updatedAt) > new Date(existing.updatedAt)) {
accounts[accounts.indexOf(existing)] = item;
updated++;
}
} else {
// Check if new item is duplicate of existing (by content)
const contentDuplicate = accounts.find(a => isAccountEqual(a, item));
if (contentDuplicate) {
skipped++;
skippedItems.push(item.platform);
return;
}
accounts.push(item);
added++;
}
});
saveData();
renderAccounts();
renderTags();
let msg = `导入完成:新增 ${added} 条`;
if (updated > 0) msg += `,更新 ${updated} 条`;
if (skipped > 0) msg += `,跳过 ${skipped} 条重复`;
showToast(msg);
if (skippedItems.length > 0) {
setTimeout(() => {
alert(`以下账号因信息完全相同已跳过:\n${skippedItems.join('\n')}`);
}, 300);
}
}
// Clear All with Double Confirm
function confirmClearAll() {
if (accounts.length === 0) {
showToast('暂无数据可清空');
return;
}
document.getElementById('confirmClearModal').classList.add('active');
document.getElementById('clearConfirmInput').value = '';
document.getElementById('clearConfirmInput').classList.remove('error');
document.getElementById('confirmClearBtn').disabled = true;
document.getElementById('clearConfirmInput').focus();
}
function closeConfirmClearModal() {
document.getElementById('confirmClearModal').classList.remove('active');
}
function closeConfirmClearModalOnOverlay(e) {
if (e.target === e.currentTarget) closeConfirmClearModal();
}
// Real-time validation for clear confirm
document.getElementById('clearConfirmInput')?.addEventListener('input', function(e) {
const input = e.target;
const btn = document.getElementById('confirmClearBtn');
if (input.value === 'DELETE') {
btn.disabled = false;
input.classList.remove('error');
} else {
btn.disabled = true;
if (input.value.length >= 6) {
input.classList.add('error');
} else {
input.classList.remove('error');
}
}
});
function executeClearAll() {
const input = document.getElementById('clearConfirmInput');
if (input.value !== 'DELETE') {
input.classList.add('error');
return;
}
accounts = [];
saveData();
renderAccounts();
renderTags();
closeConfirmClearModal();
showToast('所有数据已清空');
}
// Simple Confirm Modal
function showSimpleConfirm(title, desc, callback) {
simpleConfirmCallback = callback;
document.getElementById('simpleConfirmTitle').textContent = title;
document.getElementById('simpleConfirmDesc').textContent = desc;
document.getElementById('confirmModal').classList.add('active');
}
function closeConfirmModal() {
document.getElementById('confirmModal').classList.remove('active');
simpleConfirmCallback = null;
}
function closeConfirmModalOnOverlay(e) {
if (e.target === e.currentTarget) closeConfirmModal();
}
function executeSimpleConfirm() {
if (simpleConfirmCallback) {
simpleConfirmCallback();
}
closeConfirmModal();
}
// Render
function renderAccounts() {
const search = document.getElementById('searchInput').value.toLowerCase();
const grid = document.getElementById('accountsGrid');
const empty = document.getElementById('emptyState');
let filtered = accounts.filter(a => {
const matchSearch = !search ||
a.platform.toLowerCase().includes(search) ||
a.username.toLowerCase().includes(search) ||
a.tags?.some(t => t.toLowerCase().includes(search)) ||
(a.url && a.url.toLowerCase().includes(search)) ||
a.note?.toLowerCase().includes(search);
const matchTag = currentFilter === 'all' || a.tags?.includes(currentFilter);
return matchSearch && matchTag;
});
// Sort accounts by update time
filtered.sort((a, b) => {
const timeA = new Date(a.updatedAt).getTime();
const timeB = new Date(b.updatedAt).getTime();
if (window.sortOrder === 'oldest') {
return timeA - timeB; // Oldest first
}
return timeB - timeA; // Newest first (default)
});
document.getElementById('totalCount').textContent = filtered.length;
if (accounts.length > 0) {
const last = accounts.reduce((a, b) =>
new Date(a.updatedAt) > new Date(b.updatedAt) ? a : b
);
document.getElementById('lastUpdate').textContent = new Date(last.updatedAt).toLocaleDateString('zh-CN');
} else {
document.getElementById('lastUpdate').textContent = '-';
}
if (filtered.length === 0) {
grid.style.display = 'none';
empty.style.display = 'block';
empty.querySelector('.empty-title').textContent = search || currentFilter !== 'all' ? '无匹配结果' : '暂无账号';
empty.querySelector('.empty-desc').textContent = search || currentFilter !== 'all' ? '尝试其他关键词或筛选条件' : '点击右上角"添加账号"开始使用';
return;
}
grid.style.display = 'grid';
empty.style.display = 'none';
grid.innerHTML = filtered.map(acc => `
<div class="account-card" onclick="handleCardClick(event, '${acc.id}')">
<div class="card-header">
<div class="platform-info">
<div class="platform-icon">${acc.platform.charAt(0).toUpperCase()}</div>
<div class="platform-meta">
<div class="platform-name">${escapeHtml(acc.platform)}</div>
${acc.url ? `<a href="${escapeHtml(acc.url)}" target="_blank" class="platform-url" title="访问网站" onclick="event.stopPropagation()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
${escapeHtml(acc.url.replace(/^https?:\/\//, '').replace(/\/$/, ''))}
</a>` : ''}
${acc.tags?.length ? `<div class="card-tags">
${acc.tags.map(t => `<span class="card-tag">${escapeHtml(t)}</span>`).join('')}
</div>` : ''}
</div>
</div>
<div class="card-actions" onclick="event.stopPropagation()">
<button class="card-btn" onclick="openModal('${acc.id}')" title="编辑">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button class="card-btn delete" onclick="confirmDeleteAccount('${acc.id}', '${escapeHtml(acc.platform)}'); event.stopPropagation();" title="删除">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
</div>
<div class="fields">
<div class="field">
<span class="field-label">用户名</span>
<div class="field-value">
<span class="field-text">${escapeHtml(acc.username)}</span>
<div class="field-actions">
<button class="field-btn" onclick="copyText('${escapeHtml(acc.username)}', this); event.stopPropagation();">复制</button>
</div>
</div>
</div>
<div class="field">
<span class="field-label">密码</span>
<div class="field-value">
<span class="field-text password-text" id="pwd-${acc.id}">${escapeHtml(acc.password)}</span>
<div class="field-actions">
<button class="field-btn" onclick="togglePwd('${acc.id}'); event.stopPropagation();">显示</button>
<button class="field-btn" onclick="copyText('${escapeHtml(acc.password)}', this); event.stopPropagation();">复制</button>
</div>
</div>
</div>
${acc.note ? `
<div class="field">
<span class="field-label">备注</span>
<div class="note-text">${escapeHtml(acc.note)}</div>
</div>
` : ''}
</div>
<div class="card-footer">
<div class="update-time">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
<span>${formatRelativeTime(acc.updatedAt)}</span>
</div>
</div>
</div>
`).join('');
}
function handleCardClick(event, id) {
// Don't open view if clicking on buttons or links
if (event.target.closest('.card-actions') || event.target.closest('.field-actions') || event.target.closest('a')) {
return;
}
openViewModal(id);
}
function togglePwd(id) {
const el = document.getElementById(`pwd-${id}`);
const btn = event.target;
if (el.classList.contains('revealed')) {
el.classList.remove('revealed');
btn.textContent = '显示';
} else {
el.classList.add('revealed');
btn.textContent = '隐藏';
}
}
// Utils
function copyText(text, btn) {
// Try modern clipboard API first
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
showCopied(btn);
}).catch(() => {
fallbackCopy(text, btn);
});
} else {
fallbackCopy(text, btn);
}
}
function fallbackCopy(text, btn) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '0';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
showCopied(btn);
} else {
showToast('复制失败');
}
} catch (err) {
showToast('复制失败');
}
document.body.removeChild(textArea);
}
function showCopied(btn) {
btn.textContent = '已复制';
btn.classList.add('copied');
showToast('已复制到剪贴板');
setTimeout(() => {
btn.textContent = '复制';
btn.classList.remove('copied');
}, 2000);
}
function showToast(msg) {
const toast = document.getElementById('toast');
document.getElementById('toastMsg').textContent = msg;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 3000);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Format relative time in Chinese
function formatRelativeTime(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
const diffMonth = Math.floor(diffDay / 30);
const diffYear = Math.floor(diffDay / 365);
if (diffSec < 60) {
return '刚刚';
} else if (diffMin < 60) {
return `${diffMin}分钟前`;
} else if (diffHour < 24) {
return `${diffHour}小时前`;
} else if (diffDay === 1) {
return '昨天';
} else if (diffDay < 30) {
return `${diffDay}天前`;
} else if (diffMonth < 12) {
return `${diffMonth}个月前`;
} else if (diffYear === 1) {
return '1年前';
} else {
return `${diffYear}年前`;
}
}
// Confirm delete with modal
function confirmDeleteAccount(id, platform) {
showSimpleConfirm(
`确认删除 ${platform}?`,
'此操作不可恢复,账号将被永久删除。',
() => {
deleteAccount(id);
}
);
}
function saveData() {
localStorage.setItem('pwd_accounts', JSON.stringify(accounts));
}
// Sort order - default newest first
window.sortOrder = 'newest';
// Toggle sort order
function toggleSortOrder() {
window.sortOrder = window.sortOrder === 'newest' ? 'oldest' : 'newest';
updateSortIcon();
renderAccounts();
showToast(window.sortOrder === 'newest' ? '已切换:最新在前' : '已切换:最早在前');
}
// Update sort icon based on current order
function updateSortIcon() {
const icon = document.getElementById('sortIcon');
const btn = document.getElementById('sortBtn');
const text = document.getElementById('sortText');
if (window.sortOrder === 'newest') {
// Newest first icon (arrow up - descending)
icon.innerHTML = `
<polyline points="12 6 12 18" style="stroke-width: 2.5"></polyline>
<polyline points="8 10 12 6 16 10" style="stroke-width: 2.5"></polyline>
`;
btn.title = "当前:最新在前,点击切换";
btn.classList.add('active');
if (text) text.textContent = '最新';
} else {
// Oldest first icon (arrow down - ascending)
icon.innerHTML = `
<polyline points="12 6 12 18" style="stroke-width: 2.5"></polyline>
<polyline points="8 14 12 18 16 14" style="stroke-width: 2.5"></polyline>
`;
btn.title = "当前:最早在前,点击切换";
btn.classList.remove('active');
if (text) text.textContent = '最早';
}
}
// Init
document.addEventListener('DOMContentLoaded', () => {
initTheme();
updateSortIcon();
renderAccounts();
renderTags();
});
// Handle resize
let lastWidth = window.innerWidth;
window.addEventListener('resize', () => {
const currentWidth = window.innerWidth;
const wasMobile = lastWidth <= 768;
const isMobileNow = currentWidth <= 768;
if (wasMobile !== isMobileNow) {
renderAccounts();
}
lastWidth = currentWidth;
});
// Keyboard
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
closeViewModal();
closeConfirmModal();
closeConfirmClearModal();
closeExportModal();
cancelImport();
cancelDuplicate();
}
});
</script>
</body>
</html>这是加了版权信息的完整代码:
<!-- 原创代码专属留痕 请勿移除 -->
<!--
============================================
作者:Yangxiao
作者域名:aszv.top
创作日期:2026-05-08
本代码为本人原创,盗用必究
============================================
-->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta name="author" content="1425202077">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=0.9, maximum-scale=0.9, user-scalable=no">
<title>账号管理器</title>
<style>
:root {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--bg-tertiary: #fafafa;
--text-primary: #111111;
--text-secondary: #666666;
--text-tertiary: #999999;
--border: #e5e5e5;
--accent: #0066ff;
--accent-hover: #0052cc;
--danger: #ff3b30;
--success: #34c759;
--warning: #ff9500;
--shadow: rgba(0, 0, 0, 0.08);
--card-bg: #ffffff;
--input-bg: #ffffff;
--modal-overlay: rgba(0, 0, 0, 0.5);
}
[data-theme="dark"] {
--bg-primary: #000000;
--bg-secondary: #1c1c1e;
--bg-tertiary: #2c2c2e;
--text-primary: #ffffff;
--text-secondary: #8e8e93;
--text-tertiary: #636366;
--border: #38383a;
--accent: #0a84ff;
--accent-hover: #409cff;
--danger: #ff453a;
--success: #30d158;
--warning: #ff9f0a;
--shadow: rgba(0, 0, 0, 0.3);
--card-bg: #1c1c1e;
--input-bg: #2c2c2e;
--modal-overlay: rgba(0, 0, 0, 0.8);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
/* Allow text selection in specific elements */
.field-text, .view-value-text, .note-text, .form-input, textarea, input {
-webkit-user-select: text;
user-select: text;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
transition: background 0.3s, color 0.3s;
}
/* Sort Icon Button - Stats Bar Style */
.sort-icon-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-secondary);
border-radius: 20px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
margin-left: auto;
}
.sort-icon-btn:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--bg-tertiary);
}
.sort-icon-btn.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.sort-icon-btn.active:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.sort-text {
font-size: 12px;
font-weight: 600;
}
/* Header */
.header {
position: sticky;
top: 0;
background: var(--bg-primary);
border-bottom: 1px solid var(--border);
z-index: 100;
transition: all 0.3s;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.5px;
}
.logo-icon {
width: 32px;
height: 32px;
background: var(--accent);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.icon-btn {
width: 36px;
height: 36px;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
outline: none;
}
.icon-btn:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.icon-btn.danger:hover {
background: var(--danger);
color: white;
}
/* Search Bar */
.search-section {
max-width: 1200px;
margin: 0 auto;
padding: 20px 24px;
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.search-box {
flex: 1;
min-width: 280px;
position: relative;
}
.search-input {
width: 100%;
padding: 12px 16px 12px 40px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--input-bg);
color: var(--text-primary);
font-size: 15px;
transition: all 0.2s;
outline: none;
}
.search-input:focus {
outline: none;
border-color: var(--accent);
}
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
pointer-events: none;
}
.btn {
padding: 12px 20px;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
white-space: nowrap;
outline: none;
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
transform: translateY(-1px);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--bg-tertiary);
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn-danger:hover {
background: #ff2d55;
transform: translateY(-1px);
}
/* Tags Filter */
.tags-section {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px 20px;
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
position: relative;
}
.tag-label {
font-size: 13px;
color: var(--text-tertiary);
font-weight: 500;
}
.tag {
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: 1px solid var(--border);
background: var(--bg-primary);
color: var(--text-secondary);
}
.tag:hover {
border-color: var(--accent);
color: var(--accent);
}
.tag.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
/* Main Content */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px 40px;
}
.stats-bar {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 24px;
font-size: 13px;
color: var(--text-tertiary);
}
.stat-item {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.stat-value {
color: var(--text-primary);
font-weight: 600;
}
/* Grid Layout */
.accounts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 16px;
}
/* Card Design */
.account-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 16px;
padding: 20px;
transition: all 0.2s;
position: relative;
cursor: pointer;
min-width: 0;
outline: none;
}
.account-card:hover {
border-color: var(--accent);
box-shadow: 0 4px 20px var(--shadow);
transform: translateY(-2px);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 16px;
min-width: 0;
}
.platform-info {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.platform-icon {
width: 44px;
height: 44px;
background: var(--bg-secondary);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 600;
color: var(--accent);
flex-shrink: 0;
}
.platform-meta {
min-width: 0;
flex: 1;
overflow: hidden;
}
.platform-name {
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.platform-url {
font-size: 13px;
color: var(--accent);
text-decoration: none;
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.platform-url:hover {
text-decoration: underline;
}
/* Tags container with flex wrap */
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.card-tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 12px;
font-size: 11px;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.2s;
}
.card-tag:hover {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.card-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s;
flex-shrink: 0;
}
.account-card:hover .card-actions {
opacity: 1;
}
.card-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
outline: none;
}
.card-btn:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.card-btn.delete:hover {
color: var(--danger);
}
/* Fields */
.fields {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.field-label {
font-size: 12px;
color: var(--text-tertiary);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.field-value {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-secondary);
padding: 10px 12px;
border-radius: 10px;
font-size: 14px;
font-family: 'SF Mono', Monaco, monospace;
min-width: 0;
}
.field-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
}
.password-text {
filter: blur(4px);
transition: filter 0.2s;
user-select: none;
}
.password-text.revealed {
filter: none;
user-select: text;
}
.field-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.field-btn {
padding: 4px 8px;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
transition: all 0.2s;
outline: none;
}
.field-btn:hover {
background: var(--bg-primary);
color: var(--accent);
}
.field-btn.copied {
color: var(--success);
}
.note-text {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
padding: 8px 12px;
background: var(--bg-secondary);
border-radius: 8px;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 80px 20px;
color: var(--text-tertiary);
}
.empty-icon {
width: 80px;
height: 80px;
margin: 0 auto 20px;
background: var(--bg-secondary);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
}
.empty-title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.empty-desc {
font-size: 14px;
max-width: 400px;
margin: 0 auto;
line-height: 1.6;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--modal-overlay);
backdrop-filter: blur(8px);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
padding: 20px;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: var(--bg-primary);
border-radius: 20px;
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
animation: modalIn 0.3s ease;
}
@keyframes modalIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.modal-header {
padding: 24px 24px 0;
}
.modal-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 4px;
}
.modal-subtitle {
font-size: 14px;
color: var(--text-secondary);
}
.modal-body {
padding: 20px 24px;
overflow-y: auto;
max-height: calc(90vh - 140px);
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 6px;
}
.form-input {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--input-bg);
color: var(--text-primary);
font-size: 15px;
transition: all 0.2s;
outline: none;
}
.form-input:focus {
outline: none;
border-color: var(--accent);
}
.form-input::placeholder {
color: var(--text-tertiary);
}
textarea.form-input {
min-height: 80px;
resize: vertical;
font-family: inherit;
line-height: 1.5;
}
.input-hint {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 4px;
}
/* Password field with generator */
.password-field-wrapper {
position: relative;
display: flex;
gap: 8px;
align-items: center;
}
.password-input-wrapper {
position: relative;
flex: 1;
}
.password-actions {
display: flex;
gap: 4px;
align-items: center;
}
.icon-action-btn {
width: 36px;
height: 36px;
border: none;
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
outline: none;
flex-shrink: 0;
}
.icon-action-btn:hover {
background: var(--accent);
color: white;
}
.icon-action-btn svg {
width: 18px;
height: 18px;
}
/* Tags input with pipe separator */
.tags-simple-input {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--input-bg);
color: var(--text-primary);
font-size: 15px;
transition: all 0.2s;
outline: none;
}
.tags-simple-input:focus {
outline: none;
border-color: var(--accent);
}
.modal-footer {
padding: 16px 24px 24px;
display: flex;
justify-content: flex-end;
gap: 12px;
border-top: 1px solid var(--border);
}
.btn-text {
padding: 10px 20px;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
font-size: 15px;
font-weight: 500;
border-radius: 10px;
transition: all 0.2s;
outline: none;
min-width: 80px;
text-align: center;
}
.btn-text:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
border-color: var(--text-tertiary);
}
.btn-text.danger {
color: var(--danger);
border-color: var(--danger);
background: rgba(255, 59, 48, 0.05);
}
.btn-text.danger:hover {
background: var(--danger);
color: white;
border-color: var(--danger);
}
/* View Modal Styles */
.view-modal .modal-body {
padding: 24px;
}
.view-field {
margin-bottom: 20px;
}
.view-field:last-child {
margin-bottom: 0;
}
.view-label {
font-size: 12px;
color: var(--text-tertiary);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
margin-bottom: 8px;
}
.view-value-box {
background: var(--bg-secondary);
padding: 14px 16px;
border-radius: 12px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 15px;
color: var(--text-primary);
word-break: break-all;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.view-value-text {
flex: 1;
overflow-wrap: break-word;
}
.view-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.view-btn {
padding: 6px 12px;
border: none;
background: var(--bg-primary);
color: var(--text-secondary);
cursor: pointer;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
outline: none;
}
.view-btn:hover {
background: var(--accent);
color: white;
}
.view-btn.copied {
background: var(--success);
color: white;
}
.view-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.view-tag {
padding: 6px 14px;
background: var(--bg-secondary);
color: var(--text-secondary);
border-radius: 20px;
font-size: 13px;
font-weight: 500;
}
/* View note with resize like edit mode */
.view-note-resizable {
background: var(--bg-secondary);
padding: 14px 16px;
border-radius: 12px;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
white-space: pre-wrap;
width: 100%;
min-height: 80px;
resize: vertical;
border: 1px solid transparent;
font-family: inherit;
overflow: auto;
}
.view-note-resizable:focus {
outline: none;
border-color: var(--accent);
}
.view-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--accent);
text-decoration: none;
font-size: 14px;
margin-top: 8px;
}
.view-link:hover {
text-decoration: underline;
}
/* Toast */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
background: var(--text-primary);
color: var(--bg-primary);
padding: 14px 20px;
border-radius: 12px;
font-size: 14px;
font-weight: 500;
display: none;
align-items: center;
gap: 10px;
box-shadow: 0 10px 30px var(--shadow);
z-index: 2000;
animation: toastIn 0.3s ease;
}
.toast.show {
display: flex;
}
@keyframes toastIn {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Confirm Modal */
.confirm-modal .modal-body {
padding: 24px;
text-align: center;
}
.confirm-icon {
width: 64px;
height: 64px;
margin: 0 auto 16px;
background: var(--danger);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.confirm-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.confirm-desc {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.5;
}
/* Double confirm input */
.confirm-input-group {
margin-top: 20px;
text-align: left;
}
.confirm-input-label {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 8px;
display: block;
}
.confirm-input {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--input-bg);
color: var(--text-primary);
font-size: 15px;
text-align: center;
font-weight: 500;
}
.confirm-input:focus {
outline: none;
border-color: var(--danger);
}
.confirm-input.error {
border-color: var(--danger);
background: rgba(255, 59, 48, 0.1);
}
/* View Modal Tags */
.view-tags-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.view-tag-item {
padding: 6px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 20px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
/* Password Generator Settings */
.generator-settings {
background: var(--bg-secondary);
border-radius: 12px;
padding: 16px;
margin-top: 12px;
border: 1px solid var(--border);
}
.generator-settings-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.setting-row:last-child {
margin-bottom: 0;
}
.setting-label {
font-size: 14px;
color: var(--text-secondary);
}
.setting-control {
display: flex;
align-items: center;
gap: 8px;
}
.length-input {
width: 60px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--input-bg);
color: var(--text-primary);
font-size: 14px;
text-align: center;
}
.checkbox-wrapper {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.checkbox-wrapper input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.checkbox-wrapper span {
font-size: 14px;
color: var(--text-secondary);
}
/* Export/Import Options */
.export-options {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.export-option {
flex: 1;
padding: 16px;
border: 2px solid var(--border);
border-radius: 12px;
cursor: pointer;
text-align: center;
transition: all 0.2s;
}
.export-option:hover {
border-color: var(--accent);
}
.export-option.selected {
border-color: var(--accent);
background: rgba(0, 102, 255, 0.05);
}
.export-option-icon {
width: 48px;
height: 48px;
margin: 0 auto 8px;
background: var(--bg-secondary);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
}
.export-option-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.export-option-desc {
font-size: 12px;
color: var(--text-secondary);
}
/* Duplicate confirm modal */
.duplicate-info {
background: var(--bg-secondary);
border-radius: 12px;
padding: 16px;
margin: 16px 0;
text-align: left;
}
.duplicate-info-item {
display: flex;
gap: 8px;
margin-bottom: 8px;
font-size: 14px;
}
.duplicate-info-item:last-child {
margin-bottom: 0;
}
.duplicate-info-label {
color: var(--text-tertiary);
min-width: 60px;
}
.duplicate-info-value {
color: var(--text-primary);
font-weight: 500;
}
/* Desktop Styles (default) */
@media (min-width: 769px) {
.mobile-only {
display: none !important;
}
}
/* Mobile Styles - 90% zoom */
@media (max-width: 768px) {
html {
zoom: 0.9;
-moz-transform: scale(0.9);
-moz-transform-origin: 0 0;
}
@supports not (zoom: 0.9) {
body {
transform: scale(0.9);
transform-origin: 0 0;
width: 111.11%;
height: 111.11%;
}
}
.desktop-only {
display: none !important;
}
.header-content {
padding: 12px 16px;
}
.search-section {
padding: 16px;
}
.search-section .btn-primary {
display: none;
}
.sort-icon-btn {
padding: 4px 10px;
margin-left: auto;
}
.sort-icon-btn svg {
width: 12px;
height: 12px;
}
.sort-text {
font-size: 11px;
}
.tags-section {
padding: 0 16px 16px;
position: relative;
padding-right: 60px;
}
.mobile-add-btn {
position: absolute;
right: 16px;
top: 0;
width: 36px;
height: 36px;
border: none;
background: var(--accent);
color: white;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
outline: none;
}
.mobile-add-btn:hover {
background: var(--accent-hover);
transform: scale(1.05);
}
.container {
padding: 0 16px 32px;
}
.accounts-grid {
grid-template-columns: 1fr;
}
.card-actions {
opacity: 1;
}
/* Mobile copy fix */
.field-btn {
padding: 8px 12px;
font-size: 13px;
}
/* Ensure card doesn't overflow */
.account-card {
min-width: 0;
max-width: 100%;
}
.fields {
min-width: 0;
}
.field-value {
min-width: 0;
}
.note-text {
max-width: 100%;
}
/* Toast position fix for zoomed viewport */
.toast {
right: 20px;
bottom: 20px;
}
/* Generator settings on mobile */
.generator-settings {
padding: 12px;
}
.setting-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
/* Card Footer with Update Time */
.card-footer {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
}
.update-time {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-tertiary);
}
.update-time svg {
opacity: 0.6;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
</style>
<base target="_blank">
</head>
<body>
<header class="header">
<div class="header-content">
<div class="logo">
<div class="logo-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<rect x="3" y="11" width="18" height="11" rx="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<span>账号管理器</span>
</div>
<div class="header-actions">
<button class="icon-btn" onclick="toggleTheme()" title="切换主题">
<svg id="themeIcon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</button>
<button class="icon-btn" onclick="showExportModal()" title="导出数据">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
<button class="icon-btn" onclick="document.getElementById('importFile').click()" title="导入数据">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
</button>
<button class="icon-btn danger" onclick="confirmClearAll()" title="清空所有数据">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
<input type="file" id="importFile" style="display: none" accept=".json" onchange="importData(this)">
</div>
</div>
</header>
<section class="search-section">
<div class="search-box">
<svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
<input type="text" class="search-input" id="searchInput" placeholder="搜索平台、账号、标签、链接或备注..." oninput="renderAccounts()">
</div>
<button class="btn btn-primary desktop-only" onclick="openModal()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
添加账号
</button>
</section>
<section class="tags-section" id="tagsContainer">
<span class="tag-label">筛选:</span>
<span class="tag active" onclick="filterByTag('all')">全部</span>
</section>
<main class="container">
<div class="stats-bar">
<div class="stat-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
<span>共 <span class="stat-value" id="totalCount">0</span> 个账号</span>
</div>
<div class="stat-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
<span>更新于 <span class="stat-value" id="lastUpdate">-</span></span>
</div>
<button class="sort-icon-btn" id="sortBtn" onclick="toggleSortOrder()" title="切换排序">
<svg id="sortIcon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="12 6 12 18"></polyline>
<polyline points="8 10 12 6 16 10"></polyline>
</svg>
<span class="sort-text" id="sortText">最新</span>
</button>
</div>
<div id="contentArea">
<div class="empty-state" id="emptyState">
<div class="empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="11" width="18" height="11" rx="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<div class="empty-title">暂无账号</div>
<div class="empty-desc">点击右上角"添加账号"开始使用,或导入已有数据</div>
</div>
<div class="accounts-grid" id="accountsGrid" style="display: none;"></div>
</div>
</main>
<!-- Add/Edit Modal - No overlay click close -->
<div class="modal-overlay" id="modal">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title" id="modalTitle">添加账号</h2>
<p class="modal-subtitle">数据仅保存在本地浏览器中</p>
</div>
<form class="modal-body" id="accountForm" onsubmit="saveAccount(event)">
<input type="hidden" id="editId">
<div class="form-group">
<label class="form-label">平台名称 *</label>
<input type="text" class="form-input" id="platform" placeholder="例如:微信、GitHub、支付宝" required>
</div>
<div class="form-group">
<label class="form-label">用户名 *</label>
<input type="text" class="form-input" id="username" placeholder="邮箱/手机号/用户名" required>
</div>
<div class="form-group">
<label class="form-label">密码 *</label>
<div class="password-field-wrapper">
<div class="password-input-wrapper">
<input type="password" class="form-input" id="password" placeholder="密码" required style="padding-right: 40px;">
</div>
<div class="password-actions">
<!-- Generate password button -->
<button type="button" class="icon-action-btn" onclick="generatePassword()" title="生成随机密码">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 9.9-1"></path>
<circle cx="12" cy="16" r="1" fill="currentColor"></circle>
<line x1="8" y1="16" x2="8" y2="16"></line>
<line x1="16" y1="16" x2="16" y2="16"></line>
</svg>
</button>
<!-- Settings button -->
<button type="button" class="icon-action-btn" onclick="toggleGeneratorSettings()" title="密码生成器设置">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M12 1v6m0 6v6m4.22-10.22l4.24-4.24M6.34 6.34L2.1 2.1m17.8 17.8l-4.24-4.24M6.34 17.66l-4.24 4.24M23 12h-6m-6 0H1m20.07-4.93l-4.24 4.24M6.34 6.34l-4.24-4.24"></path>
</svg>
</button>
<!-- Toggle visibility -->
<button type="button" class="icon-action-btn" onclick="togglePasswordInput()" title="显示/隐藏密码">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
</div>
</div>
<!-- Generator Settings Panel -->
<div class="generator-settings" id="generatorSettings" style="display: none;">
<div class="generator-settings-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M12 1v6m0 6v6m4.22-10.22l4.24-4.24M6.34 6.34L2.1 2.1m17.8 17.8l-4.24-4.24M6.34 17.66l-4.24 4.24M23 12h-6m-6 0H1m20.07-4.93l-4.24 4.24M6.34 6.34l-4.24-4.24"></path>
</svg>
密码生成器设置
</div>
<div class="setting-row">
<span class="setting-label">密码长度</span>
<div class="setting-control">
<input type="number" class="length-input" id="pwdLength" value="16" min="4" max="64">
</div>
</div>
<div class="setting-row">
<span class="setting-label">包含大写字母 (A-Z)</span>
<label class="checkbox-wrapper">
<input type="checkbox" id="pwdUppercase" checked>
<span>启用</span>
</label>
</div>
<div class="setting-row">
<span class="setting-label">包含小写字母 (a-z)</span>
<label class="checkbox-wrapper">
<input type="checkbox" id="pwdLowercase" checked>
<span>启用</span>
</label>
</div>
<div class="setting-row">
<span class="setting-label">包含数字 (0-9)</span>
<label class="checkbox-wrapper">
<input type="checkbox" id="pwdNumbers" checked>
<span>启用</span>
</label>
</div>
<div class="setting-row">
<span class="setting-label">包含符号 (!@#$...)</span>
<label class="checkbox-wrapper">
<input type="checkbox" id="pwdSymbols">
<span>启用</span>
</label>
</div>
</div>
</div>
<div class="form-group">
<label class="form-label">网站链接</label>
<input type="text" class="form-input" id="url" placeholder="https://...">
<div class="input-hint">可选,方便快速访问网站,自动补全 http://</div>
</div>
<div class="form-group">
<label class="form-label">标签</label>
<input type="text" class="tags-simple-input" id="tagsInput" placeholder="工作|学习|生活">
<div class="input-hint">使用竖杆 | 分隔多个标签</div>
</div>
<div class="form-group">
<label class="form-label">备注</label>
<textarea class="form-input" id="note" placeholder="记录其他信息,如安全问题、绑定手机、备用邮箱等..."></textarea>
</div>
</form>
<div class="modal-footer">
<button type="button" class="btn-text danger" onclick="confirmDeleteCurrent()" id="deleteEditBtn" style="display: none; margin-right: auto;">删除</button>
<button type="button" class="btn-text" onclick="closeModal()">取消</button>
<button type="button" class="btn btn-primary" onclick="document.getElementById('accountForm').dispatchEvent(new Event('submit'))">保存</button>
</div>
</div>
</div>
<!-- View Modal -->
<div class="modal-overlay" id="viewModal" onclick="closeViewModalOnOverlay(event)">
<div class="modal view-modal">
<div class="modal-header">
<h2 class="modal-title" id="viewModalTitle">查看账号</h2>
<p class="modal-subtitle" id="viewModalSubtitle">查看账号详情</p>
</div>
<div class="modal-body" id="viewModalBody">
<!-- Dynamic content -->
</div>
<div class="modal-footer">
<button type="button" class="btn-text danger" onclick="confirmDeleteFromView()" style="margin-right: auto;">删除</button>
<button type="button" class="btn-text" onclick="closeViewModal()">关闭</button>
<button type="button" class="btn btn-primary" id="viewEditBtn">编辑</button>
</div>
</div>
</div>
<!-- Export Modal -->
<div class="modal-overlay" id="exportModal" onclick="closeExportModalOnOverlay(event)">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">导出数据</h2>
<p class="modal-subtitle">选择导出方式</p>
</div>
<div class="modal-body">
<div class="export-options">
<div class="export-option selected" onclick="selectExportType('plain')" id="exportPlain">
<div class="export-option-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
</svg>
</div>
<div class="export-option-title">普通导出</div>
<div class="export-option-desc">明文 JSON 格式</div>
</div>
<div class="export-option" onclick="selectExportType('encrypted')" id="exportEncrypted">
<div class="export-option-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<div class="export-option-title">加密导出</div>
<div class="export-option-desc">AES-256 加密</div>
</div>
</div>
<div id="exportPasswordSection" style="display: none;">
<div class="form-group">
<label class="form-label">设置导出密码 *</label>
<input type="password" class="form-input" id="exportPassword" placeholder="输入密码用于加密数据">
<div class="input-hint">请牢记此密码,导入时需要使用</div>
</div>
<div class="form-group">
<label class="form-label">确认密码 *</label>
<input type="password" class="form-input" id="exportPasswordConfirm" placeholder="再次输入密码">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-text" onclick="closeExportModal()">取消</button>
<button type="button" class="btn btn-primary" onclick="executeExport()">导出</button>
</div>
</div>
</div>
<!-- Import Password Modal -->
<div class="modal-overlay" id="importPasswordModal">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">输入导入密码</h2>
<p class="modal-subtitle">此数据文件已加密,需要密码才能解密</p>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">解密密码 *</label>
<input type="password" class="form-input" id="importPassword" placeholder="输入导出时设置的密码">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-text" onclick="cancelImport()">取消</button>
<button type="button" class="btn btn-primary" onclick="executeImportWithPassword()">导入</button>
</div>
</div>
</div>
<!-- Duplicate Confirm Modal -->
<div class="modal-overlay" id="duplicateModal">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">发现重复账号</h2>
<p class="modal-subtitle">该账号信息已存在</p>
</div>
<div class="modal-body">
<div class="confirm-desc">以下账号的所有信息与您要添加的账号完全相同:</div>
<div class="duplicate-info" id="duplicateInfo">
<!-- Dynamic content -->
</div>
<div class="confirm-desc" style="margin-top: 16px; color: var(--warning);">
<strong>提示:</strong>选择"覆盖"将用新信息替换原有账号(保留原ID和创建时间)。
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-text" onclick="cancelDuplicate()">取消添加</button>
<button type="button" class="btn btn-danger" onclick="forceAddDuplicate()">强制添加(覆盖)</button>
</div>
</div>
</div>
<!-- K520A6 -->
<!-- Confirm Clear Modal with Double Confirm -->
<div class="modal-overlay" id="confirmClearModal" onclick="closeConfirmClearModalOnOverlay(event)">
<div class="modal confirm-modal">
<div class="modal-body">
<div class="confirm-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</div>
<div class="confirm-title">确认清空所有数据?</div>
<div class="confirm-desc">此操作不可恢复,所有账号数据将被永久删除。<br>建议先导出数据备份。</div>
<div class="confirm-input-group">
<label class="confirm-input-label">请输入 <strong>DELETE</strong> 确认清空:</label>
<input type="text" class="confirm-input" id="clearConfirmInput" placeholder="输入 DELETE">
</div>
</div>
<div class="modal-footer" style="justify-content: center;">
<button type="button" class="btn-text" onclick="closeConfirmClearModal()">取消</button>
<button type="button" class="btn btn-danger" onclick="executeClearAll()" id="confirmClearBtn" disabled>确认清空</button>
</div>
</div>
</div>
<!-- aszv_top -->
<!-- Simple Confirm Modal for single delete -->
<div class="modal-overlay" id="confirmModal" onclick="closeConfirmModalOnOverlay(event)">
<div class="modal confirm-modal">
<div class="modal-body">
<div class="confirm-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</div>
<div class="confirm-title" id="simpleConfirmTitle">确认删除?</div>
<div class="confirm-desc" id="simpleConfirmDesc">此操作不可恢复。</div>
</div>
<div class="modal-footer" style="justify-content: center;">
<button type="button" class="btn-text" onclick="closeConfirmModal()">取消</button>
<button type="button" class="btn btn-danger" onclick="executeSimpleConfirm()">确认删除</button>
</div>
</div>
</div>
<div class="toast" id="toast">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
<span id="toastMsg">操作成功</span>
</div>
<script>
// Data
let accounts = JSON.parse(localStorage.getItem('pwd_accounts') || '[]');
let currentFilter = 'all';
let editingId = null;
let viewingId = null;
let simpleConfirmCallback = null;
let duplicateCallback = null;
let pendingExportType = 'plain';
let pendingImportData = null;
let pendingImportFile = null;
// Password Generator Settings
const defaultPwdSettings = {
length: 16,
uppercase: true,
lowercase: true,
numbers: true,
symbols: false
};
// Check if mobile
function isMobile() {
return window.innerWidth <= 768;
}
// Parse tags from pipe-separated string
function parseTags(tagsStr) {
if (!tagsStr) return [];
return tagsStr.split('|').map(t => t.trim()).filter(t => t.length > 0);
}
// Format tags to pipe-separated string
function formatTags(tags) {
if (!tags || tags.length === 0) return '';
return tags.join(' | ');
}
// Auto-fix URL
function fixUrl(url) {
if (!url) return '';
url = url.trim();
if (!url) return '';
if (!/^https?:\/\//i.test(url)) {
url = 'http://' + url;
}
return url;
}
// Check if account is exactly the same
function isAccountEqual(acc1, acc2) {
const normalizeTags = (tags) => {
if (!tags) return '';
return [...tags].sort().join('|');
};
return acc1.platform === acc2.platform &&
acc1.username === acc2.username &&
acc1.password === acc2.password &&
(acc1.url || '') === (acc2.url || '') &&
normalizeTags(acc1.tags) === normalizeTags(acc2.tags) &&
(acc1.note || '') === (acc2.note || '');
}
// Check for duplicate account
function findDuplicate(newAcc, excludeId = null) {
return accounts.find(acc => {
if (excludeId && acc.id === excludeId) return false;
return isAccountEqual(acc, newAcc);
});
}
// Simple XOR encryption for demo (in production use Web Crypto API)
async function encryptData(data, password) {
const encoder = new TextEncoder();
const dataBytes = encoder.encode(JSON.stringify(data));
const keyBytes = encoder.encode(password);
const encrypted = new Uint8Array(dataBytes.length);
for (let i = 0; i < dataBytes.length; i++) {
encrypted[i] = dataBytes[i] ^ keyBytes[i % keyBytes.length];
}
// Convert to base64
const base64 = btoa(String.fromCharCode(...encrypted));
return {
encrypted: true,
data: base64,
salt: Date.now().toString()
};
}
async function decryptData(encryptedObj, password) {
try {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const keyBytes = encoder.encode(password);
// Decode base64
const encryptedBytes = Uint8Array.from(atob(encryptedObj.data), c => c.charCodeAt(0));
const decrypted = new Uint8Array(encryptedBytes.length);
for (let i = 0; i < encryptedBytes.length; i++) {
decrypted[i] = encryptedBytes[i] ^ keyBytes[i % keyBytes.length];
}
const jsonStr = decoder.decode(decrypted);
return JSON.parse(jsonStr);
} catch (e) {
throw new Error('密码错误或数据损坏');
}
}
// Theme
function initTheme() {
const saved = localStorage.getItem('pwd_theme') || 'light';
document.documentElement.setAttribute('data-theme', saved);
updateThemeIcon(saved);
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('pwd_theme', next);
updateThemeIcon(next);
}
function updateThemeIcon(theme) {
const icon = document.getElementById('themeIcon');
if (theme === 'dark') {
icon.innerHTML = '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>';
} else {
icon.innerHTML = '<circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>';
}
}
// Tags
function getAllTags() {
const tags = new Set();
accounts.forEach(a => a.tags?.forEach(t => tags.add(t)));
return Array.from(tags).sort();
}
function renderTags() {
const container = document.getElementById('tagsContainer');
const allTags = getAllTags();
let html = '<span class="tag-label">筛选:</span>';
html += `<span class="tag ${currentFilter === 'all' ? 'active' : ''}" onclick="filterByTag('all')">全部</span>`;
allTags.forEach(tag => {
html += `<span class="tag ${currentFilter === tag ? 'active' : ''}" onclick="filterByTag('${tag}')">${escapeHtml(tag)}</span>`;
});
// Add mobile button
html += `<button class="mobile-add-btn mobile-only" onclick="openModal()" title="添加账号">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>`;
container.innerHTML = html;
}
function filterByTag(tag) {
currentFilter = tag;
renderTags();
renderAccounts();
}
// Password Generator
function toggleGeneratorSettings() {
const settings = document.getElementById('generatorSettings');
settings.style.display = settings.style.display === 'none' ? 'block' : 'none';
}
function generatePassword() {
const length = parseInt(document.getElementById('pwdLength').value) || 16;
const useUpper = document.getElementById('pwdUppercase').checked;
const useLower = document.getElementById('pwdLowercase').checked;
const useNumbers = document.getElementById('pwdNumbers').checked;
const useSymbols = document.getElementById('pwdSymbols').checked;
let chars = '';
if (useUpper) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
if (useLower) chars += 'abcdefghijklmnopqrstuvwxyz';
if (useNumbers) chars += '0123456789';
if (useSymbols) chars += '!@#$%^&*()_+-=[]{}|;:,.<>?';
if (chars === '') {
showToast('请至少选择一种字符类型');
return;
}
let password = '';
const array = new Uint32Array(length);
crypto.getRandomValues(array);
for (let i = 0; i < length; i++) {
password += chars[array[i] % chars.length];
}
document.getElementById('password').value = password;
document.getElementById('password').type = 'text';
showToast('密码已生成');
}
// Modal & Form - No overlay click close
function openModal(id = null) {
editingId = id;
const modal = document.getElementById('modal');
const title = document.getElementById('modalTitle');
const form = document.getElementById('accountForm');
const deleteBtn = document.getElementById('deleteEditBtn');
// Reset generator settings visibility
document.getElementById('generatorSettings').style.display = 'none';
if (id) {
const acc = accounts.find(a => a.id === id);
title.textContent = '编辑账号';
document.getElementById('editId').value = id;
document.getElementById('platform').value = acc.platform;
document.getElementById('username').value = acc.username;
document.getElementById('password').value = acc.password;
document.getElementById('url').value = acc.url || '';
document.getElementById('tagsInput').value = formatTags(acc.tags);
document.getElementById('note').value = acc.note || '';
deleteBtn.style.display = 'block';
} else {
title.textContent = '添加账号';
form.reset();
document.getElementById('editId').value = '';
deleteBtn.style.display = 'none';
// Set default password settings
document.getElementById('pwdLength').value = defaultPwdSettings.length;
document.getElementById('pwdUppercase').checked = defaultPwdSettings.uppercase;
document.getElementById('pwdLowercase').checked = defaultPwdSettings.lowercase;
document.getElementById('pwdNumbers').checked = defaultPwdSettings.numbers;
document.getElementById('pwdSymbols').checked = defaultPwdSettings.symbols;
}
modal.classList.add('active');
document.getElementById('platform').focus();
}
function closeModal() {
document.getElementById('modal').classList.remove('active');
editingId = null;
}
// Removed: closeModalOnOverlay - now only closes via button
function togglePasswordInput() {
const input = document.getElementById('password');
const btn = event.target.closest('.icon-action-btn');
if (input.type === 'password') {
input.type = 'text';
} else {
input.type = 'password';
}
}
function confirmDeleteCurrent() {
if (editingId) {
const acc = accounts.find(a => a.id === editingId);
showSimpleConfirm(
`确认删除 ${acc.platform}?`,
'此操作不可恢复,账号将被永久删除。',
() => {
deleteAccount(editingId);
closeModal();
}
);
}
}
// View Modal
function openViewModal(id) {
viewingId = id;
const acc = accounts.find(a => a.id === id);
if (!acc) return;
document.getElementById('viewModalTitle').textContent = acc.platform;
document.getElementById('viewModalSubtitle').textContent = '查看账号详情';
let html = '';
// Username
html += `
<div class="view-field">
<div class="view-label">用户名</div>
<div class="view-value-box">
<span class="view-value-text">${escapeHtml(acc.username)}</span>
<div class="view-actions">
<button class="view-btn" onclick="copyFromView('${escapeHtml(acc.username)}', this)">复制</button>
</div>
</div>
</div>
`;
// Password
html += `
<div class="view-field">
<div class="view-label">密码</div>
<div class="view-value-box">
<span class="view-value-text">
<span id="viewPwdText" class="password-text" style="filter: blur(4px); user-select: none;">${escapeHtml(acc.password)}</span>
</span>
<div class="view-actions">
<button class="view-btn" onclick="toggleViewPassword()" id="viewPwdBtn">显示</button>
<button class="view-btn" onclick="copyFromView('${escapeHtml(acc.password)}', this)">复制</button>
</div>
</div>
</div>
`;
// Tags - container style
if (acc.tags && acc.tags.length > 0) {
html += `
<div class="view-field">
<div class="view-label">标签</div>
<div class="view-tags-container">
${acc.tags.map(t => `<span class="view-tag-item">${escapeHtml(t)}</span>`).join('')}
</div>
</div>
`;
}
// URL
if (acc.url) {
html += `
<div class="view-field">
<div class="view-label">网站链接</div>
<a href="${escapeHtml(acc.url)}" target="_blank" class="view-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
${escapeHtml(acc.url)}
</a>
</div>
`;
}
// Note - resizable like edit mode
if (acc.note) {
html += `
<div class="view-field">
<div class="view-label">备注(可拖动调整大小)</div>
<textarea class="view-note-resizable" readonly>${escapeHtml(acc.note)}</textarea>
</div>
`;
}
document.getElementById('viewModalBody').innerHTML = html;
// Set edit button action
document.getElementById('viewEditBtn').onclick = () => {
closeViewModal();
setTimeout(() => openModal(id), 100);
};
document.getElementById('viewModal').classList.add('active');
}
function closeViewModal() {
document.getElementById('viewModal').classList.remove('active');
viewingId = null;
}
function closeViewModalOnOverlay(e) {
if (e.target === e.currentTarget) closeViewModal();
}
function toggleViewPassword() {
const pwd = document.getElementById('viewPwdText');
const btn = document.getElementById('viewPwdBtn');
if (pwd.style.filter === 'blur(4px)') {
pwd.style.filter = 'none';
pwd.style.userSelect = 'text';
btn.textContent = '隐藏';
} else {
pwd.style.filter = 'blur(4px)';
pwd.style.userSelect = 'none';
btn.textContent = '显示';
}
}
function confirmDeleteFromView() {
if (viewingId) {
const acc = accounts.find(a => a.id === viewingId);
showSimpleConfirm(
`确认删除 ${acc.platform}?`,
'此操作不可恢复,账号将被永久删除。',
() => {
deleteAccount(viewingId);
closeViewModal();
}
);
}
}
function copyFromView(text, btn) {
navigator.clipboard.writeText(text).then(() => {
btn.textContent = '已复制';
btn.classList.add('copied');
showToast('已复制到剪贴板');
setTimeout(() => {
btn.textContent = '复制';
btn.classList.remove('copied');
}, 2000);
}).catch(() => {
// Fallback for mobile
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
btn.textContent = '已复制';
btn.classList.add('copied');
showToast('已复制到剪贴板');
setTimeout(() => {
btn.textContent = '复制';
btn.classList.remove('copied');
}, 2000);
} catch (err) {
showToast('复制失败');
}
document.body.removeChild(textArea);
});
}
// Save with duplicate check
function saveAccount(e) {
e.preventDefault();
const platform = document.getElementById('platform').value.trim();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const url = fixUrl(document.getElementById('url').value.trim());
const tags = parseTags(document.getElementById('tagsInput').value);
const note = document.getElementById('note').value.trim();
if (!platform || !username || !password) return;
const newAcc = {
platform,
username,
password,
url,
tags,
note
};
// Check for duplicates when adding new account
if (!editingId) {
const duplicate = findDuplicate(newAcc);
if (duplicate) {
showDuplicateModal(newAcc, duplicate);
return;
}
}
// Save directly if editing or no duplicate
doSaveAccount(newAcc);
}
function doSaveAccount(newAcc, replaceId = null) {
const data = {
id: replaceId || editingId || Date.now().toString(),
...newAcc,
updatedAt: new Date().toISOString()
};
if (editingId || replaceId) {
const idx = accounts.findIndex(a => a.id === (replaceId || editingId));
if (idx >= 0) {
// Preserve original created time if replacing
if (replaceId && !editingId) {
data.updatedAt = accounts[idx].updatedAt;
}
accounts[idx] = data;
}
showToast(replaceId && !editingId ? '账号已覆盖' : '账号已更新');
} else {
accounts.push(data);
showToast('账号已添加');
}
saveData();
closeModal();
renderAccounts();
renderTags();
}
function showDuplicateModal(newAcc, duplicate) {
const infoHtml = `
<div class="duplicate-info-item">
<span class="duplicate-info-label">平台</span>
<span class="duplicate-info-value">${escapeHtml(duplicate.platform)}</span>
</div>
<div class="duplicate-info-item">
<span class="duplicate-info-label">用户名</span>
<span class="duplicate-info-value">${escapeHtml(duplicate.username)}</span>
</div>
<div class="duplicate-info-item">
<span class="duplicate-info-label">密码</span>
<span class="duplicate-info-value">••••••</span>
</div>
${duplicate.url ? `
<div class="duplicate-info-item">
<span class="duplicate-info-label">链接</span>
<span class="duplicate-info-value">${escapeHtml(duplicate.url)}</span>
</div>
` : ''}
${duplicate.tags?.length ? `
<div class="duplicate-info-item">
<span class="duplicate-info-label">标签</span>
<span class="duplicate-info-value">${formatTags(duplicate.tags)}</span>
</div>
` : ''}
${duplicate.note ? `
<div class="duplicate-info-item">
<span class="duplicate-info-label">备注</span>
<span class="duplicate-info-value">${escapeHtml(duplicate.note)}</span>
</div>
` : ''}
`;
document.getElementById('duplicateInfo').innerHTML = infoHtml;
document.getElementById('duplicateModal').classList.add('active');
// Store pending data
duplicateCallback = { newAcc, replaceId: duplicate.id };
}
function cancelDuplicate() {
document.getElementById('duplicateModal').classList.remove('active');
duplicateCallback = null;
}
function forceAddDuplicate() {
if (duplicateCallback) {
const { newAcc, replaceId } = duplicateCallback;
doSaveAccount(newAcc, replaceId);
document.getElementById('duplicateModal').classList.remove('active');
duplicateCallback = null;
}
}
function deleteAccount(id) {
accounts = accounts.filter(a => a.id !== id);
saveData();
renderAccounts();
renderTags();
showToast('账号已删除');
}
// Export Functions
function showExportModal() {
if (!accounts.length) {
showToast('暂无数据可导出');
return;
}
document.getElementById('exportModal').classList.add('active');
selectExportType('plain');
}
function closeExportModal() {
document.getElementById('exportModal').classList.remove('active');
}
function closeExportModalOnOverlay(e) {
if (e.target === e.currentTarget) closeExportModal();
}
function selectExportType(type) {
pendingExportType = type;
document.getElementById('exportPlain').classList.toggle('selected', type === 'plain');
document.getElementById('exportEncrypted').classList.toggle('selected', type === 'encrypted');
const pwdSection = document.getElementById('exportPasswordSection');
pwdSection.style.display = type === 'encrypted' ? 'block' : 'none';
if (type === 'encrypted') {
setTimeout(() => document.getElementById('exportPassword').focus(), 100);
}
}
async function executeExport() {
const type = pendingExportType;
if (type === 'encrypted') {
const pwd = document.getElementById('exportPassword').value;
const pwdConfirm = document.getElementById('exportPasswordConfirm').value;
if (!pwd) {
showToast('请输入导出密码');
return;
}
if (pwd !== pwdConfirm) {
showToast('两次输入的密码不一致');
return;
}
if (pwd.length < 6) {
showToast('密码长度至少6位');
return;
}
try {
const encrypted = await encryptData(accounts, pwd);
downloadFile(JSON.stringify(encrypted, null, 2), `accounts_encrypted_${new Date().toISOString().split('T')[0]}.json`);
showToast('加密数据已导出');
closeExportModal();
} catch (e) {
showToast('加密失败:' + e.message);
}
} else {
const data = JSON.stringify(accounts, null, 2);
downloadFile(data, `accounts_${new Date().toISOString().split('T')[0]}.json`);
showToast('数据已导出');
closeExportModal();
}
}
function downloadFile(content, filename) {
const blob = new Blob([content], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
// Import Functions
async function importData(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
try {
const content = e.target.result;
let data;
try {
data = JSON.parse(content);
} catch (err) {
throw new Error('文件格式错误,不是有效的 JSON');
}
// Check if encrypted
if (data.encrypted && data.data) {
pendingImportData = data;
pendingImportFile = file;
document.getElementById('importPasswordModal').classList.add('active');
document.getElementById('importPassword').value = '';
setTimeout(() => document.getElementById('importPassword').focus(), 100);
return;
}
// Plain import
if (!Array.isArray(data)) {
throw new Error('数据格式错误,应为账号数组');
}
processImportData(data);
} catch (err) {
alert('导入失败:' + err.message);
}
input.value = '';
};
reader.readAsText(file);
}
async function executeImportWithPassword() {
const pwd = document.getElementById('importPassword').value;
if (!pwd) {
showToast('请输入解密密码');
return;
}
try {
const decrypted = await decryptData(pendingImportData, pwd);
if (!Array.isArray(decrypted)) {
throw new Error('解密后数据格式错误');
}
processImportData(decrypted);
document.getElementById('importPasswordModal').classList.remove('active');
pendingImportData = null;
pendingImportFile = null;
} catch (err) {
showToast('解密失败:' + err.message);
}
}
function cancelImport() {
document.getElementById('importPasswordModal').classList.remove('active');
pendingImportData = null;
pendingImportFile = null;
document.getElementById('importFile').value = '';
}
function processImportData(data) {
// Fix URLs in imported data
data.forEach(item => {
if (item.url) {
item.url = fixUrl(item.url);
}
});
let added = 0;
let skipped = 0;
let updated = 0;
const skippedItems = [];
data.forEach(item => {
if (!item.id || !item.platform) return;
// Check for exact duplicate
const existing = accounts.find(a => a.id === item.id);
if (existing) {
// Check if exactly the same
if (isAccountEqual(existing, item)) {
skipped++;
skippedItems.push(item.platform);
return;
}
// Different - update if newer
if (new Date(item.updatedAt) > new Date(existing.updatedAt)) {
accounts[accounts.indexOf(existing)] = item;
updated++;
}
} else {
// Check if new item is duplicate of existing (by content)
const contentDuplicate = accounts.find(a => isAccountEqual(a, item));
if (contentDuplicate) {
skipped++;
skippedItems.push(item.platform);
return;
}
accounts.push(item);
added++;
}
});
saveData();
renderAccounts();
renderTags();
let msg = `导入完成:新增 ${added} 条`;
if (updated > 0) msg += `,更新 ${updated} 条`;
if (skipped > 0) msg += `,跳过 ${skipped} 条重复`;
showToast(msg);
if (skippedItems.length > 0) {
setTimeout(() => {
alert(`以下账号因信息完全相同已跳过:\n${skippedItems.join('\n')}`);
}, 300);
}
}
// Clear All with Double Confirm
function confirmClearAll() {
if (accounts.length === 0) {
showToast('暂无数据可清空');
return;
}
document.getElementById('confirmClearModal').classList.add('active');
document.getElementById('clearConfirmInput').value = '';
document.getElementById('clearConfirmInput').classList.remove('error');
document.getElementById('confirmClearBtn').disabled = true;
document.getElementById('clearConfirmInput').focus();
}
function closeConfirmClearModal() {
document.getElementById('confirmClearModal').classList.remove('active');
}
function closeConfirmClearModalOnOverlay(e) {
if (e.target === e.currentTarget) closeConfirmClearModal();
}
// Real-time validation for clear confirm
document.getElementById('clearConfirmInput')?.addEventListener('input', function(e) {
const input = e.target;
const btn = document.getElementById('confirmClearBtn');
if (input.value === 'DELETE') {
btn.disabled = false;
input.classList.remove('error');
} else {
btn.disabled = true;
if (input.value.length >= 6) {
input.classList.add('error');
} else {
input.classList.remove('error');
}
}
});
function executeClearAll() {
const input = document.getElementById('clearConfirmInput');
if (input.value !== 'DELETE') {
input.classList.add('error');
return;
}
accounts = [];
saveData();
renderAccounts();
renderTags();
closeConfirmClearModal();
showToast('所有数据已清空');
}
// Simple Confirm Modal
function showSimpleConfirm(title, desc, callback) {
simpleConfirmCallback = callback;
document.getElementById('simpleConfirmTitle').textContent = title;
document.getElementById('simpleConfirmDesc').textContent = desc;
document.getElementById('confirmModal').classList.add('active');
}
function closeConfirmModal() {
document.getElementById('confirmModal').classList.remove('active');
simpleConfirmCallback = null;
}
function closeConfirmModalOnOverlay(e) {
if (e.target === e.currentTarget) closeConfirmModal();
}
function executeSimpleConfirm() {
if (simpleConfirmCallback) {
simpleConfirmCallback();
}
closeConfirmModal();
}
// Render
function renderAccounts() {
const search = document.getElementById('searchInput').value.toLowerCase();
const grid = document.getElementById('accountsGrid');
const empty = document.getElementById('emptyState');
let filtered = accounts.filter(a => {
const matchSearch = !search ||
a.platform.toLowerCase().includes(search) ||
a.username.toLowerCase().includes(search) ||
a.tags?.some(t => t.toLowerCase().includes(search)) ||
(a.url && a.url.toLowerCase().includes(search)) ||
a.note?.toLowerCase().includes(search);
const matchTag = currentFilter === 'all' || a.tags?.includes(currentFilter);
return matchSearch && matchTag;
});
// Sort accounts by update time
filtered.sort((a, b) => {
const timeA = new Date(a.updatedAt).getTime();
const timeB = new Date(b.updatedAt).getTime();
if (window.sortOrder === 'oldest') {
return timeA - timeB; // Oldest first
}
return timeB - timeA; // Newest first (default)
});
document.getElementById('totalCount').textContent = filtered.length;
if (accounts.length > 0) {
const last = accounts.reduce((a, b) =>
new Date(a.updatedAt) > new Date(b.updatedAt) ? a : b
);
document.getElementById('lastUpdate').textContent = new Date(last.updatedAt).toLocaleDateString('zh-CN');
} else {
document.getElementById('lastUpdate').textContent = '-';
}
if (filtered.length === 0) {
grid.style.display = 'none';
empty.style.display = 'block';
empty.querySelector('.empty-title').textContent = search || currentFilter !== 'all' ? '无匹配结果' : '暂无账号';
empty.querySelector('.empty-desc').textContent = search || currentFilter !== 'all' ? '尝试其他关键词或筛选条件' : '点击右上角"添加账号"开始使用';
return;
}
grid.style.display = 'grid';
empty.style.display = 'none';
grid.innerHTML = filtered.map(acc => `
<div class="account-card" onclick="handleCardClick(event, '${acc.id}')">
<div class="card-header">
<div class="platform-info">
<div class="platform-icon">${acc.platform.charAt(0).toUpperCase()}</div>
<div class="platform-meta">
<div class="platform-name">${escapeHtml(acc.platform)}</div>
${acc.url ? `<a href="${escapeHtml(acc.url)}" target="_blank" class="platform-url" title="访问网站" onclick="event.stopPropagation()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
${escapeHtml(acc.url.replace(/^https?:\/\//, '').replace(/\/$/, ''))}
</a>` : ''}
${acc.tags?.length ? `<div class="card-tags">
${acc.tags.map(t => `<span class="card-tag">${escapeHtml(t)}</span>`).join('')}
</div>` : ''}
</div>
</div>
<div class="card-actions" onclick="event.stopPropagation()">
<button class="card-btn" onclick="openModal('${acc.id}')" title="编辑">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button class="card-btn delete" onclick="confirmDeleteAccount('${acc.id}', '${escapeHtml(acc.platform)}'); event.stopPropagation();" title="删除">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
</div>
<div class="fields">
<div class="field">
<span class="field-label">用户名</span>
<div class="field-value">
<span class="field-text">${escapeHtml(acc.username)}</span>
<div class="field-actions">
<button class="field-btn" onclick="copyText('${escapeHtml(acc.username)}', this); event.stopPropagation();">复制</button>
</div>
</div>
</div>
<div class="field">
<span class="field-label">密码</span>
<div class="field-value">
<span class="field-text password-text" id="pwd-${acc.id}">${escapeHtml(acc.password)}</span>
<div class="field-actions">
<button class="field-btn" onclick="togglePwd('${acc.id}'); event.stopPropagation();">显示</button>
<button class="field-btn" onclick="copyText('${escapeHtml(acc.password)}', this); event.stopPropagation();">复制</button>
</div>
</div>
</div>
${acc.note ? `
<div class="field">
<span class="field-label">备注</span>
<div class="note-text">${escapeHtml(acc.note)}</div>
</div>
` : ''}
</div>
<div class="card-footer">
<div class="update-time">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
<span>${formatRelativeTime(acc.updatedAt)}</span>
</div>
</div>
</div>
`).join('');
}
function handleCardClick(event, id) {
// Don't open view if clicking on buttons or links
if (event.target.closest('.card-actions') || event.target.closest('.field-actions') || event.target.closest('a')) {
return;
}
openViewModal(id);
}
function togglePwd(id) {
const el = document.getElementById(`pwd-${id}`);
const btn = event.target;
if (el.classList.contains('revealed')) {
el.classList.remove('revealed');
btn.textContent = '显示';
} else {
el.classList.add('revealed');
btn.textContent = '隐藏';
}
}
// Utils
function copyText(text, btn) {
// Try modern clipboard API first
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
showCopied(btn);
}).catch(() => {
fallbackCopy(text, btn);
});
} else {
fallbackCopy(text, btn);
}
}
function fallbackCopy(text, btn) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '0';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
showCopied(btn);
} else {
showToast('复制失败');
}
} catch (err) {
showToast('复制失败');
}
document.body.removeChild(textArea);
}
function showCopied(btn) {
btn.textContent = '已复制';
btn.classList.add('copied');
showToast('已复制到剪贴板');
setTimeout(() => {
btn.textContent = '复制';
btn.classList.remove('copied');
}, 2000);
}
function showToast(msg) {
const toast = document.getElementById('toast');
document.getElementById('toastMsg').textContent = msg;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 3000);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Format relative time in Chinese
function formatRelativeTime(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
const diffMonth = Math.floor(diffDay / 30);
const diffYear = Math.floor(diffDay / 365);
if (diffSec < 60) {
return '刚刚';
} else if (diffMin < 60) {
return `${diffMin}分钟前`;
} else if (diffHour < 24) {
return `${diffHour}小时前`;
} else if (diffDay === 1) {
return '昨天';
} else if (diffDay < 30) {
return `${diffDay}天前`;
} else if (diffMonth < 12) {
return `${diffMonth}个月前`;
} else if (diffYear === 1) {
return '1年前';
} else {
return `${diffYear}年前`;
}
}
// Confirm delete with modal
function confirmDeleteAccount(id, platform) {
showSimpleConfirm(
`确认删除 ${platform}?`,
'此操作不可恢复,账号将被永久删除。',
() => {
deleteAccount(id);
}
);
}
function saveData() {
localStorage.setItem('pwd_accounts', JSON.stringify(accounts));
}
// Sort order - default newest first
window.sortOrder = 'newest';
// Toggle sort order
function toggleSortOrder() {
window.sortOrder = window.sortOrder === 'newest' ? 'oldest' : 'newest';
updateSortIcon();
renderAccounts();
showToast(window.sortOrder === 'newest' ? '已切换:最新在前' : '已切换:最早在前');
}
// Update sort icon based on current order
function updateSortIcon() {
const icon = document.getElementById('sortIcon');
const btn = document.getElementById('sortBtn');
const text = document.getElementById('sortText');
if (window.sortOrder === 'newest') {
// Newest first icon (arrow up - descending)
icon.innerHTML = `
<polyline points="12 6 12 18" style="stroke-width: 2.5"></polyline>
<polyline points="8 10 12 6 16 10" style="stroke-width: 2.5"></polyline>
`;
btn.title = "当前:最新在前,点击切换";
btn.classList.add('active');
if (text) text.textContent = '最新';
} else {
// Oldest first icon (arrow down - ascending)
icon.innerHTML = `
<polyline points="12 6 12 18" style="stroke-width: 2.5"></polyline>
<polyline points="8 14 12 18 16 14" style="stroke-width: 2.5"></polyline>
`;
btn.title = "当前:最早在前,点击切换";
btn.classList.remove('active');
if (text) text.textContent = '最早';
}
}
// Init
document.addEventListener('DOMContentLoaded', () => {
initTheme();
updateSortIcon();
renderAccounts();
renderTags();
});
// Handle resize
let lastWidth = window.innerWidth;
window.addEventListener('resize', () => {
const currentWidth = window.innerWidth;
const wasMobile = lastWidth <= 768;
const isMobileNow = currentWidth <= 768;
if (wasMobile !== isMobileNow) {
renderAccounts();
}
lastWidth = currentWidth;
});
// Keyboard
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
closeViewModal();
closeConfirmModal();
closeConfirmClearModal();
closeExportModal();
cancelImport();
cancelDuplicate();
}
});
</script>
</body>
</html>这是说明文本:
------------------------------------------------------
作者:Yangxiao
作者域名:aszv.top
声明:作者不承担任何责任,是否使用取决于你
安全与否取决于你怎么保存的数据,即使做的再好,也不能避免物理层面信息泄露,实在不放心的可断网使用
------------------------------------------------------
使用方法:
1、电脑端直接双击打开即可使用,这是纯前端网页工具,无需后端运行
2、手机上可以在文件夹里找到文件,选择浏览器打开也可以使用,收藏到书签方便下次访问
3、有服务器的,可以把 index.html 文件上传到网站根目录下,访问域名即可直接使用
------------------------------------------------------这里是网盘文件地址:
文件存在蓝奏云上,登录的是15310649220手机号,文件路径:根目录/2026/05/11/账号管理器/账号管理器.zip
文件存在夸克网盘上,登录手机号同上,文件路径:根目录/2026/05/11/账号管理器/账号管理器.zip
网站搭建地址同域名
演示图片:


下面是发布在blog.aszv.top的原文章内容:
这是一款账号管理器网页工具,核心作用是帮你集中、安全地管理各类平台的账号密码信息,整体设计兼顾了实用性和易用性,还适配了桌面端和移动端使用场景,具体功能和特点可以分为这些方面:
展示图片:
![]() | ![]() |
![]() | ![]() |
![]() | ![]() |
1. 核心功能:账号信息管理
- 集中存储:可以添加并保存不同平台(比如网站、APP)的账号信息,包括平台名称、网址、用户名、密码,还能记录备注、添加标签,把零散的账号密码都汇总在一处,不用再记在本子或随便存在记事本里。
- 便捷查看/编辑:账号信息以卡片形式展示,点击卡片能查看详情,也能随时编辑、删除账号,还支持复制账号/密码(复制后会有提示),密码默认模糊显示,可手动切换显示/隐藏。
- 密码生成:添加账号时,工具自带密码生成功能,还能自定义密码长度、是否包含数字/符号等,帮你创建安全度更高的密码。
2. 实用辅助功能
- 搜索与筛选:支持关键词搜索账号,也能通过标签筛选,快速找到需要的账号信息;还能对账号列表进行排序,方便管理。
- 数据备份/恢复:支持导出账号数据(备份)和导入数据(恢复/迁移),不用担心数据丢失;
- 主题切换:有浅色/深色两种主题模式,可根据使用习惯或环境切换,视觉体验更舒适。
3. 体验与适配
- 界面设计:整体简洁清晰,卡片式布局一目了然,操作按钮(比如添加、编辑、复制)都很直观,hover(悬停)时还有交互反馈,使用起来很顺手;
- 多端适配:专门做了移动端优化(比如调整布局、缩放适配),手机上也能方便操作,桌面端则保留更完整的操作空间;
- 细节体验:比如密码复制后有 Toast 提示、删除账号有二次确认(防止误删)、账号卡片显示更新时间等,细节上考虑得比较周全。
简单来说,这个工具解决了“账号密码太多记不住、找起来麻烦、密码不安全”的问题,相当于一个专属的“数字密码本”,而且是网页形式,不用安装额外软件,打开就能用,还兼顾了使用的便捷性和数据管理的安全性。






评论 (0)