查看: 215|回复: 0

一个密码管理器!

[复制链接]

89

主题

8

回帖

31

积分

管理员

Rank: 9Rank: 9Rank: 9

积分
31
发表于 2025-10-3 23:47:36 | 显示全部楼层 |阅读模式
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
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

申明:本站所发布的一切资源(包括但不限于文章、信息、软件、补丁、注册机、注册信息、解密分析内容等)均来自网络,仅限用于学习和研究目的;不得将上述内容用于商业或者非法用途,否则,一切后果用户自负。发布的信息来自网络,版权争议与本人无关。您必须在下载后的24小时之内,从您的电脑中彻底删除上述内容。如果您喜欢该资源,请支持正版,购买注册,得到更好的正版服务。如有侵权请与本站联系删除本信息。
快速回复 返回顶部 返回列表