Files
simple_password/index.html
T
laoyang 853531c07c feat: 密码生成器 simple_password 初版 (#1)
单文件 index.html,纯 HTML + CSS + JS,零外部依赖,双击即用。

功能:
- 长度 4~64 可调;选「每类至少一位」时下限自动收紧到所选类型数
- 小写 / 大写 / 数字 / 符号四类可选,全不选会兜回小写而非生成空密码
- 生成即复制;剪贴板 API 不可用时降级到 execCommand
- 排除易混淆字符(0 O o 1 l I)与自定义排除
- 真实熵值(长度 × log2(字符池))与五档强度评价
- 批量生成 5 个候选、最近 6 条历史(仅本机 localStorage)
- 深浅色跟随系统并可手动切换,首帧前定主题避免闪烁
- 快捷键:空格换一个、C 复制

实现要点:
- 随机数走 crypto.getRandomValues + 拒绝采样,消除取模偏差
- 「每类至少一位」= 各类先取一位 + 剩余全池随机 + 整体洗牌,
  避免固定位置带来的可预测性

验证:功能判据 80 条、浏览器几何/对比度/多视口 105 条、
真实剪贴板端到端 18 条全绿;24 条反向注入全部命中。
2026-10-28 11:52:37 +08:00

1852 lines
52 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Simple Password · 密码生成器</title>
<meta name="description" content="一个干净、纯本地的密码生成器。可调长度、自选字符组成,生成即复制。所有运算在你的浏览器里完成。">
<meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#0a0b0f">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%236366f1'/%3E%3Cpath d='M24 30v-4a8 8 0 0 1 16 0v4' stroke='white' stroke-width='4' fill='none' stroke-linecap='round'/%3E%3Crect x='20' y='30' width='24' height='17' rx='5' fill='white'/%3E%3C/svg%3E">
<script>
/* 主题要在首帧之前定下来,避免白屏闪烁 */
(function () {
var t;
try { t = localStorage.getItem('sp.theme.v1'); } catch (e) {}
if (t !== 'dark' && t !== 'light') {
t = (window.matchMedia && matchMedia('(prefers-color-scheme: light)').matches) ? 'light' : 'dark';
}
document.documentElement.setAttribute('data-theme', t);
})();
</script>
<style>
/* ============================================================
设计变量
============================================================ */
:root {
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", "Courier New", monospace;
--bg: #0a0b0f;
--bg-soft: #101218;
--glow-a: rgba(99, 102, 241, .22);
--glow-b: rgba(168, 85, 247, .16);
--glow-c: rgba(34, 211, 238, .10);
--surface: rgba(20, 22, 30, .72);
--surface-2: rgba(255, 255, 255, .035);
--pw-bg: rgba(255, 255, 255, .045);
--line: rgba(255, 255, 255, .085);
--line-2: rgba(255, 255, 255, .16);
--text: #eef1f7;
--text-2: #a4abbb;
--text-3: #868da0;
--accent: #8b87ff;
--accent-solid: #6366f1;
--accent-2: #c084fc;
--accent-soft: rgba(139, 135, 255, .14);
--ok: #34d399;
--warn: #fbbf24;
--bad: #f87171;
--r-lg: 26px;
--r-md: 16px;
--r-sm: 11px;
--shadow: 0 1px 0 rgba(255, 255, 255, .045) inset,
0 30px 70px -34px rgba(0, 0, 0, .95);
--ring: 0 0 0 3px var(--accent-soft);
}
[data-theme="light"] {
--bg: #f4f5f8;
--bg-soft: #eceef4;
--glow-a: rgba(99, 102, 241, .16);
--glow-b: rgba(168, 85, 247, .13);
--glow-c: rgba(14, 165, 233, .12);
--surface: rgba(255, 255, 255, .78);
--surface-2: rgba(16, 18, 27, .028);
--pw-bg: rgba(16, 18, 27, .035);
--line: rgba(16, 18, 27, .085);
--line-2: rgba(16, 18, 27, .15);
--text: #12141b;
--text-2: #565c6b;
--text-3: #686d7c;
--accent: #4f46e5;
--accent-solid: #4f46e5;
--accent-2: #9333ea;
--accent-soft: rgba(79, 70, 229, .10);
--ok: #0f9d76;
--warn: #b45309;
--bad: #dc2626;
--shadow: 0 1px 2px rgba(16, 18, 27, .04),
0 26px 60px -30px rgba(16, 18, 27, .35);
--ring: 0 0 0 3px var(--accent-soft);
}
/* ============================================================
基础
============================================================ */
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
min-height: 100dvh;
font-family: var(--sans);
font-size: 15px;
line-height: 1.55;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
transition: background-color .35s ease, color .35s ease;
}
/* 背景光晕 */
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
background:
radial-gradient(46rem 32rem at 8% -12%, var(--glow-a), transparent 62%),
radial-gradient(40rem 30rem at 96% 4%, var(--glow-b), transparent 58%),
radial-gradient(52rem 34rem at 50% 112%, var(--glow-c), transparent 60%),
var(--bg);
pointer-events: none;
}
::selection { background: var(--accent-soft); }
:focus-visible {
outline: none;
box-shadow: var(--ring);
border-radius: 8px;
}
/* ============================================================
布局
============================================================ */
.page {
width: 100%;
max-width: 1080px;
margin: 0 auto;
padding: clamp(20px, 3.4vw, 38px) clamp(16px, 3vw, 28px) 64px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: clamp(18px, 3vw, 28px);
}
.brand {
display: flex;
align-items: center;
gap: 13px;
min-width: 0;
}
.logo {
width: 40px;
height: 40px;
flex: none;
border-radius: 12px;
display: grid;
place-items: center;
color: #fff;
background: linear-gradient(145deg, #6366f1, #a855f7 62%, #7c3aed);
box-shadow: 0 8px 20px -8px rgba(99, 102, 241, .85),
0 1px 0 rgba(255, 255, 255, .3) inset;
}
.logo svg { width: 21px; height: 21px; display: block; }
.brand-text { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; }
.brand-text strong {
font-size: 15.5px;
font-weight: 650;
letter-spacing: -.01em;
}
.brand-text span {
font-size: 11px;
color: var(--text-3);
letter-spacing: .1em;
text-transform: uppercase;
margin-top: 2px;
}
.icon-btn {
width: 40px;
height: 40px;
flex: none;
display: grid;
place-items: center;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--text-2);
cursor: pointer;
transition: color .18s, background .18s, border-color .18s, transform .18s;
}
.icon-btn:hover { color: var(--text); border-color: var(--line-2); transform: translateY(-1px); }
.icon-btn:active { transform: translateY(0) scale(.97); }
.icon-btn svg { width: 18px; height: 18px; }
.icon-btn .sun { display: none; }
[data-theme="light"] .icon-btn .sun { display: block; }
[data-theme="light"] .icon-btn .moon { display: none; }
/* ============================================================
主卡片
============================================================ */
.card {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1.08fr) minmax(0, .92fr);
border-radius: var(--r-lg);
border: 1px solid var(--line);
background: var(--surface);
backdrop-filter: blur(22px) saturate(150%);
-webkit-backdrop-filter: blur(22px) saturate(150%);
box-shadow: var(--shadow);
overflow: hidden;
}
.pane { padding: clamp(22px, 3vw, 30px); min-width: 0; }
/* 左栏内容比右栏短,直接顶部对齐会在底部留出一大片空档(实测 180px)。
主内容用 auto 上下外边距居中,快捷键提示压在底部,版面才立得住。 */
.pane--out {
display: flex;
flex-direction: column;
}
.out-main { margin: auto 0; }
/* 左栏补充一条"字符池"信息:既填住空档,也让用户看见 S 到底有多少种可能 */
.pool {
display: flex;
align-items: center;
gap: 8px;
margin: 18px 0 0;
padding-top: 16px;
border-top: 1px solid var(--line);
font-size: 11px;
color: var(--text-3);
line-height: 1.6;
}
.pool b {
font-family: var(--mono);
font-size: 12.5px;
font-weight: 650;
color: var(--text-2);
font-variant-numeric: tabular-nums;
}
.pool .sep { opacity: .4; }
.shortcuts {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 14px;
margin: 22px 0 0;
font-size: 11px;
color: var(--text-3);
line-height: 1.6;
}
.shortcuts span { display: inline-flex; align-items: center; gap: 6px; }
kbd {
font-family: var(--mono);
font-size: 11px;
font-weight: 600;
line-height: 1;
padding: 4px 6px;
border-radius: 5px;
color: var(--text-2);
background: var(--surface-2);
border: 1px solid var(--line-2);
border-bottom-width: 2px;
}
.pane--ctl {
border-left: 1px solid var(--line);
background: var(--surface-2);
display: flex;
flex-direction: column;
gap: 22px;
}
/* ---------- 输出区 ---------- */
.label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.eyebrow {
font-size: 11px;
font-weight: 600;
letter-spacing: .13em;
text-transform: uppercase;
color: var(--text-3);
}
.tag {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 500;
padding: 3px 9px;
border-radius: 999px;
color: var(--text-3);
background: var(--surface-2);
border: 1px solid var(--line);
white-space: nowrap;
transition: color .2s, border-color .2s;
}
.tag .dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
opacity: .55;
}
.tag.is-live { color: var(--accent); border-color: var(--accent-soft); }
.tag.is-live .dot { opacity: 1; box-shadow: 0 0 0 3px var(--accent-soft); }
.pw {
position: relative;
display: block;
margin: 0;
padding: clamp(20px, 2.6vw, 28px) clamp(16px, 2.2vw, 24px);
min-height: 132px;
border-radius: var(--r-md);
border: 1px solid var(--line);
background: var(--pw-bg);
font-family: var(--mono);
font-size: clamp(20px, 2.55vw, 30px);
font-weight: 600;
line-height: 1.46;
letter-spacing: .012em;
word-break: break-all;
overflow-wrap: anywhere;
color: var(--text);
cursor: pointer;
user-select: all;
transition: border-color .2s, background .2s, box-shadow .2s;
}
.pw:hover { border-color: var(--line-2); }
.pw:active { box-shadow: var(--ring); }
.pw .ch-a { color: var(--text); }
.pw .ch-d { color: var(--accent); }
.pw .ch-s { color: var(--accent-2); }
.pw.is-empty {
display: flex;
align-items: center;
font-family: var(--sans);
font-size: 14.5px;
font-weight: 500;
line-height: 1.5;
color: var(--bad);
cursor: default;
user-select: none;
}
.out-actions {
display: flex;
gap: 10px;
margin-top: 14px;
}
.btn {
appearance: none;
border: 1px solid transparent;
border-radius: 12px;
font-family: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: .01em;
padding: 11px 18px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: transform .16s, box-shadow .2s, background .2s, border-color .2s, color .2s;
}
.btn svg { width: 16px; height: 16px; flex: none; }
.btn:active { transform: scale(.985); }
.btn--primary {
flex: 1 1 auto;
color: #fff;
background: linear-gradient(180deg, #5b5ee8, #4f46e5);
box-shadow: 0 10px 24px -12px rgba(99, 102, 241, .95),
0 1px 0 rgba(255, 255, 255, .22) inset;
}
.btn--primary:hover {
box-shadow: 0 14px 30px -12px rgba(99, 102, 241, 1),
0 1px 0 rgba(255, 255, 255, .28) inset;
}
.btn--primary:active { box-shadow: 0 6px 14px -10px rgba(99, 102, 241, .9); }
.btn--ghost {
flex: 0 1 auto;
color: var(--text-2);
border-color: var(--line);
background: transparent;
}
.btn--ghost:hover { color: var(--text); border-color: var(--line-2); background: var(--surface-2); }
.btn[disabled] { opacity: .45; cursor: not-allowed; transform: none; }
/* ---------- 强度 ---------- */
.meter { margin-top: 24px; }
.meter-track {
height: 5px;
border-radius: 999px;
background: var(--line);
overflow: hidden;
}
.meter-fill {
height: 100%;
width: 0%;
border-radius: 999px;
background: var(--text-3);
transition: width .38s cubic-bezier(.4, 0, .2, 1), background-color .38s;
}
.meter-fill.lvl-1 { background: var(--bad); }
.meter-fill.lvl-2 { background: var(--warn); }
.meter-fill.lvl-3 { background: #a3e635; }
.meter-fill.lvl-4 { background: var(--ok); }
.meter-fill.lvl-5 { background: linear-gradient(90deg, var(--ok), var(--accent)); }
.meter-meta {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
font-size: 12.5px;
}
.meter-meta .strength { font-weight: 600; color: var(--text-2); }
.meter-meta .entropy {
color: var(--text-3);
font-family: var(--mono);
font-size: 11px;
letter-spacing: .02em;
}
/* ---------- 预设 ---------- */
.presets {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.preset {
appearance: none;
font-family: inherit;
font-size: 12.5px;
font-weight: 550;
color: var(--text-2);
padding: 6px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
cursor: pointer;
transition: color .16s, border-color .16s, background .16s;
}
.preset:hover { color: var(--text); border-color: var(--line-2); background: var(--surface-2); }
.preset.is-active {
color: var(--accent);
border-color: var(--accent-soft);
background: var(--accent-soft);
}
/* ---------- 字段 ---------- */
.field { display: flex; flex-direction: column; gap: 11px; }
.field-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.field-head > label,
.field-head > span {
font-size: 12.5px;
font-weight: 600;
color: var(--text-2);
letter-spacing: .02em;
}
.len-out {
font-family: var(--mono);
font-size: 19px;
font-weight: 650;
color: var(--text);
line-height: 1;
letter-spacing: -.02em;
font-variant-numeric: tabular-nums;
}
.len-out small {
font-size: 11px;
font-weight: 500;
color: var(--text-3);
letter-spacing: .08em;
margin-left: 3px;
text-transform: uppercase;
}
.link-btn {
appearance: none;
border: 0;
background: none;
padding: 0;
font-family: inherit;
font-size: 12.5px;
font-weight: 550;
color: var(--text-3);
cursor: pointer;
transition: color .16s;
}
.link-btn:hover { color: var(--accent); }
/* ---------- 滑块 ---------- */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 22px;
margin: 0;
background: transparent;
cursor: pointer;
}
input[type="range"]::-webkit-slider-runnable-track {
height: 5px;
border-radius: 999px;
background: linear-gradient(90deg,
var(--accent) 0%, var(--accent) var(--fill, 20%),
var(--line) var(--fill, 20%), var(--line) 100%);
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
margin-top: -6.5px;
border-radius: 50%;
background: #fff;
border: 2px solid var(--accent-solid);
box-shadow: 0 2px 8px rgba(0, 0, 0, .3);
transition: transform .15s, box-shadow .15s;
}
input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.1); }
input[type="range"]:active::-webkit-slider-thumb { transform: scale(1.16); }
input[type="range"]:focus-visible::-webkit-slider-thumb {
box-shadow: 0 0 0 5px var(--accent-soft);
}
input[type="range"]::-moz-range-track {
height: 5px;
border-radius: 999px;
background: var(--line);
}
input[type="range"]::-moz-range-progress {
height: 5px;
border-radius: 999px;
background: var(--accent);
}
input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
border: 2px solid var(--accent-solid);
box-shadow: 0 2px 8px rgba(0, 0, 0, .3);
}
/* ---------- 字符类型 ---------- */
.chips {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.chip {
position: relative;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--r-sm);
border: 1px solid var(--line);
background: transparent;
cursor: pointer;
transition: border-color .16s, background .16s, color .16s;
}
.chip input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.chip:hover { border-color: var(--line-2); background: var(--surface-2); }
.chip-box {
width: 16px;
height: 16px;
flex: none;
border-radius: 5px;
border: 1.5px solid var(--line-2);
display: grid;
place-items: center;
transition: background .16s, border-color .16s;
}
.chip-box svg {
width: 10px;
height: 10px;
color: #fff;
opacity: 0;
transform: scale(.6);
transition: opacity .16s, transform .16s;
}
.chip.is-on .chip-box {
background: var(--accent-solid);
border-color: var(--accent-solid);
}
.chip.is-on .chip-box svg { opacity: 1; transform: scale(1); }
.chip-body { display: flex; flex-direction: column; line-height: 1.2; min-width: 0; }
.chip-name { font-size: 13px; font-weight: 550; color: var(--text-2); transition: color .16s; }
.chip-sample {
font-family: var(--mono);
font-size: 11px;
color: var(--text-3);
margin-top: 1px;
letter-spacing: .04em;
}
.chip.is-on { border-color: var(--accent-soft); background: var(--accent-soft); }
.chip.is-on .chip-name { color: var(--text); }
.chip:focus-within { box-shadow: var(--ring); }
/* ---------- 开关 ---------- */
.switches { display: flex; flex-direction: column; gap: 2px; }
.switch {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 9px 0;
cursor: pointer;
}
.switch + .switch { border-top: 1px solid var(--line); }
.switch-text { display: flex; flex-direction: column; line-height: 1.3; min-width: 0; }
.switch-text b {
font-size: 13px;
font-weight: 550;
color: var(--text-2);
letter-spacing: .01em;
}
.switch-text i {
font-style: normal;
font-size: 11px;
color: var(--text-3);
margin-top: 2px;
}
.switch input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.track {
position: relative;
width: 38px;
height: 22px;
flex: none;
border-radius: 999px;
background: var(--line-2);
transition: background .22s;
}
.track::after {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, .35);
transition: transform .22s cubic-bezier(.4, 0, .2, 1);
}
.switch input:checked + .track { background: var(--accent-solid); }
.switch input:checked + .track::after { transform: translateX(16px); }
.switch input:focus-visible + .track { box-shadow: 0 0 0 3px var(--accent-soft); }
/* ---------- 高级 ---------- */
.adv {
border-top: 1px solid var(--line);
padding-top: 14px;
margin-top: -4px;
}
.adv summary {
list-style: none;
cursor: pointer;
font-size: 12.5px;
font-weight: 550;
color: var(--text-3);
display: flex;
align-items: center;
gap: 6px;
transition: color .16s;
user-select: none;
}
.adv summary::-webkit-details-marker { display: none; }
.adv summary:hover { color: var(--text-2); }
.adv summary svg {
width: 12px;
height: 12px;
transition: transform .22s;
}
.adv[open] summary svg { transform: rotate(90deg); }
.adv[open] summary { color: var(--text-2); margin-bottom: 11px; }
.adv-body { animation: fade-down .22s ease both; }
@keyframes fade-down {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: none; }
}
.text-input {
width: 100%;
font-family: var(--mono);
font-size: 13px;
color: var(--text);
padding: 10px 12px;
border-radius: var(--r-sm);
border: 1px solid var(--line);
background: var(--pw-bg);
transition: border-color .16s;
}
.text-input::placeholder { color: var(--text-3); font-family: var(--sans); }
.text-input:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
.hint {
margin: 8px 0 0;
font-size: 11px;
color: var(--text-3);
line-height: 1.5;
}
/* ============================================================
候选 / 历史
============================================================ */
.grid2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: clamp(14px, 2vw, 20px);
margin-top: clamp(14px, 2vw, 20px);
}
.panel {
border-radius: var(--r-lg);
border: 1px solid var(--line);
background: var(--surface);
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
padding: 20px 22px 18px;
box-shadow: var(--shadow);
min-width: 0;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.panel-head h2 {
margin: 0;
font-size: 11px;
font-weight: 600;
letter-spacing: .13em;
text-transform: uppercase;
color: var(--text-3);
}
.list { list-style: none; margin: 0; padding: 0; }
.list li {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
margin: 0 -10px;
border-radius: 9px;
cursor: pointer;
transition: background .16s;
}
.list li:hover { background: var(--surface-2); }
.list li:focus-visible { box-shadow: var(--ring); }
.list .val {
flex: 1 1 auto;
min-width: 0;
font-family: var(--mono);
font-size: 12.5px;
letter-spacing: .01em;
color: var(--text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.list .meta {
flex: none;
font-size: 11px;
color: var(--text-3);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.empty {
font-size: 12.5px;
color: var(--text-3);
padding: 10px 0 6px;
margin: 0;
}
/* ============================================================
页脚 / 提示
============================================================ */
.foot {
margin-top: 26px;
text-align: center;
font-size: 11px;
color: var(--text-3);
line-height: 1.7;
}
.foot b { color: var(--text-2); font-weight: 550; }
.foot .sep { opacity: .45; margin: 0 7px; }
.toast {
position: fixed;
left: 50%;
bottom: 26px;
z-index: 50;
transform: translate(-50%, 14px);
opacity: 0;
pointer-events: none;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 17px;
border-radius: 999px;
font-size: 13px;
font-weight: 550;
color: var(--text);
background: var(--surface);
border: 1px solid var(--line-2);
backdrop-filter: blur(18px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%);
box-shadow: 0 16px 40px -16px rgba(0, 0, 0, .55);
transition: opacity .24s, transform .24s cubic-bezier(.4, 0, .2, 1);
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
.toast svg { width: 15px; height: 15px; color: var(--ok); }
/* ============================================================
响应式
============================================================ */
@media (max-width: 880px) {
.card { grid-template-columns: minmax(0, 1fr); }
.pane--ctl { border-left: 0; border-top: 1px solid var(--line); }
.grid2 { grid-template-columns: minmax(0, 1fr); }
.pw { min-height: 96px; }
}
@media (max-width: 420px) {
.btn { padding: 11px 14px; font-size: 13.5px; }
.pw { font-size: 19px; min-height: 88px; }
.panel { padding: 17px 16px 14px; }
.list li { padding: 9px 8px; margin: 0 -8px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .001ms !important;
transition-duration: .001ms !important;
}
}
</style>
</head>
<body>
<div class="page">
<header class="topbar">
<div class="brand">
<div class="logo" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
<rect x="4" y="10.5" width="16" height="10.5" rx="3.2"/>
<path d="M8 10.5V7.6a4 4 0 0 1 8 0v2.9"/>
<circle cx="12" cy="15.8" r="1.35" fill="currentColor" stroke="none"/>
</svg>
</div>
<div class="brand-text">
<strong>Simple Password</strong>
<span>Generator</span>
</div>
</div>
<button class="icon-btn" id="themeBtn" type="button" title="切换深浅色" aria-label="切换深浅色主题">
<svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M20.5 14.4A8.6 8.6 0 0 1 9.6 3.5a8.9 8.9 0 1 0 10.9 10.9z"/>
</svg>
<svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4.2"/>
<path d="M12 2.6v2M12 19.4v2M2.6 12h2M19.4 12h2M5.4 5.4l1.4 1.4M17.2 17.2l1.4 1.4M18.6 5.4l-1.4 1.4M6.8 17.2l-1.4 1.4"/>
</svg>
</button>
</header>
<main>
<section class="card">
<!-- 输出 -->
<div class="pane pane--out">
<div class="out-main">
<div class="label-row">
<span class="eyebrow">生成的密码</span>
<span class="tag" id="autoTag"><span class="dot"></span><span id="autoTagText">自动复制</span></span>
</div>
<div class="pw" id="pw" role="textbox" aria-readonly="true" tabindex="0" title="点击即可复制"></div>
<div class="out-actions">
<button class="btn btn--primary" id="copyBtn" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="11.5" height="11.5" rx="2.4"/>
<path d="M15 6.2A2.2 2.2 0 0 0 12.8 4H5.7A2.2 2.2 0 0 0 3.5 6.2v7.1A2.2 2.2 0 0 0 5.7 15"/>
</svg>
复制密码
</button>
<button class="btn btn--ghost" id="regenBtn" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20.4 12a8.4 8.4 0 1 1-2.6-6.1"/>
<path d="M20.6 4.2v5h-5"/>
</svg>
换一个
</button>
</div>
<div class="meter">
<div class="meter-track"><div class="meter-fill" id="meterFill"></div></div>
<div class="meter-meta">
<span class="strength" id="strength">—</span>
<span class="entropy" id="entropy">—</span>
</div>
</div>
<p class="pool" id="pool"></p>
</div>
<p class="shortcuts">
<span><kbd>空格</kbd> 换一个</span>
<span><kbd>C</kbd> 复制</span>
<span><kbd>点击密码</kbd> 也可以复制</span>
</p>
</div>
<!-- 控制 -->
<div class="pane pane--ctl">
<div class="presets" id="presets" role="group" aria-label="快捷预设">
<button class="preset" type="button" data-preset="std">标准</button>
<button class="preset" type="button" data-preset="strong">高强度</button>
<button class="preset" type="button" data-preset="pin">数字 PIN</button>
<button class="preset" type="button" data-preset="pass">易读口令</button>
</div>
<div class="field">
<div class="field-head">
<label for="len">长度</label>
<output class="len-out" id="lenOut" for="len">16<small>位</small></output>
</div>
<input type="range" id="len" min="4" max="64" step="1" value="16" aria-label="密码长度">
</div>
<div class="field">
<div class="field-head">
<span>字符组成</span>
<button class="link-btn" id="allBtn" type="button">全选</button>
</div>
<div class="chips" id="chips"></div>
</div>
<div class="switches">
<label class="switch">
<span class="switch-text">
<b>排除易混淆字符</b>
<i>去掉 0 O o 1 l I,抄写不出错</i>
</span>
<input type="checkbox" id="optAmbig" checked>
<span class="track"></span>
</label>
<label class="switch">
<span class="switch-text">
<b>每类至少一位</b>
<i>所选的字符类型都会出现在结果里</i>
</span>
<input type="checkbox" id="optEach" checked>
<span class="track"></span>
</label>
<label class="switch">
<span class="switch-text">
<b>自动复制</b>
<i>生成后直接写入剪贴板</i>
</span>
<input type="checkbox" id="optCopy" checked>
<span class="track"></span>
</label>
</div>
<details class="adv">
<summary>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>
自定义排除字符
</summary>
<div class="adv-body">
<input class="text-input" id="exclude" type="text" maxlength="80"
placeholder="想避免的字符,例如 ! @ $ 空格" spellcheck="false" autocomplete="off">
<p class="hint">这些字符不会出现在生成的密码中,可留空。</p>
</div>
</details>
</div>
</section>
<section class="grid2">
<div class="panel">
<div class="panel-head">
<h2>候选密码</h2>
<button class="link-btn" id="batchBtn" type="button">生成 5 个</button>
</div>
<ul class="list" id="candList"><li class="empty">点右上角一次生成多个备选,点任意一条即复制。</li></ul>
</div>
<div class="panel">
<div class="panel-head">
<h2>最近生成</h2>
<button class="link-btn" id="clearBtn" type="button">清空</button>
</div>
<ul class="list" id="histList"><li class="empty">还没有记录。生成并复制后会自动保存在本机。</li></ul>
</div>
</section>
</main>
<footer class="foot">
<b>全程本地运算</b><span class="sep">·</span>使用 Web Crypto 密码学安全随机数<span class="sep">·</span>不联网、不上传、不留痕
</footer>
</div>
<div class="toast" id="toast" role="status" aria-live="polite">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6.5L9.5 17 4 11.5"/></svg>
<span id="toastText">已复制</span>
</div>
<noscript>
<div style="padding:20px;text-align:center;font-family:sans-serif">此工具需要启用 JavaScript 才能在本地生成密码。</div>
</noscript>
<script>
(function () {
'use strict';
/* ==========================================================
常量
========================================================== */
var KEY_PREFS = 'sp.prefs.v1';
var KEY_HIST = 'sp.history.v1';
var KEY_THEME = 'sp.theme.v1';
var MAX_LEN = 64;
var HIST_MAX = 6;
var CLASSES = [
{ id: 'lower', label: '小写', sample: 'abc', chars: 'abcdefghijklmnopqrstuvwxyz' },
{ id: 'upper', label: '大写', sample: 'ABC', chars: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' },
{ id: 'digit', label: '数字', sample: '123', chars: '0123456789' },
{ id: 'symbol', label: '符号', sample: '!@#', chars: '!@#$%^&*()-_=+[]{};:,.?/~' }
];
/* 易混淆:数字 0 / 字母 O o / 数字 1 / 字母 l I */
var AMBIGUOUS = '0Oo1lI';
var PRESETS = {
std: { len: 16, sets: ['lower', 'upper', 'digit', 'symbol'], ambig: true, each: true },
strong: { len: 28, sets: ['lower', 'upper', 'digit', 'symbol'], ambig: true, each: true },
pin: { len: 6, sets: ['digit'], ambig: true, each: false },
pass: { len: 20, sets: ['lower', 'upper', 'digit'], ambig: true, each: true }
};
/* ==========================================================
状态
========================================================== */
var prefs = {
len: 16,
sets: { lower: true, upper: true, digit: true, symbol: true },
ambig: true,
each: true,
autoCopy: true,
exclude: ''
};
var current = '';
var copyTimer = null;
var toastTimer = null;
function $(id) { return document.getElementById(id); }
/* ==========================================================
随机数:拒绝采样,消除取模偏差
========================================================== */
var RND = new Uint32Array(1);
var TWO32 = 4294967296;
function randInt(n) {
if (n <= 1) return 0;
var limit = Math.floor(TWO32 / n) * n;
var v;
do {
if (window.crypto && crypto.getRandomValues) {
crypto.getRandomValues(RND);
v = RND[0];
} else {
v = Math.floor(Math.random() * TWO32);
}
} while (v >= limit);
return v % n;
}
function shuffle(arr) {
for (var i = arr.length - 1; i > 0; i--) {
var j = randInt(i + 1);
var t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
return arr;
}
/* ==========================================================
字符池
========================================================== */
function excludedSet() {
if (!prefs.exclude) return null;
var s = {}, chars = Array.from(prefs.exclude);
for (var i = 0; i < chars.length; i++) s[chars[i]] = true;
return s;
}
/* 返回按字符类型分组的可用字符数组(已过滤易混淆 / 自定义排除) */
function pools() {
var ex = excludedSet();
var out = [];
for (var i = 0; i < CLASSES.length; i++) {
var def = CLASSES[i];
if (!prefs.sets[def.id]) continue;
var kept = '';
for (var k = 0; k < def.chars.length; k++) {
var ch = def.chars.charAt(k);
if (prefs.ambig && AMBIGUOUS.indexOf(ch) !== -1) continue;
if (ex && ex[ch]) continue;
kept += ch;
}
if (kept) out.push(kept);
}
return out;
}
/* ==========================================================
生成
========================================================== */
function generate() {
var groups = pools();
if (!groups.length) {
return { error: '请至少选择一种字符类型' };
}
var pool = groups.join('');
var need = groups.length;
var len = prefs.len;
if (prefs.each && len < need) {
return { error: '长度不足,无法覆盖所选的全部字符类型' };
}
var chars = [];
if (prefs.each) {
/* 每个类型先各取一位,剩余长度从全池随机,最后整体洗牌 */
for (var g = 0; g < groups.length; g++) {
chars.push(groups[g].charAt(randInt(groups[g].length)));
}
for (var r = need; r < len; r++) {
chars.push(pool.charAt(randInt(pool.length)));
}
shuffle(chars);
} else {
for (var i = 0; i < len; i++) {
chars.push(pool.charAt(randInt(pool.length)));
}
}
var bits = len * Math.log(pool.length) / Math.LN2;
var lvl = strengthLevel(bits);
return {
pw: chars.join(''),
bits: bits,
level: lvl.level,
label: lvl.label,
poolSize: pool.length,
groups: groups.length,
each: prefs.each
};
}
function strengthLevel(bits) {
if (bits >= 112) return { level: 5, label: '极强' };
if (bits >= 80) return { level: 4, label: '很强' };
if (bits >= 60) return { level: 3, label: '强' };
if (bits >= 40) return { level: 2, label: '一般' };
return { level: 1, label: '弱' };
}
/* ==========================================================
渲染:密码
========================================================== */
function paint(el, pw) {
var frag = document.createDocumentFragment();
for (var i = 0; i < pw.length; i++) {
var ch = pw.charAt(i);
var cls;
if (ch >= '0' && ch <= '9') cls = 'ch-d';
else if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')) cls = 'ch-a';
else cls = 'ch-s';
var span = document.createElement('span');
span.className = cls;
span.textContent = ch;
frag.appendChild(span);
}
el.textContent = '';
el.appendChild(frag);
}
function renderPool(info) {
var el = $('pool');
if (!info || !info.poolSize) {
el.textContent = '';
return;
}
el.textContent = '';
var b1 = document.createElement('b');
b1.textContent = info.poolSize;
el.appendChild(document.createTextNode('可用字符 '));
el.appendChild(b1);
el.appendChild(document.createTextNode(' 个'));
var sep = document.createElement('span');
sep.className = 'sep';
sep.textContent = '·';
el.appendChild(sep);
var b2 = document.createElement('b');
b2.textContent = info.groups;
el.appendChild(b2);
el.appendChild(document.createTextNode(' 类组合'));
if (info.each) {
var sep2 = document.createElement('span');
sep2.className = 'sep';
sep2.textContent = '·';
el.appendChild(sep2);
el.appendChild(document.createTextNode('每类至少 1 位'));
}
}
function setMeter(level, bits, label) {
var fill = $('meterFill');
fill.className = 'meter-fill' + (level ? ' lvl-' + level : '');
fill.style.width = (level * 20) + '%';
$('strength').textContent = label || '—';
$('entropy').textContent = bits ? '约 ' + Math.round(bits) + ' bit 熵' : '—';
}
/* ==========================================================
渲染:主流程
========================================================== */
function regenerate() {
var el = $('pw');
var res = generate();
if (res.error) {
current = '';
el.className = 'pw is-empty';
el.textContent = res.error;
setMeter(0, 0, '不可用');
renderPool(null);
$('copyBtn').disabled = true;
return;
}
current = res.pw;
el.className = 'pw';
paint(el, res.pw);
setMeter(res.level, res.bits, res.label);
renderPool(res);
$('copyBtn').disabled = false;
}
/* 控件变动:立刻重算,稍等再复制(拖滑块时不至于疯狂写剪贴板) */
function onControlsChanged(immediate) {
syncLengthBounds();
persist();
regenerate();
updatePresetHighlight();
if (!prefs.autoCopy) return;
clearTimeout(copyTimer);
if (immediate) {
copyNow();
} else {
copyTimer = setTimeout(copyNow, 420);
}
}
/* ==========================================================
剪贴板
========================================================== */
function copyText(text, cb) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () {
cb(true);
}, function () {
cb(fallbackCopy(text));
});
return;
}
cb(fallbackCopy(text));
}
function fallbackCopy(text) {
var ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;';
document.body.appendChild(ta);
var ok = false;
try {
ta.select();
ta.setSelectionRange(0, text.length);
ok = document.execCommand('copy');
} catch (e) {
ok = false;
}
document.body.removeChild(ta);
return ok;
}
function copyNow() {
if (!current) return;
copyText(current, function (ok) {
if (ok) {
pushHistory(current);
toast('已复制到剪贴板');
} else {
toast('复制被拦截,请手动选中', true);
}
});
}
/* ==========================================================
提示条
========================================================== */
function toast(msg, isError) {
var el = $('toast');
$('toastText').textContent = msg;
el.style.setProperty('--ok', isError ? 'var(--bad)' : 'var(--ok)');
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
el.classList.remove('show');
}, 1900);
}
/* ==========================================================
历史(本机 localStorage)
========================================================== */
function readHistory() {
try {
var raw = localStorage.getItem(KEY_HIST);
var arr = raw ? JSON.parse(raw) : [];
if (!Array.isArray(arr)) return [];
return arr.filter(function (x) { return x && typeof x.p === 'string' && x.p; });
} catch (e) {
return [];
}
}
function pushHistory(pw) {
var arr = readHistory();
if (arr.length && arr[0].p === pw) { renderHistory(); return; }
arr.unshift({ p: pw, t: Date.now() });
arr = arr.slice(0, HIST_MAX);
try { localStorage.setItem(KEY_HIST, JSON.stringify(arr)); } catch (e) {}
renderHistory();
}
function renderHistory() {
var box = $('histList');
var arr = readHistory();
box.textContent = '';
if (!arr.length) {
var li = document.createElement('li');
li.className = 'empty';
li.textContent = '还没有记录。生成并复制后会自动保存在本机。';
box.appendChild(li);
return;
}
for (var i = 0; i < arr.length; i++) {
(function (item) {
box.appendChild(listItem(item.p, ago(item.t), function () {
copyText(item.p, function (ok) {
toast(ok ? '已从历史复制' : '复制被拦截', !ok);
});
}));
})(arr[i]);
}
}
function ago(ts) {
var diff = Math.max(0, Date.now() - ts);
var m = Math.floor(diff / 60000);
if (m < 1) return '刚刚';
if (m < 60) return m + ' 分钟前';
var h = Math.floor(m / 60);
if (h < 24) return h + ' 小时前';
return Math.floor(h / 24) + ' 天前';
}
function listItem(text, meta, onClick) {
var li = document.createElement('li');
li.tabIndex = 0;
li.title = '点击复制';
var span = document.createElement('span');
span.className = 'val';
span.textContent = text;
var m = document.createElement('span');
m.className = 'meta';
m.textContent = meta;
li.appendChild(span);
li.appendChild(m);
li.addEventListener('click', onClick);
li.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
});
return li;
}
/* ==========================================================
候选
========================================================== */
function renderCandidates() {
var box = $('candList');
box.textContent = '';
var made = 0;
for (var i = 0; i < 5; i++) {
var res = generate();
if (res.error) {
var err = document.createElement('li');
err.className = 'empty';
err.textContent = res.error;
box.appendChild(err);
return;
}
(function (pw, bits) {
box.appendChild(listItem(pw, Math.round(bits) + ' bit', function () {
copyText(pw, function (ok) {
if (ok) { pushHistory(pw); toast('已复制候选密码'); }
else { toast('复制被拦截', true); }
});
}));
})(res.pw, res.bits);
made++;
}
if (!made) box.innerHTML = '<li class="empty">生成失败</li>';
}
/* ==========================================================
长度上下限
========================================================== */
function syncLengthBounds() {
var n = 0;
for (var i = 0; i < CLASSES.length; i++) if (prefs.sets[CLASSES[i].id]) n++;
var min = prefs.each ? Math.max(2, n) : 1;
if (min > MAX_LEN) min = MAX_LEN;
var slider = $('len');
slider.min = min;
if (prefs.len < min) prefs.len = min;
if (prefs.len > MAX_LEN) prefs.len = MAX_LEN;
slider.value = prefs.len;
var pct = MAX_LEN > min ? ((prefs.len - min) / (MAX_LEN - min)) * 100 : 100;
slider.style.setProperty('--fill', pct + '%');
$('lenOut').innerHTML = prefs.len + '<small>位</small>';
}
/* ==========================================================
界面同步
========================================================== */
function buildChips() {
var box = $('chips');
box.textContent = '';
for (var i = 0; i < CLASSES.length; i++) {
(function (def) {
var label = document.createElement('label');
label.className = 'chip';
var input = document.createElement('input');
input.type = 'checkbox';
input.setAttribute('data-set', def.id);
input.checked = !!prefs.sets[def.id];
var boxEl = document.createElement('span');
boxEl.className = 'chip-box';
boxEl.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6.5L9.5 17 4 11.5"/></svg>';
var body = document.createElement('span');
body.className = 'chip-body';
var name = document.createElement('span');
name.className = 'chip-name';
name.textContent = def.label;
var sample = document.createElement('span');
sample.className = 'chip-sample';
sample.textContent = def.sample;
body.appendChild(name);
body.appendChild(sample);
label.appendChild(input);
label.appendChild(boxEl);
label.appendChild(body);
input.addEventListener('change', function () {
prefs.sets[def.id] = input.checked;
label.classList.toggle('is-on', input.checked);
ensureOneSet();
onControlsChanged(false);
});
label.classList.toggle('is-on', input.checked);
box.appendChild(label);
})(CLASSES[i]);
}
}
/* 不允许一个都不选 */
function ensureOneSet() {
var any = false;
for (var i = 0; i < CLASSES.length; i++) if (prefs.sets[CLASSES[i].id]) any = true;
if (!any) {
prefs.sets.lower = true;
var inputs = $('chips').querySelectorAll('input[data-set]');
for (var k = 0; k < inputs.length; k++) {
if (inputs[k].getAttribute('data-set') === 'lower') inputs[k].checked = true;
}
var first = $('chips').querySelector('.chip');
if (first) first.classList.add('is-on');
}
}
function syncChips() {
var inputs = $('chips').querySelectorAll('input[data-set]');
for (var i = 0; i < inputs.length; i++) {
var id = inputs[i].getAttribute('data-set');
inputs[i].checked = !!prefs.sets[id];
inputs[i].parentNode.classList.toggle('is-on', !!prefs.sets[id]);
}
}
function updatePresetHighlight() {
var btns = $('presets').querySelectorAll('.preset');
for (var i = 0; i < btns.length; i++) {
var p = PRESETS[btns[i].getAttribute('data-preset')];
if (!p) continue;
var same = prefs.len === p.len && prefs.ambig === p.ambig && prefs.each === p.each;
if (same) {
for (var c = 0; c < CLASSES.length; c++) {
var id = CLASSES[c].id;
var want = p.sets.indexOf(id) !== -1;
if (!!prefs.sets[id] !== want) { same = false; break; }
}
}
btns[i].classList.toggle('is-active', same);
}
}
function syncAutoTag() {
var tag = $('autoTag');
tag.classList.toggle('is-live', prefs.autoCopy);
$('autoTagText').textContent = prefs.autoCopy ? '自动复制已开启' : '自动复制已关闭';
}
/* ==========================================================
持久化
========================================================== */
function persist() {
try {
localStorage.setItem(KEY_PREFS, JSON.stringify({
len: prefs.len,
sets: prefs.sets,
ambig: prefs.ambig,
each: prefs.each,
autoCopy: prefs.autoCopy,
exclude: prefs.exclude
}));
} catch (e) {}
}
function restore() {
var raw;
try { raw = localStorage.getItem(KEY_PREFS); } catch (e) { return; }
if (!raw) return;
var d;
try { d = JSON.parse(raw); } catch (e) { return; }
if (!d || typeof d !== 'object') return;
if (typeof d.len === 'number' && isFinite(d.len)) {
prefs.len = Math.min(MAX_LEN, Math.max(1, Math.round(d.len)));
}
if (d.sets && typeof d.sets === 'object') {
for (var i = 0; i < CLASSES.length; i++) {
var id = CLASSES[i].id;
if (typeof d.sets[id] === 'boolean') prefs.sets[id] = d.sets[id];
}
}
if (typeof d.ambig === 'boolean') prefs.ambig = d.ambig;
if (typeof d.each === 'boolean') prefs.each = d.each;
if (typeof d.autoCopy === 'boolean') prefs.autoCopy = d.autoCopy;
if (typeof d.exclude === 'string') prefs.exclude = d.exclude.slice(0, 80);
var any = false;
for (var k = 0; k < CLASSES.length; k++) if (prefs.sets[CLASSES[k].id]) any = true;
if (!any) prefs.sets.lower = true;
}
/* ==========================================================
主题
========================================================== */
function currentTheme() {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
}
function setTheme(t) {
document.documentElement.setAttribute('data-theme', t);
try { localStorage.setItem(KEY_THEME, t); } catch (e) {}
var meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', t === 'light' ? '#f4f5f8' : '#0a0b0f');
}
/* ==========================================================
事件绑定
========================================================== */
function bind() {
$('themeBtn').addEventListener('click', function () {
setTheme(currentTheme() === 'dark' ? 'light' : 'dark');
});
$('len').addEventListener('input', function () {
prefs.len = parseInt(this.value, 10) || 1;
onControlsChanged(false);
});
$('optAmbig').addEventListener('change', function () {
prefs.ambig = this.checked;
onControlsChanged(false);
});
$('optEach').addEventListener('change', function () {
prefs.each = this.checked;
onControlsChanged(false);
});
$('optCopy').addEventListener('change', function () {
prefs.autoCopy = this.checked;
syncAutoTag();
persist();
});
var ex = $('exclude');
ex.addEventListener('input', function () {
prefs.exclude = this.value;
onControlsChanged(false);
});
$('allBtn').addEventListener('click', function () {
var all = true;
for (var i = 0; i < CLASSES.length; i++) if (!prefs.sets[CLASSES[i].id]) all = false;
var next = !all;
for (var k = 0; k < CLASSES.length; k++) prefs.sets[CLASSES[k].id] = next;
syncChips();
onControlsChanged(false);
this.textContent = next ? '清空' : '全选';
});
$('presets').addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('.preset') : null;
if (!btn) return;
var p = PRESETS[btn.getAttribute('data-preset')];
if (!p) return;
prefs.len = p.len;
prefs.ambig = p.ambig;
prefs.each = p.each;
for (var i = 0; i < CLASSES.length; i++) {
prefs.sets[CLASSES[i].id] = p.sets.indexOf(CLASSES[i].id) !== -1;
}
syncChips();
$('optAmbig').checked = prefs.ambig;
$('optEach').checked = prefs.each;
onControlsChanged(true);
});
$('copyBtn').addEventListener('click', function () {
copyNow();
});
$('regenBtn').addEventListener('click', function () {
onControlsChanged(true);
});
$('pw').addEventListener('click', function () {
if (!current) return;
copyNow();
});
$('pw').addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (current) copyNow();
}
});
$('batchBtn').addEventListener('click', renderCandidates);
$('clearBtn').addEventListener('click', function () {
try { localStorage.removeItem(KEY_HIST); } catch (e) {}
renderHistory();
toast('历史记录已清空');
});
/* 快捷键:空格=换一个,C=复制(输入框内不拦截) */
document.addEventListener('keydown', function (e) {
var tag = (e.target && e.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.code === 'Space') {
e.preventDefault();
onControlsChanged(true);
} else if (e.key === 'c' || e.key === 'C') {
e.preventDefault();
copyNow();
}
});
}
/* ==========================================================
启动
========================================================== */
function init() {
restore();
buildChips();
syncChips();
syncLengthBounds();
syncAutoTag();
updatePresetHighlight();
$('optAmbig').checked = prefs.ambig;
$('optEach').checked = prefs.each;
$('optCopy').checked = prefs.autoCopy;
$('exclude').value = prefs.exclude;
var all = true;
for (var i = 0; i < CLASSES.length; i++) if (!prefs.sets[CLASSES[i].id]) all = false;
$('allBtn').textContent = all ? '清空' : '全选';
bind();
/* 首屏只生成,不自动复制:避免在没有用户手势时触发剪贴板策略告警 */
regenerate();
renderHistory();
var meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', currentTheme() === 'light' ? '#f4f5f8' : '#0a0b0f');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
</body>
</html>