|
|
GitHub:https://github.com/IIIStudio/IIIPasswords
CNB:https://cnb.cool/IIIStudio/HTML/IIIPasswords
演示:https://iiistudio.github.io/IIIPasswords/
测试密码数据:https://iiistudio.github.io/IIIPasswords/passwords.json
导入密码数据是加密的密码是123
密码管理器(单页版)一个纯前端的本地密码管理器,基于 index.html 单文件实现,数据保存在浏览器 localStorage 中,无需后端。 使用说明- passwords.json 为测试数据,可用于导入测试。密码是123
- 小技巧,当在密码里面输入1-64位数字,可以直接生成对应长度的密码。
功能概览- 标签管理:创建、重命名、删除、置顶标签,拖拽排序
- 账户管理:新增、编辑、删除、置顶账户;支持按标签内拖拽排序
- 搜索过滤:按名称/站点/用户名实时过滤
- 详情查看:点击账户查看站点、用户名、密码、备注等
- 密码可见切换:详情页与编辑/新增弹窗均支持小眼睛显示/隐藏
- 随机密码生成:支持“字母+数字”或“含符号”两种生成方式,可输入数字指定长度(1-64)
- 导入/导出:
- 导出普通 JSON(AES=no)
- 导出 AES-GCM 加密 JSON(AES=yes,PBKDF2-SHA256 派生,支持盐与迭代次数)
- 导入普通或加密 JSON(加密需输入密码)
- 置顶标记:账户可置顶,并在列表中优先显示
- 上下文菜单:右键账户/标签可进行置顶、编辑、删除等操作
- 移动端视图:标签/账户/详情三栏在小屏幕自动切换;提供“显示标签”和“返回”按钮
- 仅中栏滚动:当账户过多时,滚动条只出现在“账户列表”区域(样式为细线)
使用说明- 打开 mima/index.html 即可使用(双击或浏览器打开)。
- 新增标签:
- 新增账户:
- 中栏“新增”,填写名称/站点/用户名/密码/备注,并选择标签;可使用随机密码按钮。
- 搜索:
- 查看/编辑:
- 置顶与排序:
- 右键账户选择“置顶/取消置顶”;
- 在具体标签视图下,可拖拽账户改变排序。
- 导出:
- 右下角“下载”按钮导出。
- 在“设置”中开启 AES 加密并设置密码后,可导出加密数据。
- 导入:
- 右下角“上传”按钮选择 JSON 导入;导入将覆盖当前数据。
- 若文件为加密格式(AES": "yes"),会提示输入解密密码。
- 测试数据:
- 已提供 mima/test-accounts.json(15 条账户,分布“测试/社交/工作”)。
- 导入步骤:点击“上传”→选择该文件→确认覆盖。
界面与滚动说明- 布局为三栏:标签(左)、账户(中)、详情(右)。
- 仅“账户列表”区域可滚动:
- .app 使用 overflow:hidden,.panel 使用 min-height:0;
- .accounts 使用 flex:1; min-height:0; overflow:auto;
- 滚动条样式(细线)仅在 .accounts 区域生效:
- Firefox:scrollbar-width: thin; scrollbar-color: var(--border) transparent
- WebKit:::-webkit-scrollbar { width:4px } 等
- 若仍出现浏览器右侧滚动,通常是弹窗/上下文菜单在特定状态下撑高页面,关闭后即可恢复;正常列表场景下滚动只在中栏出现。
设置与安全- 设置入口:左下角“设置”悬浮按钮。
- AES 加密导出:
- 算法:AES-GCM(12 字节随机 IV)
- 密钥派生:PBKDF2-SHA256(默认 100000 次迭代,随机盐)
- 加密字段:用户名与密码(其余元数据明文,便于标签/排序)
- 注意:这是纯前端本地应用,安全性依赖浏览器与本机环境。请勿在不可信设备上使用。
数据持久化与结构- 所有数据保存在浏览器 localStorage:
- pm_data: 业务数据(标签、账户、置顶、排序等)
- pm_ui: UI 状态(选中的标签/账户)
- pm_settings: 设置(AES 开关与密码)
- 导出普通 JSON结构示例:
[XHTML] 纯文本查看 复制代码 {
"AES": "no",
"tags": [{ "tag": "社交", "accounts": [ { "id": "acc_xxx", "name": "Twitter", ... } ] }],
"pinnedIds": ["acc_xxx"],
"tagOrder": ["社交", "工作"],
"accountOrderByTag": { "社交": ["acc_xxx", "acc_yyy"] }
}
[HTML] 纯文本查看 复制代码 <!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<title>IIIPasswords 密码管理器</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<style>
:root{--bg:#0f1115;--panel:#151924;--panel2:#1b2130;--text:#e6eaf2;--muted:#a9b2c7;--brand:#4c8df5;--brand-weak:rgba(76,141,245,.15);--danger:#e34f4f;--ok:#37c485;--border:#253048;--hover:#222a3b;--focus:#2b3650}
*{box-sizing:border-box}
html,body{height:100%;margin:0;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,"PingFang SC","Microsoft Yahei",sans-serif}
.app{display:grid;grid-template-columns:240px 320px 1fr;height:100vh;gap:1px;background:var(--border);overflow:hidden}
.panel{background:var(--panel);display:flex;flex-direction:column;min-width:0;min-height:0}
.panel header{padding:12px;border-bottom:1px solid var(--border);background:var(--panel2);display:flex;align-items:center;gap:8px}
.panel header h2{font-size:13px;margin:0;color:var(--muted);letter-spacing:.5px;text-transform:uppercase}
.accounts{flex:1;min-height:0;overflow:auto}
/* 账户区域滚动条为细线,仅显示于账户区域 */
.accounts{scrollbar-width:thin;scrollbar-color:var(--border) transparent}
.accounts::-webkit-scrollbar{width:4px}
.accounts::-webkit-scrollbar-track{background:transparent}
.accounts::-webkit-scrollbar-thumb{background-color:var(--border);border-radius:2px}
.tags{overflow:hidden}
.tag-item,.account-item{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border);cursor:pointer}
.tag-item:hover,.account-item:hover{background:var(--hover)}
.tag-item.active,.account-item.active{background:var(--focus)}
.tag-pill{font-size:12px;color:var(--text);background:var(--brand-weak);border:1px solid var(--border);padding:2px 8px;border-radius:999px}
.badge{margin-left:8px;min-width:24px;height:24px;display:flex;align-items:center;justify-content:center;border-radius:50%;border:1px solid var(--border);background:#0e1422;color:#494949;font-weight:600}
.pin-badge{margin-left:8px;padding:2px 6px;border-radius:999px;border:1px solid var(--border);background:#17305a;color:var(--text);font-size:12px}
.account-item.pinned{border-left:2px solid var(--brand)}
.muted{color:var(--muted)}
.grow{flex:1;min-width:0}
.search{display:flex;gap:8px;padding:8px 12px;border-bottom:1px solid var(--border);background:var(--panel2)}
.search input{flex:1;padding:8px 10px;border-radius:8px;border:1px solid var(--border);background:#0e1422;color:var(--text);outline:none}
.search input:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-weak)}
.btn{padding:8px 10px;border-radius:8px;border:1px solid var(--border);background:#0e1422;color:var(--text);cursor:pointer}
.btn:hover{background:var(--hover)}
.btn.brand{border-color:#326adf;background:#17305a}
.icon-btn{padding:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;border:none;background:transparent}
.btn.icon-btn:hover{background:transparent}
#mobileTagsBtn,#mobileBackBtn{display:none}
.details{padding:12px;overflow:hidden;display:flex;flex-direction:column;gap:12px}
.detail-row{display:grid;grid-template-columns:140px 1fr;gap:10px;align-items:center}
.detail-row .label{color:var(--muted)}
.tag-list{display:flex;gap:6px;flex-wrap:wrap}
.details table{width:100%;border-collapse:collapse}
.details th{width:140px;text-align:left;color:var(--muted);padding:8px 10px;border-bottom:1px solid var(--border)}
.details td{padding:8px 10px;border-bottom:1px solid var(--border)}
.details tr:last-child th,.details tr:last-child td{border-bottom:none}
.divider{border-top:1px solid var(--border);margin:4px 0}
.empty{color:var(--muted);padding:16px;text-align:center}
.header-actions{margin-left:auto;display:flex;gap:8px}
a.link{color:var(--brand);text-decoration:none;font-weight:600}
a.link:hover{text-decoration:none}
[url=home.php?mod=space&uid=945662]@media[/url] (max-width:980px){.app{grid-template-columns:180px 260px 1fr}}
@media (max-width:720px){
.app{grid-template-columns:1fr}
/* 手机默认只显示账户面板 */
#tags-panel,#details-panel{display:none}
/* 切换到标签面板 */
body.mobile-show-tags #tags-panel{display:block}
body.mobile-show-tags #accounts-panel{display:none}
/* 切换到详细面板 */
body.mobile-show-details #details-panel{display:block}
body.mobile-show-details #accounts-panel{display:none}
/* 手机下显示专用按钮 */
#mobileTagsBtn,#mobileBackBtn{display:flex}
}
/* Modal */
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000}
.modal{width:560px;max-width:90vw;background:#121725;border:1px solid var(--border);border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.5);overflow:hidden}
.modal header{background:var(--panel2);padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center}
.modal header h3{margin:0;font-size:16px}
.modal .body{padding:14px 16px;display:flex;flex-direction:column;gap:12px}
.modal .footer{padding:12px 16px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;background:#0f1422}
.input{width:100%;padding:8px 10px;border-radius:8px;border:1px solid var(--border);background:#0e1422;color:var(--text);outline:none}
.input:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-weak)}
.form-row{display:grid;grid-template-columns:140px 1fr;gap:10px;align-items:center}
.chips{display:flex;gap:8px;flex-wrap:wrap}
.chip{border:1px solid var(--border);background:#0e1422;color:var(--text);padding:6px 10px;border-radius:999px;cursor:pointer}
.chip.active{background:#17305a;border-color:#326adf}
.tag-item.drag-over{outline:1px dashed var(--brand);background:var(--hover)}
.account-item.drag-over{outline:1px dashed var(--brand);background:var(--hover)}
.fab{position:fixed;left:12px;bottom:12px;z-index:900;padding:10px 12px;border-radius:999px;border:1px solid var(--border);background:#0e1422;color:var(--text);box-shadow:0 6px 24px rgba(0,0,0,.35)}
/* Context menu */
.context-menu{position:fixed;z-index:1100;background:#121725;border:1px solid var(--border);border-radius:8px;box-shadow:0 12px 32px rgba(0,0,0,.45);min-width:140px;overflow:hidden}
.context-menu .item{padding:8px 12px;cursor:pointer}
.context-menu .item:hover{background:var(--hover)}
</style>
</head>
<body>
<div class="app">
<section class="panel" id="tags-panel">
<header><h2>标签</h2><div class="header-actions"><button class="btn" id="btnAddTag">新增</button></div></header>
<div class="tags" id="tagsList"></div>
</section>
<section class="panel" id="accounts-panel">
<header>
<button class="btn icon-btn" id="mobileTagsBtn" title="显示标签">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M4 6h16M4 12h16M4 18h16" stroke="#a9b2c7" stroke-width="1.8" stroke-linecap="round"/></svg>
</button>
<h2>IIIPasswords</h2>
<div class="header-actions"><button class="btn" id="btnAddAccount">新增</button></div>
</header>
<div class="search"><input type="text" id="searchInput" placeholder="搜索账户名称、站点、用户名…"/><button class="btn" id="clearSearch">清空</button></div>
<div class="accounts" id="accountsList"></div>
</section>
<section class="panel" id="details-panel">
<header>
<button class="btn icon-btn" id="mobileBackBtn" title="返回">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M15 6l-6 6 6 6" stroke="#a9b2c7" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<h2>详细</h2>
<div class="header-actions"><button class="btn" id="editBtn">编辑</button></div>
</header>
<div class="details" id="details"><div class="empty">请选择左侧标签并在中栏选择一个账户查看详情</div></div>
</section>
</div>
<button class="fab btn" id="settingsFab">设置</button>
<button class="fab btn" id="uploadFab" style="left:92px;">上传</button>
<button class="fab btn" id="downloadFab" style="left:172px;">下载</button>
<script>
// 示例数据
const data=[];/*{tag:"社交",accounts:[{id:"acc_tw",name:"Twitter",site:"https://twitter.com",username:"alice",password:"p@ssw0rd",note:"启用双重认证",tags:["社交"]},{id:"acc_fb",name:"Facebook",site:"https://facebook.com",username:"alice.fb",password:"F@ceB00k!",note:"",tags:["社交"]}]},{tag:"工作",accounts:[{id:"acc_github",name:"GitHub",site:"https://github.com",username:"alice-dev",password:"ghp_******",note:"个人访问令牌保存在密码管理器里",tags:["工作","开发"]},{id:"acc_jira",name:"Jira",site:"https://jira.company.com",username:"alice",password:"Company#Jira",note:"公司VPN下访问",tags:["工作"]}]},{tag:"金融",accounts:[{id:"acc_bank",name:"招商银行",site:"https://cmbchina.com",username:"133****8888",password:"不可明文记录",note:"手机令牌",tags:["金融"]}]}];*/
/* 示例数据已禁用,使用 localStorage 持久化 */
// 索引
const tagIndex=new Map(),accountIndex=new Map();
function initFromStorage(){
try{
const raw=localStorage.getItem("pm_data");
if(raw){
importJSONObj(JSON.parse(raw));
}
}catch(e){}
}
// 状态
let state={selectedTag:"__ALL__",selectedAccountId:null,search:"",tagOrder:[],pinnedIds:new Set(),accountOrderByTag:{}};
initFromStorage();
// DOM
const tagsList=document.getElementById("tagsList");
const accountsList=document.getElementById("accountsList");
const detailsRoot=document.getElementById("details");
const searchInput=document.getElementById("searchInput");
// 渲染标签
function renderTags(){
const keys=Array.from(tagIndex.keys());
let allTags=(state.tagOrder&&state.tagOrder.length)?[...state.tagOrder]:keys.slice();
keys.forEach(k=>{if(!allTags.includes(k)) allTags.push(k);});
// 计算“全部”数量(按账户唯一 id 去重)
let allCount=0;{const ids=new Set();tagIndex.forEach(list=>list.forEach(a=>ids.add(a.id)));allCount=ids.size;}
tagsList.innerHTML="";
const allItem=document.createElement("div");
allItem.className="tag-item"+(state.selectedTag==="__ALL__"?" active":"");
allItem.innerHTML=`
<span class="tag-pill">全部</span>
<span class="grow"></span>
<span class="badge">${allCount}</span>
`;
allItem.onclick=()=>{state.selectedTag="__ALL__";state.selectedAccountId=null;saveUI();renderAccounts();renderDetails();highlightTags();if(isMobile()){document.body.classList.remove("mobile-show-tags");}};
tagsList.appendChild(allItem);
allTags.forEach(tag=>{
const el=document.createElement("div");
el.className="tag-item"+(state.selectedTag===tag?" active":"");
const count=(tagIndex.get(tag)||[]).length;
el.innerHTML=`
<span class="tag-pill">${tag}</span>
<span class="grow"></span>
<span class="badge">${count}</span>
`;
// 拖拽事件绑定
el.draggable=true;
el.dataset.tag=tag;
el.addEventListener("dragstart",(e)=>{e.dataTransfer.setData("text/plain",tag);e.dataTransfer.effectAllowed="move";});
el.addEventListener("dragover",(e)=>{e.preventDefault();el.classList.add("drag-over");e.dataTransfer.dropEffect="move";});
el.addEventListener("dragleave",()=>{el.classList.remove("drag-over");});
el.addEventListener("drop",(e)=>{e.preventDefault();el.classList.remove("drag-over");const fromTag=e.dataTransfer.getData("text/plain");reorderTag(fromTag,tag);});
el.onclick=()=>{state.selectedTag=tag;state.selectedAccountId=null;saveUI();renderAccounts();renderDetails();highlightTags();if(isMobile()){document.body.classList.remove("mobile-show-tags");}};
// 右键菜单:删除/修改/置顶
el.oncontextmenu=(e)=>{
e.preventDefault();
const text=tag;
// “全部”不展示菜单
if(text==="全部")return;
closeContextMenu();
const menu=document.createElement("div");menu.className="context-menu";
const mkItem=(txt,fn)=>{const d=document.createElement("div");d.className="item";d.textContent=txt;d.onclick=()=>{fn();closeContextMenu();};return d;};
menu.appendChild(mkItem("删除",()=>deleteTag(text)));
menu.appendChild(mkItem("修改",()=>renameTag(text)));
menu.appendChild(mkItem("置顶",()=>pinTag(text)));
document.body.appendChild(menu);
const x=e.pageX,y=e.pageY;menu.style.left=Math.max(8,x-4)+"px";menu.style.top=Math.max(8,y-4)+"px";
setTimeout(()=>{document.addEventListener("click",closeContextMenu,{once:true});},0);
};
tagsList.appendChild(el);
});
}
function highlightTags(){
[...tagsList.children].forEach(el=>{
el.classList.remove("active");
const text=el.querySelector(".tag-pill")?.textContent;
const isAll=text==="全部";
if((isAll&&state.selectedTag==="__ALL__")||text===state.selectedTag)el.classList.add("active");
});
}
// 标签排序
function reorderTag(fromTag,toTag){
if(fromTag==="全部"||toTag==="全部")return;
const keys=Array.from(tagIndex.keys());
let base=(state.tagOrder&&state.tagOrder.length)?[...state.tagOrder]:keys.slice();
keys.forEach(k=>{if(!base.includes(k)) base.push(k);});
const fromIdx=base.indexOf(fromTag),toIdx=base.indexOf(toTag);
if(fromIdx<0||toIdx<0||fromIdx===toIdx)return;
const [moved]=base.splice(fromIdx,1);
base.splice(toIdx,0,moved);
state.tagOrder=base;
renderTags();
localStorage.setItem("pm_data", JSON.stringify(buildExportData()));
}
// 过滤账户
function getFilteredAccounts(){
let accounts=[];
if(!state.selectedTag||state.selectedTag==="__ALL__"){tagIndex.forEach(list=>accounts=accounts.concat(list));}
else{accounts=tagIndex.get(state.selectedTag)||[];}
if(state.search){
const q=state.search.toLowerCase();
accounts=accounts.filter(a=>a.name.toLowerCase().includes(q)||(a.site||"").toLowerCase().includes(q)||(a.username||"").toLowerCase().includes(q));
}
const seen=new Set(),uniq=[];
for(const a of accounts){if(!seen.has(a.id)){seen.add(a.id);uniq.push(a);}}
uniq.sort((a,b)=>{
const pa=state.pinnedIds?.has(a.id)?-1:0;
const pb=state.pinnedIds?.has(b.id)?-1:0;
if(pa!==pb) return pa-pb;
return a.name.localeCompare(b.name,"zh-CN");
});
return uniq;
}
// 渲染账户
function renderAccounts(){
const accounts=getFilteredAccounts();
// 在具体标签视图下按自定义顺序排序(置顶优先)
if(state.selectedTag && state.selectedTag!=="__ALL__"){
const order=Array.isArray(state.accountOrderByTag?.[state.selectedTag])?state.accountOrderByTag[state.selectedTag]:null;
if(order){
const pos=new Map(order.map((id,idx)=>[id,idx]));
accounts.sort((a,b)=>{
const ap=state.pinnedIds?.has(a.id), bp=state.pinnedIds?.has(b.id);
if(ap!==bp) return ap?-1:1;
const ai=pos.has(a.id)?pos.get(a.id):Number.MAX_SAFE_INTEGER;
const bi=pos.has(b.id)?pos.get(b.id):Number.MAX_SAFE_INTEGER;
if(ai!==bi) return ai-bi;
return a.name.localeCompare(b.name,"zh-CN");
});
}
}
accountsList.innerHTML="";
if(accounts.length===0){accountsList.innerHTML='<div class="empty">暂无账户</div>';return;}
accounts.forEach(acc=>{
const el=document.createElement("div");
const pinned=state.pinnedIds?.has(acc.id);
el.className="account-item"+(pinned?" pinned":"")+(state.selectedAccountId===acc.id?" active":"");
const siteHost=tryGetHost(acc.site);
el.innerHTML=`
<div class="grow">
<div>${acc.name}</div>
<div class="muted" title="${acc.site}">${siteHost||acc.site||""}</div>
</div>
<div class="muted">${(acc.tags||[]).join(" · ")}${pinned?'<span class="pin-badge">置顶</span>':''}</div>
`;
el.onclick=()=>{state.selectedAccountId=acc.id;saveUI();renderAccounts();renderDetails();if(isMobile()){document.body.classList.add("mobile-show-details");document.body.classList.remove("mobile-show-tags");}};
// 拖拽(仅在具体标签视图下启用)
el.draggable=state.selectedTag && state.selectedTag!=="__ALL__";
el.dataset.id=acc.id;
el.addEventListener("dragstart",(e)=>{e.dataTransfer.setData("text/plain",acc.id);e.dataTransfer.effectAllowed="move";});
el.addEventListener("dragover",(e)=>{if(!el.draggable)return; e.preventDefault();el.classList.add("drag-over");e.dataTransfer.dropEffect="move";});
el.addEventListener("dragleave",()=>{el.classList.remove("drag-over");});
el.addEventListener("drop",(e)=>{ if(!el.draggable)return; e.preventDefault();el.classList.remove("drag-over"); const fromId=e.dataTransfer.getData("text/plain");const toId=acc.id; reorderAccount(state.selectedTag, fromId, toId); });
// 右键菜单:置顶/编辑/删除
el.oncontextmenu=(e)=>{
e.preventDefault();
closeContextMenu();
const menu=document.createElement("div");menu.className="context-menu";
const mkItem=(txt,fn)=>{const d=document.createElement("div");d.className="item";d.textContent=txt;d.onclick=()=>{fn();closeContextMenu();};return d;};
const isPinned=state.pinnedIds?.has(acc.id);
menu.appendChild(mkItem(isPinned?"取消置顶":"置顶",()=>{
if(isPinned){state.pinnedIds.delete(acc.id);toast("已取消置顶");}
else{state.pinnedIds.add(acc.id);toast("已置顶");}
renderAccounts();
localStorage.setItem("pm_data", JSON.stringify(buildExportData()));
}));
menu.appendChild(mkItem("编辑",()=>{state.selectedAccountId=acc.id;document.getElementById("editBtn").click();}));
menu.appendChild(mkItem("删除",()=>{deleteAccount(acc.id);}));
document.body.appendChild(menu);
const x=e.pageX,y=e.pageY;menu.style.left=Math.max(8,x-4)+"px";menu.style.top=Math.max(8,y-4)+"px";
setTimeout(()=>{document.addEventListener("click",closeContextMenu,{once:true});},0);
};
accountsList.appendChild(el);
});
}
// 渲染详情(只读)
function renderDetails(){
detailsRoot.innerHTML="";
const acc=accountIndex.get(state.selectedAccountId);
if(!acc){detailsRoot.innerHTML='<div class="empty">未选择账户</div>';return;}
const table=document.createElement("table");
const tbody=document.createElement("tbody");
// 名称
tbody.appendChild(trRow("名称", acc.name));
// 网站/地址
const siteCell=document.createElement("div");
siteCell.style.display="flex";siteCell.style.gap="8px";siteCell.style.alignItems="center";
const link=document.createElement("a");
link.href=acc.site||"#";
link.textContent=acc.site||"";
link.target="_blank";link.rel="noopener noreferrer";link.className="link";
siteCell.appendChild(link);
tbody.appendChild(trRow("网站/地址", siteCell));
// 用户名
const userCell=document.createElement("div");
userCell.style.display="flex";userCell.style.gap="8px";userCell.style.alignItems="center";
const userText=document.createElement("span");userText.textContent=acc.username||"";userText.style.cursor="pointer";userText.title="点击复制用户名";
userText.onclick=()=>copyText(acc.username);
userCell.appendChild(userText);
tbody.appendChild(trRow("用户名", userCell));
// 密码
const passCell=document.createElement("div");
passCell.style.display="flex";passCell.style.gap="8px";passCell.style.alignItems="center";
const passText=document.createElement("span");passText.textContent="●●●●●●●";passText.style.cursor="pointer";passText.title="点击复制密码";
// 小眼睛图标按钮(SVG,支持开/闭眼切换)
const showBtn=document.createElement("button");showBtn.className="btn icon-btn";showBtn.title="显示/隐藏密码";
const svgEyeOpen='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 5C7 5 3.27 8.11 2 12c1.27 3.89 5 7 10 7s8.73-3.11 10-7c-1.27-3.89-5-7-10-7Z" stroke="#a9b2c7" stroke-width="1.5" fill="none"/><circle cx="12" cy="12" r="3" stroke="#a9b2c7" stroke-width="1.5" fill="none"/></svg>';
const svgEyeClosed='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M2 12c1.5-4 5.3-7 10-7 2.1 0 4 .5 5.7 1.4M22 12c-1.27 3.89-5 7-10 7-2.1 0-4-.5-5.7-1.4" stroke="#a9b2c7" stroke-width="1.5"/><path d="M15 9l-6 6" stroke="#a9b2c7" stroke-width="1.5"/></svg>';
showBtn.innerHTML=svgEyeOpen;
showBtn.onclick=()=>{
const masked=passText.textContent==="●●●●●●●";
if(masked){passText.textContent=acc.password;showBtn.innerHTML=svgEyeClosed;}
else{passText.textContent="●●●●●●●";showBtn.innerHTML=svgEyeOpen;}
};
passText.onclick=()=>copyText(acc.password);
passCell.appendChild(passText);passCell.appendChild(showBtn);
tbody.appendChild(trRow("密码", passCell));
// 标签
const tagWrap=document.createElement("div");tagWrap.className="tag-list";
(acc.tags||[]).forEach(t=>{const pill=document.createElement("span");pill.className="tag-pill";pill.textContent=t;tagWrap.appendChild(pill);});
tbody.appendChild(trRow("标签", tagWrap));
// 备注
tbody.appendChild(trRow("备注", acc.note||""));
table.appendChild(tbody);
detailsRoot.appendChild(table);
}
// 表格行构建
function trRow(label, valueNodeOrText){
const tr=document.createElement("tr");
const th=document.createElement("th");th.textContent=label;
const td=document.createElement("td");
if(typeof valueNodeOrText==="string"||valueNodeOrText instanceof String){td.textContent=valueNodeOrText;}
else{td.appendChild(valueNodeOrText);}
tr.appendChild(th);tr.appendChild(td);
return tr;
}
function detailRow(label,val,actionBtn){
const row=document.createElement("div");row.className="detail-row";
const l=document.createElement("div");l.className="label";l.textContent=label;
const r=document.createElement("div");
if(typeof val==="string"||val instanceof String){r.textContent=val;}
else{r.appendChild(val);}
row.appendChild(l);row.appendChild(r);
if(actionBtn){const a=document.createElement("div");a.appendChild(actionBtn);row.appendChild(a);}
return row;
}
// Modal 基础
function showModal(title, buildContent, onSave, onCancel){
const backdrop=document.createElement("div");backdrop.className="modal-backdrop";
const modal=document.createElement("div");modal.className="modal";
const header=document.createElement("header");const h3=document.createElement("h3");h3.textContent=title;header.appendChild(h3);
const body=document.createElement("div");body.className="body";
const footer=document.createElement("div");footer.className="footer";
const cancelBtn=button("取消",()=>{cleanupKeys();onCancel?.();document.body.removeChild(backdrop);});
const saveBtn=button("保存",()=>{const ok=onSave?.();if(ok!==false){cleanupKeys();document.body.removeChild(backdrop);}});
saveBtn.classList.add("brand");
footer.appendChild(cancelBtn);footer.appendChild(saveBtn);
modal.appendChild(header);modal.appendChild(body);modal.appendChild(footer);
backdrop.appendChild(modal);document.body.appendChild(backdrop);
buildContent(body);
// 键盘交互:Enter=保存,Esc=取消
const onKey=(e)=>{
if(e.key==="Escape"){
e.preventDefault();
cancelBtn.click();
}else if(e.key==="Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey){
e.preventDefault();
saveBtn.click();
}
};
const cleanupKeys=()=>{document.removeEventListener("keydown", onKey);};
document.addEventListener("keydown", onKey);
return {backdrop,body,saveBtn,cancelBtn};
}
/* 居中弹窗:Alert/Confirm/Prompt(先启用 Alert) */
function modalAlert(message,title="提示"){
const dlg=showModal(title,(body)=>{
const p=document.createElement("div");p.textContent=message;body.appendChild(p);
},()=>true);
dlg.cancelBtn.style.display="none";
}
function modalConfirm(message,onYes,title="确认"){
showModal(title,(body)=>{
const p=document.createElement("div");p.textContent=message;body.appendChild(p);
},()=>{
try{onYes?.();}catch(e){}
return true;
});
}
function modalPrompt(message,def="",onSubmit,title="输入"){
// 预留:后续把 prompt 改为此居中弹窗并重构为异步回调
const v=prompt(message,def||"");
if(v!=null){ try{onSubmit?.(v);}catch(e){} }
}
// 辅助 UI
function input(label,opts){const row=document.createElement("div");row.className="form-row";
const l=document.createElement("label");l.className="label";l.textContent=label;
const i=document.createElement("input");i.className="input";Object.assign(i,opts||{});
row.appendChild(l);row.appendChild(i);return {row,input:i};}
function textarea(label,opts){const row=document.createElement("div");row.className="form-row";
const l=document.createElement("label");l.className="label";l.textContent=label;
const ta=document.createElement("textarea");ta.className="input";ta.rows=opts?.rows||4;ta.value=opts?.value||"";row.appendChild(l);row.appendChild(ta);return {row,textarea:ta};}
function tagChips(selected=new Set()){
const row=document.createElement("div");row.className="form-row";
const l=document.createElement("label");l.className="label";l.textContent="标签";
const chips=document.createElement("div");chips.className="chips";
Array.from(tagIndex.keys()).forEach(tag=>{
const chip=document.createElement("span");chip.className="chip";chip.textContent=tag;
if(selected.has(tag))chip.classList.add("active");
chip.onclick=()=>{if(chip.classList.toggle("active"))selected.add(tag);else selected.delete(tag);};
chips.appendChild(chip);
});
row.appendChild(l);row.appendChild(chips);
return {row,getSelected:()=>Array.from(selected)};
}
// 业务:新增标签(弹窗)
document.getElementById("btnAddTag").onclick=()=>{
showModal("新增标签",(body)=>{
const {row,input:i}=input("标签名称",{placeholder:"例如:工作/社交/金融"});
body.appendChild(row);
},function onSave(){
const name=document.querySelector(".modal .body .form-row input")?.value?.trim();
if(!name){modalAlert("请输入标签名称");return false;}
if(tagIndex.has(name)){modalAlert("标签已存在");return false;}
tagIndex.set(name,[]);
if(!state.tagOrder.includes(name)) state.tagOrder.push(name);
renderTags();localStorage.setItem("pm_data", JSON.stringify(buildExportData()));return true;
});
};
// 业务:新增账户(弹窗)
document.getElementById("btnAddAccount").onclick=()=>{
const preSelected=(state.selectedTag&&state.selectedTag!=="__ALL__")?new Set([state.selectedTag]):new Set();
showModal("新增账户",(body)=>{
const f1=input("名称",{placeholder:"例如:GitHub"});body.appendChild(f1.row);
const f2=input("网站/地址",{placeholder:"https://example.com"});body.appendChild(f2.row);
const f3=input("用户名",{placeholder:"用户名/邮箱/手机号"});body.appendChild(f3.row);
const f4=input("密码",{placeholder:"密码"});f4.input.type="password";body.appendChild(f4.row);
{
const cell=f4.row.children[1];
const wrap=document.createElement("div");
wrap.style.display="flex";wrap.style.gap="8px";wrap.style.alignItems="center";
cell.replaceWith(wrap);
// 左:输入框(内置小眼睛,支持开/闭眼切换)
const inputWrap=document.createElement("div");
inputWrap.style.position="relative";inputWrap.style.flex="1";
f4.input.style.width="100%";
f4.input.style.flex="1";
f4.input.type="password";
f4.input.style.paddingRight="34px"; // 预留小眼睛空间
inputWrap.appendChild(f4.input);
const eyeBtn=document.createElement("button");
eyeBtn.className="btn icon-btn";eyeBtn.title="显示/隐藏密码";
eyeBtn.style.position="absolute";eyeBtn.style.top="50%";eyeBtn.style.transform="translateY(-50%)";
eyeBtn.style.right="6px";
const svgEyeOpen='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 5C7 5 3.27 8.11 2 12c1.27 3.89 5 7 10 7s8.73-3.11 10-7c-1.27-3.89-5-7-10-7Z" stroke="#a9b2c7" stroke-width="1.5" fill="none"/><circle cx="12" cy="12" r="3" stroke="#a9b2c7" stroke-width="1.5" fill="none"/></svg>';
const svgEyeClosed='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M2 12c1.5-4 5.3-7 10-7 2.1 0 4 .5 5.7 1.4M22 12c-1.27 3.89-5 7-10 7-2.1 0-4-.5-5.7-1.4" stroke="#a9b2c7" stroke-width="1.5"/><path d="M15 9l-6 6" stroke="#a9b2c7" stroke-width="1.5"/></svg>';
eyeBtn.innerHTML=svgEyeOpen;
eyeBtn.onclick=()=>{
const isPwd=f4.input.type==="password";
f4.input.type=isPwd?"text":"password";
eyeBtn.innerHTML=isPwd?svgEyeClosed:svgEyeOpen;
};
inputWrap.appendChild(eyeBtn);
wrap.appendChild(inputWrap);
// 右:随机密码按钮
const btnGen1=button("随机(字母+数字)",()=>{const v=f4.input.value.trim();const n=Number(v);const len=(/^\d+$/.test(v)&&n>=1&&n<=64)?n:12;f4.input.value=randomPassword(len,false);});
const btnGen2=button("随机(含符号)",()=>{const v=f4.input.value.trim();const n=Number(v);const len=(/^\d+$/.test(v)&&n>=1&&n<=64)?n:12;f4.input.value=randomPassword(len,true);});
wrap.appendChild(btnGen1);wrap.appendChild(btnGen2);
}
const f5=textarea("备注",{rows:3,value:""});body.appendChild(f5.row);
const chips=tagChips(preSelected);body.appendChild(chips.row);
body.dataset.fields=JSON.stringify({}); // 占位
body._fields={f1,f2,f3,f4,f5,chips};
},function onSave(){
const {_fields}=document.querySelector(".modal .body");
const name=_fields.f1.input.value.trim();
if(!name){modalAlert("请输入名称");return false;}
const id="acc_"+Math.random().toString(36).slice(2,8);
const acc={id,name,site:_fields.f2.input.value.trim(),username:_fields.f3.input.value.trim(),password:_fields.f4.input.value,note:_fields.f5.textarea.value,tags:_fields.chips.getSelected()};
accountIndex.set(id,acc);
// 放入各标签
const tags=acc.tags.length?acc.tags:["未分组"];
tags.forEach(t=>{
if(!tagIndex.has(t)){
tagIndex.set(t,[]);
if(!state.tagOrder.includes(t)) state.tagOrder.push(t);
}
tagIndex.get(t).push(acc);
});
renderTags();state.selectedAccountId=id;renderAccounts();renderDetails();localStorage.setItem("pm_data", JSON.stringify(buildExportData()));return true;
});
};
// 编辑(弹窗)
document.getElementById("editBtn").onclick=()=>{
const acc=accountIndex.get(state.selectedAccountId);
if(!acc){modalAlert("请先选择账户");return;}
const selected=new Set(acc.tags||[]);
showModal("编辑账户",(body)=>{
const f1=input("名称",{value:acc.name});body.appendChild(f1.row);
const f2=input("网站/地址",{value:acc.site||""});body.appendChild(f2.row);
const f3=input("用户名",{value:acc.username||""});body.appendChild(f3.row);
const f4=input("密码",{value:acc.password||""});f4.input.type="password";body.appendChild(f4.row);
// 在密码输入右侧添加随机密码按钮(与输入同格右侧),并在输入内置小眼睛切换显示
{
const cell=f4.row.children[1];
const wrap=document.createElement("div");
wrap.style.display="flex";wrap.style.gap="8px";wrap.style.alignItems="center";
cell.replaceWith(wrap);
// 输入框容器,内嵌小眼睛
const inputWrap=document.createElement("div");
inputWrap.style.position="relative";inputWrap.style.flex="1";
f4.input.style.width="100%";
f4.input.style.flex="1";
f4.input.type="password";
f4.input.style.paddingRight="34px"; // 预留小眼睛空间
inputWrap.appendChild(f4.input);
const eyeBtn=document.createElement("button");
eyeBtn.className="btn icon-btn";eyeBtn.title="显示/隐藏密码";
eyeBtn.style.position="absolute";eyeBtn.style.top="50%";eyeBtn.style.transform="translateY(-50%)";
eyeBtn.style.right="6px";
const svgEyeOpen='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 5C7 5 3.27 8.11 2 12c1.27 3.89 5 7 10 7s8.73-3.11 10-7c-1.27-3.89-5-7-10-7Z" stroke="#a9b2c7" stroke-width="1.5" fill="none"/><circle cx="12" cy="12" r="3" stroke="#a9b2c7" stroke-width="1.5" fill="none"/></svg>';
const svgEyeClosed='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M2 12c1.5-4 5.3-7 10-7 2.1 0 4 .5 5.7 1.4M22 12c-1.27 3.89-5 7-10 7-2.1 0-4-.5-5.7-1.4" stroke="#a9b2c7" stroke-width="1.5"/><path d="M15 9l-6 6" stroke="#a9b2c7" stroke-width="1.5"/></svg>';
eyeBtn.innerHTML=svgEyeOpen;
eyeBtn.onclick=()=>{
const isPwd=f4.input.type==="password";
f4.input.type=isPwd?"text":"password";
eyeBtn.innerHTML=isPwd?svgEyeClosed:svgEyeOpen;
};
inputWrap.appendChild(eyeBtn);
wrap.appendChild(inputWrap);
// 随机密码按钮
const btnGen1=button("随机(字母+数字)",()=>{const v=f4.input.value.trim();const n=Number(v);const len=(/^\d+$/.test(v)&&n>=1&&n<=64)?n:12;f4.input.value=randomPassword(len,false);});
const btnGen2=button("随机(含符号)",()=>{const v=f4.input.value.trim();const n=Number(v);const len=(/^\d+$/.test(v)&&n>=1&&n<=64)?n:12;f4.input.value=randomPassword(len,true);});
wrap.appendChild(btnGen1);wrap.appendChild(btnGen2);
}
const f5=textarea("备注",{rows:3,value:acc.note||""});body.appendChild(f5.row);
const chips=tagChips(selected);body.appendChild(chips.row);
body._fields={f1,f2,f3,f4,f5,chips};
},function onSave(){
const {_fields}=document.querySelector(".modal .body");
const updated={...acc,
name:_fields.f1.input.value.trim(),
site:_fields.f2.input.value.trim(),
username:_fields.f3.input.value.trim(),
password:_fields.f4.input.value,
note:_fields.f5.textarea.value,
tags:_fields.chips.getSelected()
};
accountIndex.set(acc.id,updated);
// 同步到各标签列表:先从所有列表移除,再按新标签添加
tagIndex.forEach((list,tag)=>{
const i=list.findIndex(x=>x.id===acc.id);
if(i>=0)list.splice(i,1);
});
const finalTags=updated.tags.length?updated.tags:["未分组"];
finalTags.forEach(t=>{
if(!tagIndex.has(t))tagIndex.set(t,[]);
tagIndex.get(t).push(updated);
});
renderTags();renderAccounts();renderDetails();localStorage.setItem("pm_data", JSON.stringify(buildExportData()));return true;
});
};
/* UI状态持久化 */
function saveUI(){try{localStorage.setItem("pm_ui", JSON.stringify({selectedTag: state.selectedTag, selectedAccountId: state.selectedAccountId}));}catch(e){}}
function loadUI(){try{const raw=localStorage.getItem("pm_ui");if(raw){const ui=JSON.parse(raw);if(ui.selectedTag && (ui.selectedTag==="__ALL__" || tagIndex.has(ui.selectedTag))) state.selectedTag=ui.selectedTag; if(ui.selectedAccountId && accountIndex.has(ui.selectedAccountId)) state.selectedAccountId=ui.selectedAccountId;}}catch(e){}}
/* 手机模式辅助 */
function isMobile(){return window.matchMedia && window.matchMedia("(max-width:720px)").matches;}
function updateMobileView(){
if(!isMobile()){
document.body.classList.remove("mobile-show-tags","mobile-show-details");
return;
}
// 默认只显示账户(不加类即可显示账户)
}
/* 设置持久化 */
let settings={aesEnabled:false,aesPassword:""};
function saveSettings(){try{localStorage.setItem("pm_settings", JSON.stringify(settings));}catch(e){}}
function loadSettings(){try{const raw=localStorage.getItem("pm_settings");if(raw){const s=JSON.parse(raw);settings.aesEnabled=!!s.aesEnabled;settings.aesPassword=s.aesPassword||"";}}catch(e){}}
// 工具
function button(text,onClick,kind){const b=document.createElement("button");b.className="btn"+(kind?" "+kind:"");b.textContent=text;b.onclick=onClick;return b;}
function openSite(url){if(!url){modalAlert("没有站点信息");return;}try{window.open(url,"_blank","noopener,noreferrer");}catch(e){}}
function copyText(text){
if(!text)return;
navigator.clipboard?.writeText(text).then(()=>{toast("已复制到剪贴板");}).catch(()=>{
const ta=document.createElement("textarea");ta.value=text;document.body.appendChild(ta);ta.select();try{document.execCommand("copy");toast("已复制到剪贴板");}catch(e){}document.body.removeChild(ta);
});
}
function toast(msg){
const div=document.createElement("div");div.textContent=msg;div.style.position="fixed";div.style.bottom="16px";div.style.right="16px";
div.style.background="#1f2637";div.style.border="1px solid var(--border)";div.style.color="var(--text)";div.style.padding="8px 12px";
div.style.borderRadius="8px";div.style.boxShadow="0 6px 24px rgba(0,0,0,.35)";document.body.appendChild(div);setTimeout(()=>div.remove(),1600);
}
function randomPassword(len=12,useSymbols=false){
const letters="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const digits="0123456789";
const symbols="!@#$%^&*()-_=+[]{};:,.<>/?";
const base=letters+digits+(useSymbols?symbols:"");
let out="";for(let i=0;i<len;i++){out+=base[Math.floor(Math.random()*base.length)];}
// 确保至少包含一个字母与数字(以及符号可选)
function ensure(charset){if(!out.split("").some(ch=>charset.includes(ch))){const idx=Math.floor(Math.random()*out.length);out=out.slice(0,idx)+charset[Math.floor(Math.random()*charset.length)]+out.slice(idx+1);}}
ensure(letters);ensure(digits);if(useSymbols)ensure(symbols);
return out;
}
/* AES-GCM 加解密辅助 */
function toB64(buf){return btoa(String.fromCharCode(...new Uint8Array(buf)));}
function fromB64(str){return Uint8Array.from(atob(str),c=>c.charCodeAt(0)).buffer;}
async function aesDeriveKey(password, saltB64, iterations=100000){
const enc=new TextEncoder();
const keyMaterial=await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]);
const key=await crypto.subtle.deriveKey(
{name:"PBKDF2", salt: fromB64(saltB64), iterations, hash:"SHA-256"},
keyMaterial,
{name:"AES-GCM", length:256},
false,
["encrypt","decrypt"]
);
return key;
}
async function aesEncrypt(plain, key){
const enc=new TextEncoder();
const iv=crypto.getRandomValues(new Uint8Array(12));
const ct=await crypto.subtle.encrypt({name:"AES-GCM", iv}, key, enc.encode(plain));
return {ct: toB64(ct), iv: toB64(iv.buffer)};
}
async function aesDecrypt(encObj, key){
const dec=new TextDecoder();
const ivBuf=fromB64(encObj.iv);
const ctBuf=fromB64(encObj.ct);
const pt=await crypto.subtle.decrypt({name:"AES-GCM", iv: new Uint8Array(ivBuf)}, key, ctBuf);
return dec.decode(pt);
}
/* 构建加密导出数据(仅加密 username/password) */
async function buildExportDataEncrypted(password){
const salt=crypto.getRandomValues(new Uint8Array(16));
const saltB64=toB64(salt.buffer);
const iterations=100000;
const key=await aesDeriveKey(password, saltB64, iterations);
const tags=[];
tagIndex.forEach((list,tag)=>{
const accs=list.map(a=>({
id:a.id,
name:a.name||"",
site:a.site||"",
usernameEnc:null,
passwordEnc:null,
note:a.note||"",
tags:(a.tags||[]).slice()
}));
tags.push({tag,accounts:accs});
});
// 对去重账户加密字段(避免同一账户出现在多个标签重复加密不一致,统一遍历 accountIndex)
const encMap=new Map();
for(const [id,a] of accountIndex.entries()){
const u=await aesEncrypt(a.username||"", key);
const p=await aesEncrypt(a.password||"", key);
encMap.set(id,{u,p});
}
tags.forEach(group=>{
group.accounts.forEach(acc=>{
const ep=encMap.get(acc.id);
acc.usernameEnc=ep.u;
acc.passwordEnc=ep.p;
// 删除明文字段,避免混淆
delete acc.username;delete acc.password;
});
});
return {
AES:"yes",
aes:{algo:"AES-GCM",salt:saltB64,iterations},
tags,
pinnedIds:Array.from(state.pinnedIds||[]),
tagOrder:state.tagOrder||[]
};
}
/* 导入加密数据(AES yes)并解密后再导入 */
async function importEncryptedObj(obj, password){
if(!obj || obj.AES!=="yes" || !obj.aes || !obj.aes.salt) throw new Error("加密数据格式错误");
const key=await aesDeriveKey(password, obj.aes.salt, obj.aes.iterations||100000);
const plain={tags:[], pinnedIds: Array.isArray(obj.pinnedIds)?obj.pinnedIds:[], tagOrder: Array.isArray(obj.tagOrder)?obj.tagOrder:[]};
for(const group of obj.tags){
const accounts=[];
for(const a of (group.accounts||[])){
const username=await aesDecrypt(a.usernameEnc,key);
const password=await aesDecrypt(a.passwordEnc,key);
accounts.push({
id:a.id||("acc_"+Math.random().toString(36).slice(2,8)),
name:a.name||"",
site:a.site||"",
username,
password,
note:a.note||"",
tags:Array.isArray(a.tags)?a.tags:[group.tag]
});
}
plain.tags.push({tag:group.tag, accounts});
}
importJSONObj(plain);
}
function tryGetHost(url){try{return new URL(url).host;}catch{return "";}}
function reorderAccount(tag, fromId, toId){
if(!tag || tag==="__ALL__") return;
const list=(tagIndex.get(tag)||[]).map(a=>a.id);
if(!list.includes(fromId) || !list.includes(toId) || fromId===toId) return;
let order=Array.isArray(state.accountOrderByTag?.[tag])?state.accountOrderByTag[tag].slice():list.slice();
// 并集补齐
list.forEach(id=>{if(!order.includes(id)) order.push(id);});
const fi=order.indexOf(fromId), ti=order.indexOf(toId);
if(fi<0 || ti<0) return;
const [moved]=order.splice(fi,1);
order.splice(ti,0,moved);
state.accountOrderByTag[tag]=order;
renderAccounts();
localStorage.setItem("pm_data", JSON.stringify(buildExportData()));
}
function deleteAccount(id){
const acc=accountIndex.get(id);
if(!acc) return;
modalConfirm("确定删除账户:"+(acc.name||id)+"?",()=>{
// 从所有标签列表移除,并同步每标签排序
tagIndex.forEach((list,tag)=>{
const i=list.findIndex(x=>x.id===id);
if(i>=0) list.splice(i,1);
if(Array.isArray(state.accountOrderByTag?.[tag])){
state.accountOrderByTag[tag]=state.accountOrderByTag[tag].filter(x=>x!==id);
}
});
accountIndex.delete(id);
state.pinnedIds?.delete(id);
if(state.selectedAccountId===id){state.selectedAccountId=null;}
renderTags();renderAccounts();renderDetails();
localStorage.setItem("pm_data", JSON.stringify(buildExportData()));
});
}
function buildExportData(){
const tags=[];
tagIndex.forEach((list,tag)=>{tags.push({tag,accounts:list.map(a=>({...a}))});});
// 导出每个标签下的账户排序
const accountOrderByTag = {};
tagIndex.forEach((list, tag)=>{
const ids=list.map(a=>a.id);
let order=Array.isArray(state.accountOrderByTag?.[tag])?state.accountOrderByTag[tag].slice():[];
// 并集补齐
ids.forEach(id=>{if(!order.includes(id)) order.push(id);});
accountOrderByTag[tag]=order;
});
return {
tags,
pinnedIds:Array.from(state.pinnedIds||[]),
tagOrder:state.tagOrder||[],
accountOrderByTag
};
}
function downloadJSON(obj, filename="passwords.json"){
const blob=new Blob([JSON.stringify(obj,null,2)],{type:"application/json"});
const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=filename;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000);
}
function closeContextMenu(){document.querySelectorAll(".context-menu").forEach(m=>m.remove());}
function deleteTag(tag){
if(!tagIndex.has(tag))return;
modalConfirm("确定删除标签:"+tag+"?其内账户将移至“未分组”。",()=>{
const list=tagIndex.get(tag)||[];
const target="未分组";
if(!tagIndex.has(target)) tagIndex.set(target,[]);
list.forEach(acc=>{
acc.tags=(acc.tags||[]).filter(t=>t!==tag);
if(acc.tags.length===0) acc.tags=[target];
tagIndex.get(target).push(acc);
});
tagIndex.delete(tag);
state.tagOrder=(state.tagOrder||[]).filter(t=>t!==tag);
const keys=Array.from(tagIndex.keys());
keys.forEach(k=>{if(!state.tagOrder.includes(k)) state.tagOrder.push(k);});
renderTags();renderAccounts();renderDetails();
localStorage.setItem("pm_data", JSON.stringify(buildExportData()));
});
}
function renameTag(tag){
showModal("修改标签",(body)=>{
const {row,input:i}=input("新的标签名称",{value:tag});
body.appendChild(row);
body._input=i;
},function onSave(){
const name=document.querySelector(".modal .body")._input.value.trim();
if(!name){modalAlert("请输入标签名称");return false;}
if(tag===name) return true;
if(tagIndex.has(name)){modalAlert("目标标签已存在");return false;}
const list=tagIndex.get(tag)||[];
tagIndex.set(name, list);
list.forEach(acc=>{acc.tags=(acc.tags||[]).map(t=>t===tag?name:t);});
tagIndex.delete(tag);
state.tagOrder=(state.tagOrder||[]).map(t=>t===tag?name:t);
renderTags();renderAccounts();renderDetails();
localStorage.setItem("pm_data", JSON.stringify(buildExportData()));
return true;
});
}
function pinTag(tag){
if(tag==="全部")return;
const keys=Array.from(tagIndex.keys());
let order=(state.tagOrder&&state.tagOrder.length)?[...state.tagOrder]:keys.slice();
keys.forEach(k=>{if(!order.includes(k)) order.push(k);});
const i=order.indexOf(tag);if(i<0)return;
order.splice(i,1);order.unshift(tag);
state.tagOrder=order;
renderTags();
localStorage.setItem("pm_data", JSON.stringify(buildExportData()));
}
function importJSONObj(obj){
if(!obj||!Array.isArray(obj.tags)) throw new Error("格式错误:缺少 tags");
tagIndex.clear();accountIndex.clear();
obj.tags.forEach(group=>{
const list=(group.accounts||[]).map(a=>({
id:a.id||("acc_"+Math.random().toString(36).slice(2,8)),
name:a.name||"",
site:a.site||"",
username:a.username||"",
password:a.password||"",
note:a.note||"",
tags:Array.isArray(a.tags)&&a.tags.length?a.tags:[group.tag]
}));
tagIndex.set(group.tag,list);
list.forEach(a=>accountIndex.set(a.id,a));
});
state.tagOrder = Array.isArray(obj.tagOrder)?obj.tagOrder:Array.from(tagIndex.keys());
state.pinnedIds = new Set(Array.isArray(obj.pinnedIds)?obj.pinnedIds:[]);
// 加载并合并每标签账户排序
state.accountOrderByTag = (obj.accountOrderByTag && typeof obj.accountOrderByTag==="object") ? obj.accountOrderByTag : {};
tagIndex.forEach((list, tag)=>{
const ids=list.map(a=>a.id);
let order=Array.isArray(state.accountOrderByTag?.[tag])?state.accountOrderByTag[tag].slice():[];
ids.forEach(id=>{ if(!order.includes(id)) order.push(id); });
state.accountOrderByTag[tag]=order;
});
renderTags();renderAccounts();renderDetails();
}
/* 手机模式按钮绑定 */
document.getElementById("mobileTagsBtn")?.addEventListener("click",()=>{if(isMobile()){document.body.classList.add("mobile-show-tags");document.body.classList.remove("mobile-show-details");}});
document.getElementById("mobileBackBtn")?.addEventListener("click",()=>{if(isMobile()){document.body.classList.remove("mobile-show-details");}});
document.getElementById("settingsFab").onclick=()=>{
showModal("设置",(body)=>{
// 开关
const row1=document.createElement("div");row1.className="form-row";
const l1=document.createElement("label");l1.className="label";l1.textContent="启用AES加密(导出时)";
const r1=document.createElement("div");
const chk=document.createElement("input");chk.type="checkbox";chk.checked=!!settings.aesEnabled;
r1.appendChild(chk);row1.appendChild(l1);row1.appendChild(r1);body.appendChild(row1);
// 密码输入
const {row,input:i}=input("Password(加密密码)",{type:"password",value:settings.aesPassword||"",placeholder:"请输入用于加解密的密码"});
body.appendChild(row);
body._controls={chk,i};
},function onSave(){
const {_controls}=document.querySelector(".modal .body");
const enabled=_controls.chk.checked;
const pwd=_controls.i.value;
settings.aesEnabled=enabled;
settings.aesPassword=pwd||"";
saveSettings();
toast("设置已保存");
return true;
});
};
document.getElementById("downloadFab").onclick=async ()=>{
loadSettings();
try{
let obj;
if(settings.aesEnabled){
if(!settings.aesPassword){modalAlert("请在设置中填写加密密码");return;}
obj=await buildExportDataEncrypted(settings.aesPassword);
}else{
obj=buildExportData();
obj.AES="no";
}
downloadJSON(obj,"passwords.json");
toast(settings.aesEnabled?"已导出(AES加密)":"已导出");
}catch(e){
modalAlert("导出失败:"+(e?.message||e));
}
};
document.getElementById("uploadFab").onclick=()=>{
const fileInput=document.createElement("input");fileInput.type="file";fileInput.accept="application/json";
fileInput.onchange=()=>{const f=fileInput.files?.[0];if(!f){modalAlert("请选择 JSON 文件");return;}
modalConfirm("导入将覆盖当前数据,是否继续?",()=>{
f.text().then(async t=>{
try{
const obj=JSON.parse(t);
if(obj.AES==="yes"){
// 居中弹窗输入密码
showModal("输入加密密码",(body)=>{
const {row,input:i}=input("Password",{type:"password",placeholder:"请输入用于解密的密码"});
body.appendChild(row);body._i=i;
},async ()=>{
const pwd=document.querySelector(".modal .body")._i.value;
if(!pwd){modalAlert("请输入密码");return false;}
try{
await importEncryptedObj(obj, pwd);
localStorage.setItem("pm_data", JSON.stringify(buildExportData()));
toast("导入成功(AES解密)");
}catch(e){
modalAlert("解密失败:"+(e?.message||e));return false;
}
return true;
});
}else{
importJSONObj(obj);
localStorage.setItem("pm_data", JSON.stringify(buildExportData()));
toast("导入成功");
}
}catch(e){modalAlert("导入失败:"+(e?.message||e));}
});
});
};
fileInput.click();
};
document.getElementById("clearSearch").onclick=()=>{searchInput.value="";state.search="";renderAccounts();};
searchInput.addEventListener("input",e=>{state.search=e.target.value;renderAccounts();});
/* 初始化:加载UI状态后再渲染 */
loadUI();
renderTags();renderAccounts();renderDetails();highlightTags();updateMobileView();
</script>
<style>
.corner-links {
position: fixed;
right: 20px;
bottom: 20px;
display: flex;
align-items: center;
z-index: 9999;
}
.corner-link {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
color: #ffffff;
text-decoration: none;
transition: all 0.3s ease;
}
.corner-link:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(255, 255, 255, 0.15);
}
.corner-link img,
.corner-link svg {
width: 22px;
height: 22px;
}
.corner-link .label {
font-weight: 600;
font-size: 0.95rem;
}
</style>
<div class="corner-links" aria-label="页面固定链接">
<a class="corner-link" href="https://github.com/IIIStudio/IIIPasswords" target="_blank" rel="noopener noreferrer" aria-label="前往 GitHub 仓库">
<!-- 内联 GitHub 图标,避免外部资源依赖 -->
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
<path fill="#ffffff" d="M12 .5a12 12 0 0 0-3.79 23.41c.6.11.82-.26.82-.58v-2.02c-3.35.73-4.06-1.61-4.06-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.09-.75.08-.74.08-.74 1.2.09 1.83 1.23 1.83 1.23 1.07 1.83 2.8 1.3 3.49.99.11-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.13-.3-.54-1.51.12-3.15 0 0 1.01-.32 3.3 1.23.96-.27 1.99-.4 3.01-.4s2.05.14 3.01.4c2.29-1.55 3.3-1.23 3.3-1.23.66 1.64.25 2.85.12 3.15.77.84 1.24 1.91 1.24 3.22 0 4.61-2.8 5.63-5.47 5.93.43.37.81 1.1.81 2.22v3.29c0 .32.22.7.83.58A12 12 0 0 0 12 .5Z"/>
</svg>
<span class="label">IIIPasswords</span>
</a>
<a class="corner-link" href="https://cnb.cool/IIIStudio/HTML/IIIPasswords/" target="_blank" rel="noopener noreferrer" aria-label="前往 IIIPasswords 文档页面">
<img src="https://docs.cnb.cool/images/logo/svg/LogoColorfulIcon.svg" alt="CNB Logo">
</a>
</div>
</body>
</html>
|
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
x
|