单文件 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 条反向注入全部命中。
1852 lines
52 KiB
HTML
1852 lines
52 KiB
HTML
<!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> |