开源一个可以把HTML敏感词替换的页面小工具

有的时候,比如有个HTML,里面有敏感的词,要发给AI,怕敏感词泄露不好,于是可以用下面的 页面小工具来替换:

可以手工输入各类替换对,可以随机生成,也支持边看,边觉得哪些地方要替换,适合仔细检查

然后把规则和替换对,导出EXCEL文件。然后在另外的TAB中,选择要替换的HTML,

然后加载该规则EXCEL文件,就可以加密HTML咯。

代码如下:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文本脱敏工具箱 V1.0</title>
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body { font-family: -apple-system, "Microsoft YaHei", "PingFang SC", sans-serif; background: #f5f6fa; color: #2c3e50; min-height: 100vh; }
  .header { background: linear-gradient(135deg, #1a2330 0%, #2c3e50 100%); color: #fff; padding: 20px 32px 16px; }
  .header h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
  .header p { font-size: 14px; color: #b0b8c0; }
  /* Tabs */
  .tabs { display: flex; gap: 0; background: #fff; border-bottom: 2px solid #e8ecf1; position: sticky; top: 0; z-index: 100; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
  .tab-btn { flex: 1; padding: 14px 20px; border: none; background: transparent; font-size: 15px; font-weight: 700; color: #90989f; cursor: pointer; transition: all 0.2s; border-bottom: 3px solid transparent; margin-bottom: -2px; }
  .tab-btn:hover { color: #2c3e50; background: #fafbfc; }
  .tab-btn.active { color: #d4875a; border-bottom-color: #d4875a; background: #fff; }
  .tab-panel { display: none; }
  .tab-panel.active { display: block; }
  .container { max-width: 960px; margin: 24px auto; padding: 0 20px; }
  .card { background: #fff; border-radius: 10px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); padding: 24px; margin-bottom: 20px; }
  .card-title { font-size: 16px; font-weight: 700; color: #2c3e50; margin-bottom: 16px; padding-bottom: 10px; border-bottom: 2px solid #d4875a; display: flex; align-items: center; gap: 8px; }
  .card-title::before { content: ""; display: inline-block; width: 4px; height: 18px; background: #d4875a; border-radius: 2px; }
  .input-row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
  .input-group { flex: 1; min-width: 200px; }
  .input-group label { display: block; font-size: 13px; color: #607080; margin-bottom: 6px; font-weight: 600; }
  .input-group input, .input-group select { width: 100%; height: 40px; border: 1.5px solid #ddd; border-radius: 6px; padding: 0 12px; font-size: 14px; transition: border-color 0.2s; outline: none; }
  .input-group input:focus, .input-group select:focus { border-color: #d4875a; }
  .btn { height: 40px; padding: 0 20px; border: none; border-radius: 6px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s; white-space: nowrap; }
  .btn-primary { background: #d4875a; color: #fff; }
  .btn-primary:hover { background: #c07848; transform: translateY(-1px); }
  .btn-primary:disabled { background: #ccc; cursor: not-allowed; transform: none; }
  .btn-secondary { background: #e8ecf1; color: #2c3e50; }
  .btn-secondary:hover { background: #dde2e8; }
  .btn-success { background: #27ae60; color: #fff; }
  .btn-success:hover { background: #219a52; }
  .btn-danger { background: #e74c3c; color: #fff; padding: 0 12px; font-size: 12px; height: 32px; }
  .btn-danger:hover { background: #c0392b; }
  .btn-sm { height: 32px; padding: 0 12px; font-size: 12px; }
  .btn-lg { height: 44px; padding: 0 28px; font-size: 15px; border-radius: 8px; }
  .btn-group { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 16px; }
  .btn-group-center { display: flex; justify-content: center; margin-top: 20px; }
  /* Table */
  table { width: 100%; border-collapse: collapse; margin-top: 4px; }
  thead th { background: #f8f0eb; color: #2c3e50; font-weight: 700; font-size: 13px; text-align: left; padding: 10px 12px; border-bottom: 2px solid #d4875a; }
  thead th:first-child { width: 60px; text-align: center; }
  thead th:last-child { width: 140px; text-align: center; }
  tbody td { padding: 10px 12px; border-bottom: 1px solid #eee; font-size: 14px; vertical-align: middle; }
  tbody tr:hover { background: #fdf8f3; }
  tbody td:first-child { text-align: center; color: #90989f; font-size: 13px; }
  .empty-state { text-align: center; padding: 40px 20px; color: #90989f; font-size: 14px; }
  .empty-state svg { width: 48px; height: 48px; margin-bottom: 12px; opacity: 0.4; }
  .stat-bar { display: flex; gap: 20px; margin-bottom: 16px; }
  .stat-item { font-size: 13px; color: #607080; }
  .stat-item span { font-weight: 700; color: #d4875a; font-size: 18px; margin-right: 4px; }
  .toast { position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 8px; color: #fff; font-size: 14px; font-weight: 600; z-index: 9999; transform: translateX(120%); transition: transform 0.3s ease; }
  .toast.show { transform: translateX(0); }
  .toast-success { background: #27ae60; }
  .toast-error { background: #e74c3c; }
  .toast-info { background: #d4875a; }
  .filename-input { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
  .filename-input label { font-size: 13px; color: #607080; white-space: nowrap; }
  .filename-input input { width: 260px; height: 36px; border: 1.5px solid #ddd; border-radius: 6px; padding: 0 12px; font-size: 13px; outline: none; }
  .filename-input input:focus { border-color: #d4875a; }
  /* Rules */
  .rule-row { display: flex; align-items: center; gap: 12px; padding: 14px 16px; background: #fafbfc; border-radius: 8px; border: 1.5px solid #e8ecf1; transition: all 0.2s; }
  .rule-row:hover { border-color: #d4875a; background: #fdf8f3; }
  .rule-row.active { border-color: #d4875a; background: #fff8f3; }
  .rule-checkbox { width: 20px; height: 20px; accent-color: #d4875a; cursor: pointer; flex-shrink: 0; }
  .rule-info { flex: 1; }
  .rule-name { font-size: 14px; font-weight: 700; color: #2c3e50; }
  .rule-desc { font-size: 12px; color: #90989f; margin-top: 2px; }
  .rule-tag { display: inline-block; background: #d4875a; color: #fff; font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 600; margin-right: 6px; }
  .rule-options { margin-top: 12px; padding: 12px 16px; background: #fafbfc; border-radius: 8px; border-left: 3px solid #d4875a; }
  .rule-options label { font-size: 13px; color: #607080; font-weight: 600; display: block; margin-bottom: 6px; }
  .rule-options select, .rule-options input[type="text"] { height: 36px; border: 1.5px solid #ddd; border-radius: 6px; padding: 0 12px; font-size: 13px; outline: none; }
  .rule-options select:focus, .rule-options input[type="text"]:focus { border-color: #d4875a; }
  .rule-options select { min-width: 200px; }
  .rule-options input[type="text"] { width: 120px; }
  .rule-inline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
  .rule-sub { font-size: 12px; color: #90989f; margin-top: 6px; margin-left: 32px; }
  /* Modal */
  .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 1000; display: flex; align-items: center; justify-content: center; }
  .modal { background: #fff; border-radius: 12px; padding: 28px; max-width: 560px; width: 92%; box-shadow: 0 8px 40px rgba(0,0,0,0.15); }
  .modal h3 { font-size: 17px; font-weight: 700; color: #2c3e50; margin-bottom: 16px; }
  .modal p { font-size: 13px; color: #607080; margin-bottom: 8px; line-height: 1.6; }
  .modal .highlight { color: #d4875a; font-weight: 700; }
  .modal-btns { display: flex; gap: 10px; margin-top: 20px; justify-content: flex-end; }
  .table-scroll { max-height: 260px; overflow-y: auto; border: 1px solid #eee; border-radius: 8px; margin-top: 12px; }
  .table-scroll table { margin: 0; }
  .table-scroll th, .table-scroll td { font-size: 12px; padding: 6px 10px; }
  /* Tool2 extras */
  .upload-area { border: 2px dashed #d0d0d0; border-radius: 10px; padding: 32px 20px; text-align: center; cursor: pointer; transition: all 0.2s; position: relative; }
  .upload-area:hover { border-color: #d4875a; background: #fdf8f3; }
  .upload-area.loaded { border-color: #27ae60; background: #f0faf4; }
  .upload-area input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
  .upload-icon { font-size: 36px; color: #b0b8c0; margin-bottom: 8px; }
  .upload-area.loaded .upload-icon { color: #27ae60; }
  .upload-text { font-size: 14px; color: #607080; }
  .upload-hint { font-size: 12px; color: #90989f; margin-top: 4px; }
  .file-info { margin-top: 10px; font-size: 13px; color: #27ae60; font-weight: 600; }
  .hidden { display: none !important; }
  .step-flow { display: flex; align-items: center; justify-content: center; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; }
  .step { display: flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 20px; font-size: 13px; font-weight: 600; background: #e8ecf1; color: #607080; }
  .step.active { background: #d4875a; color: #fff; }
  .step.done { background: #27ae60; color: #fff; }
  .step-arrow { color: #b0b8c0; font-size: 18px; }
  .rule-badge { display: inline-block; background: #d4875a; color: #fff; font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 600; margin-left: 8px; }
  .preview-table { width: 100%; border-collapse: collapse; margin-top: 12px; }
  .preview-table th { background: #f8f0eb; color: #2c3e50; font-weight: 700; font-size: 13px; padding: 8px 12px; text-align: left; border-bottom: 2px solid #d4875a; }
  .preview-table td { padding: 8px 12px; border-bottom: 1px solid #eee; font-size: 13px; }
  .stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-top: 16px; }
  .stat-card { background: #f8f0eb; border-radius: 8px; padding: 16px; text-align: center; }
  .stat-card .num { font-size: 28px; font-weight: 800; color: #d4875a; }
  .stat-card .label { font-size: 12px; color: #607080; margin-top: 4px; }
  .use-local-btn { display: inline-flex; align-items: center; gap: 6px; padding: 10px 20px; background: #f0faf4; border: 2px solid #27ae60; border-radius: 8px; color: #27ae60; font-size: 13px; font-weight: 700; cursor: pointer; transition: all 0.2s; margin-top: 12px; }
  .use-local-btn:hover { background: #27ae60; color: #fff; }
  .use-local-btn .badge { background: #27ae60; color: #fff; font-size: 11px; padding: 1px 6px; border-radius: 10px; }
  .use-local-btn:hover .badge { background: #fff; color: #27ae60; }
  .or-divider { display: flex; align-items: center; gap: 12px; margin: 16px 0; color: #90989f; font-size: 13px; }
  .or-divider::before, .or-divider::after { content: ""; flex: 1; height: 1px; background: #e8ecf1; }

  /* HTML Preview & Select-to-Replace */
  .html-preview-card { position: relative; }
  .html-preview-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
  .html-preview-bar .tip { font-size: 12px; color: #90989f; }
  .html-preview-bar .tip b { color: #d4875a; }
  .html-preview-iframe { width: 100%; height: 520px; border: 1px solid #e0e4e8; border-radius: 8px; background: #fff; }
  .sel-popup { position: fixed; z-index: 9999; background: #fff; border-radius: 10px; box-shadow: 0 8px 32px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.08); padding: 16px 18px; width: 340px; display: none; }
  .sel-popup.show { display: block; animation: selPopIn 0.18s ease-out; }
  @keyframes selPopIn { from { opacity:0; transform: translateY(6px) scale(0.97); } to { opacity:1; transform: translateY(0) scale(1); } }
  .sel-popup-title { font-size: 13px; font-weight: 700; color: #2c3e50; margin-bottom: 10px; display: flex; align-items: center; gap: 6px; }
  .sel-popup-title::before { content: ''; display: inline-block; width: 4px; height: 14px; background: #d4875a; border-radius: 2px; }
  .sel-popup .sel-original { background: #f5f6fa; border-radius: 6px; padding: 8px 12px; font-size: 13px; color: #2c3e50; word-break: break-all; margin-bottom: 10px; max-height: 80px; overflow-y: auto; line-height: 1.5; border-left: 3px solid #d4875a; }
  .sel-popup .sel-original label { font-size: 11px; color: #90989f; display: block; margin-bottom: 4px; }
  .sel-popup .sel-row { display: flex; gap: 8px; margin-bottom: 10px; align-items: stretch; }
  .sel-popup .sel-row input { flex: 1; padding: 8px 12px; border: 1.5px solid #e0e4e8; border-radius: 6px; font-size: 13px; outline: none; transition: border-color 0.2s; }
  .sel-popup .sel-row input:focus { border-color: #d4875a; }
  .sel-popup .sel-btns { display: flex; gap: 8px; justify-content: flex-end; }
  .sel-popup .sel-btns button { padding: 7px 16px; border-radius: 6px; border: none; font-size: 13px; font-weight: 700; cursor: pointer; transition: all 0.15s; }
  .sel-popup .btn-sel-rand { background: #f0f4ff; color: #4a6cf7; }
  .sel-popup .btn-sel-rand:hover { background: #4a6cf7; color: #fff; }
  .sel-popup .btn-sel-ok { background: #d4875a; color: #fff; }
  .sel-popup .btn-sel-ok:hover { background: #c07848; }
  .sel-popup .btn-sel-cancel { background: #f0f0f0; color: #607080; }
  .sel-popup .btn-sel-cancel:hover { background: #e0e0e0; }
  .sel-popup .sel-arrow { position: absolute; top: -6px; left: 28px; width: 12px; height: 12px; background: #fff; transform: rotate(45deg); border-left: 1px solid #e0e4e8; border-top: 1px solid #e0e4e8; box-shadow: -2px -2px 4px rgba(0,0,0,0.04); }


  

</style>
</head>
<body>

<div class="header">
  <h1>文本脱敏工具箱</h1>
  <p>本地化敏感信息替换工具,所有数据仅在浏览器本地处理,不上传服务器</p>
</div>

<div class="tabs">
  <button class="tab-btn active" onclick="switchTab(0)">替换对管理</button>
  <button class="tab-btn" onclick="switchTab(1)">文件批量替换</button>
</div>

<!-- ============ TAB 1: 替换对管理 ============ -->
<div class="tab-panel active" id="tab0">
<div class="container">
  <div class="card">
    <div class="card-title">添加替换对</div>
    <div class="input-row">
      <div class="input-group">
        <label>原始文本(需替换的敏感信息)</label>
        <input type="text" id="originalText" placeholder="例如:张三" />
      </div>
      <div class="input-group">
        <label>替换文本(脱敏后的内容)</label>
        <input type="text" id="replaceText" placeholder="例如:李四" />
      </div>
      <button class="btn btn-secondary" onclick="randomReplace()" title="自动生成随机替换文本">随机生成</button>
      <button class="btn btn-primary" onclick="addPair()">添  加</button>
    </div>
  </div>
  <div class="card">
    <div class="card-title">特殊规则</div>
    <p style="font-size:13px;color:#607080;margin-bottom:14px;">定义自动化替换规则,切换到"文件批量替换"标签页执行时自动生效,规则随映射表一起导出。</p>
    <div class="rule-row" id="ruleBookTitleRow">
      <input type="checkbox" class="rule-checkbox" id="ruleBookTitle" onchange="toggleRule('bookTitle')" />
      <div class="rule-info">
        <div class="rule-name"><span class="rule-tag">规则</span> 书名号内容替换</div>
        <div class="rule-desc">自动将源文件中所有书名号内的文字替换为随机文本,保留符号本身。例如《ABC》变为《DEFF》</div>
      </div>
    </div>
    <div class="rule-options" id="ruleBookTitleOptions" style="display:none">
      <label>替换模式:</label>
      <div class="rule-inline">
        <select id="bookTitleMode">
          <option value="random_name">随机人名(如《张伟》)</option>
          <option value="random_text" selected>随机文本(如《星辰大海》)</option>
          <option value="fixed_prefix">固定前缀+序号(如《文档001》)</option>
        </select>
        <span style="font-size:12px;color:#90989f;margin-left:8px">固定前缀模式可自定义:</span>
        <input type="text" id="bookTitlePrefix" value="文档" placeholder="前缀" />
      </div>
    </div>
    <div class="rule-row" id="ruleTableRow" style="margin-top:12px">
      <input type="checkbox" class="rule-checkbox" id="ruleTable" onchange="toggleRule('table')" />
      <div class="rule-info">
        <div class="rule-name"><span class="rule-tag">规则</span> 表格内容替换</div>
        <div class="rule-desc">将源文件中表格(Tab/逗号/竖线分隔)内的文字按映射对进行替换,保留分隔符和数字</div>
      </div>
    </div>
    <div class="rule-options" id="ruleTableOptions" style="display:none">
      <label>分隔符识别:</label>
      <div class="rule-inline">
        <select id="tableSepMode">
          <option value="auto" selected>自动识别(Tab / 逗号 / 竖线)</option>
          <option value="tab">仅 Tab 分隔</option>
          <option value="comma">仅 逗号 分隔</option>
          <option value="pipe">仅 竖线(|) 分隔</option>
        </select>
      </div>
      <div class="rule-sub">
        <label style="display:inline;cursor:pointer"><input type="checkbox" id="tableKeepHeader" checked style="accent-color:#d4875a;vertical-align:middle;margin-right:4px" /> 保留首行(表头行不替换)</label>
        &nbsp;&nbsp;
        <label style="display:inline;cursor:pointer"><input type="checkbox" id="tableKeepNumbers" checked style="accent-color:#d4875a;vertical-align:middle;margin-right:4px" /> 保留纯数字单元格</label>
      </div>
    </div>
    <div class="rule-row" id="ruleJsDataRow" style="margin-top:12px">
      <input type="checkbox" class="rule-checkbox" id="ruleJsData" onchange="toggleRule('jsData')" />
      <div class="rule-info">
        <div class="rule-name"><span class="rule-tag">规则</span> JS 数据块替换</div>
        <div class="rule-desc">识别 &lt;script&gt; 内 const/var/let 声明的数据对象/数组,替换其中字符串值里的中文(解决数据驱动页面的表格/图表数据源漏脱敏)</div>
      </div>
    </div>
    <div class="rule-options" id="ruleJsDataOptions" style="display:none">
      <label>替换模式:</label>
      <div class="rule-inline">
        <select id="jsDataMode">
          <option value="random" selected>随机替换字符串值中的中文(先应用映射对)</option>
          <option value="mapping">仅按映射对替换(不随机)</option>
        </select>
        <span style="font-size:12px;color:#90989f;margin-left:8px">目标变量(逗号分隔):</span>
        <input type="text" id="jsDataTarget" value="data" placeholder="data" style="width:180px" />
      </div>
      <div class="rule-sub">默认处理名为 data 的变量,可填多个如 data,listData。仅替换字符串值中的中文,属性名(键)不动,保留日期与数字。</div>
    </div>
    <div class="rule-row" id="ruleAllCnRow" style="margin-top:12px">
      <input type="checkbox" class="rule-checkbox" id="ruleAllCn" onchange="toggleRule('allCn')" />
      <div class="rule-info">
        <div class="rule-name"><span class="rule-tag">规则</span> 全量中文替换</div>
        <div class="rule-desc">一键彻底脱敏:先应用映射对,再把页面所有"内容性中文"随机替换;自动保留 id/class、JS 键名、变量名等结构性中文,避免页面报错</div>
      </div>
    </div>
    <div class="rule-options" id="ruleAllCnOptions" style="display:none">
      <div class="rule-sub">无需额外配置。自动保护:HTML 的 id/class/name 属性、JS 对象键名("键":)、data['键'] 引用、中文变量名;其余中文(正文、字符串值、data-content/title 等属性内容)全部随机替换,日期与数字保留。</div>
    </div>
  </div>
  <div class="card">
    <div class="card-title">映射列表</div>
    <div class="stat-bar">
      <div class="stat-item">已录入 <span id="pairCount">0</span> 组</div>
      <div class="stat-item" id="ruleStat" style="display:none">| 已启用 <span id="ruleCount">0</span> 条特殊规则</div>
    </div>
    <div id="tableContainer"><div class="empty-state"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zM6 20V4h7v5h5v11H6z"/><path d="M8 12h8v2H8zm0 4h5v2H8z"/></svg><div>暂无替换对,请在上方添加或从 Excel 导入</div></div></div>
    <div class="btn-group">
      <button class="btn btn-success" onclick="exportExcel()">导出 Excel</button>
      <button class="btn btn-secondary" onclick="importExcel()">导入 Excel</button>
      <div style="flex:1"></div>
      <button class="btn btn-danger" onclick="clearAll()">清空全部</button>
    </div>
    <div class="filename-input">
      <label>导出文件名:</label>
      <input type="text" id="fileName" value="替换映射表" placeholder="替换映射表" />
      <label>.xlsx</label>
    </div>
  </div>
  <div class="card">
    <div class="card-title">HTML 预览选词</div>
    <div class="upload-area" id="htmlFileArea">
      <input type="file" id="htmlFileInput" accept=".html,.htm" onchange="handleHtmlPreviewFile(event)" />
      <div class="upload-icon">&#128065;</div>
      <div class="upload-text">上传 HTML 文件,预览内容并鼠标选词添加替换对</div>
      <div class="upload-hint">支持 .html .htm 格式,选中文字后弹出替换面板</div>
      <div class="file-info hidden" id="htmlFileInfo"></div>
    </div>
  </div>
  <div class="card hidden html-preview-card" id="htmlPreviewCard">
    <div class="html-preview-bar">
      <div class="card-title" style="margin-bottom:0;padding-bottom:0;border-bottom:none">HTML 内容预览</div>
      <div style="display:flex;align-items:center;gap:8px">
        <div class="tip" style="font-size:12px;color:#90989f"><b style="color:#d4875a">鼠标选中</b>文字即可添加替换对</div>
        <button class="btn btn-secondary" style="padding:4px 12px;font-size:12px" onclick="closeHtmlPreview()">收起</button>
      </div>
    </div>
    <iframe id="htmlPreviewIframe" class="html-preview-iframe" sandbox="allow-same-origin"></iframe>

  </div>
</div>
</div>

<!-- ============ TAB 2: 文件批量替换 ============ -->
<div class="tab-panel" id="tab1">
<div class="container">
  <div class="step-flow">
    <div class="step" id="step1">1. 上传文件</div>
    <div class="step-arrow">&rarr;</div>
    <div class="step" id="step2">2. 确认映射</div>
    <div class="step-arrow">&rarr;</div>
    <div class="step" id="step3">3. 执行替换</div>
  </div>
  <div class="card">
    <div class="card-title">上传源文件</div>
    <div class="upload-area" id="srcArea">
      <input type="file" id="srcInput" accept=".txt,.csv,.json,.log,.md,.xml,.html,.htm,.js,.css,.py,.java,.c,.cpp,.h,.sql,.yaml,.yml,.ini,.conf" onchange="handleSrcFile(event)" />
      <div class="upload-icon">&#128196;</div>
      <div class="upload-text">点击选择源文件 A(含敏感信息的文件)</div>
      <div class="upload-hint">支持 .txt .csv .json .log .md 等文本格式</div>
      <div class="file-info hidden" id="srcInfo"></div>
    </div>
    <div class="or-divider">或</div>
    <button class="use-local-btn" onclick="useLocalMapping()">
      &#9889; 直接使用「替换对管理」中的 <span class="badge" id="localPairBadge">0</span> 组映射 + 规则
    </button>
    <div style="margin-top:16px">
      <div class="upload-area" id="mapArea">
        <input type="file" id="mapInput" accept=".xlsx,.xls" onchange="handleMapFile(event)" />
        <div class="upload-icon">&#128202;</div>
        <div class="upload-text">点击选择映射文件 B(可选,也可用上方按钮直接引用)</div>
        <div class="upload-hint">由"替换对管理"导出的 .xlsx 文件</div>
        <div class="file-info hidden" id="mapInfo"></div>
      </div>
    </div>
  </div>
  

  <div class="card hidden" id="previewCard">
    <div class="card-title">映射关系预览 <span id="ruleBadge" class="rule-badge hidden"></span></div>
    <div id="previewContent"></div>
    <div class="btn-group-center">
      <button class="btn btn-primary btn-lg" id="replaceBtn" onclick="executeReplace()" disabled>开始替换</button>
    </div>
  </div>
  <div class="card hidden" id="resultCard">
    <div class="card-title">替换结果</div>
    <div class="stats-grid">
      <div class="stat-card"><div class="num" id="statLines">0</div><div class="label">扫描行数</div></div>
      <div class="stat-card"><div class="num" id="statReplacements">0</div><div class="label">替换次数</div></div>
      <div class="stat-card"><div class="num" id="statKeys">0</div><div class="label">命中键数</div></div>
      <div class="stat-card"><div class="num" id="statTime">0</div><div class="label">耗时(ms)</div></div>
    </div>
    <div id="ruleStatsArea" class="hidden" style="margin-top:16px;padding:12px 16px;background:#fff8f3;border-radius:8px;border-left:3px solid #d4875a"></div>
  </div>
</div>
</div>


<!-- Select-to-Replace Popup -->
<div class="sel-popup" id="selPopup">
  <div class="sel-arrow"></div>
  <div class="sel-popup-title">添加替换对</div>
  <div class="sel-original"><label>选中的文字(原始文本)</label><div id="selOriginalText"></div></div>
  <div class="sel-row">
    <input type="text" id="selReplaceInput" placeholder="输入替换后的文字..." />
    <button class="btn-sel-rand" onclick="selRandomFill()" title="随机生成">随机</button>
  </div>
  <div class="sel-btns">
    <button class="btn-sel-cancel" onclick="hideSelPopup()">取消</button>
    <button class="btn-sel-ok" onclick="confirmSelReplace()">确认添加</button>
  </div>
</div>

<!-- Toast & Modal -->
<div id="toast" class="toast"></div>
<input type="file" id="importFileInput" accept=".xlsx,.xls" style="display:none" onchange="handleImport(event)" />
<div id="importModal" class="modal-overlay" style="display:none">
  <div class="modal">
    <h3>导入结果</h3>
    <div id="importResult"></div>
    <div class="modal-btns"><button class="btn btn-secondary" onclick="closeImportModal()">关闭</button></div>
  </div>
</div>
<script>
// ============ Tab Switching ============
function switchTab(idx) {
  var btns = document.querySelectorAll('.tab-btn');
  var panels = document.querySelectorAll('.tab-panel');
  for (var i = 0; i < btns.length; i++) { btns[i].classList.toggle('active', i === idx); }
  for (var i = 0; i < panels.length; i++) { panels[i].classList.toggle('active', i === idx); }
  if (idx === 1) document.getElementById('localPairBadge').textContent = pairs.length;
}

// ============ Shared Data ============
var pairs = [];
var editingIndex = -1;
var rules = { bookTitle: false, bookTitleMode: 'random_text', bookTitlePrefix: '文档', table: false, tableSepMode: 'auto', tableKeepHeader: true, tableKeepNumbers: true, jsData: false, jsDataMode: 'random', jsDataTarget: 'data', allCn: false };
var srcContent = null;
var srcFileName = '';
var mappingPairs = [];  // used by tab2 (from Excel or local)
var bookTitleRule = null;
var tableRule = null;
var jsDataRule = null;
var allCnRule = null;
var useLocal = false;

// ============ Random Generators ============
var SURNAMES = '赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨朱秦尤许何吕施张孔曹严华金魏陶姜戚谢邹喻柏水窦章云苏潘葛奚范彭郎鲁韦昌马苗凤花方俞任袁柳鲍史唐费廉岑薛雷贺倪汤滕殷罗毕郝邬安常乐于时傅皮卞齐康伍余元卜顾孟平黄和穆萧尹姚邵湛汪祁毛禹狄米贝明臧计伏成戴谈宋茅庞熊纪舒屈项祝董梁杜阮蓝闵席季麻强贾路娄危江童颜郭梅盛林刁钟徐邱骆高夏蔡田樊胡凌霍虞万支柯管卢莫经房裘缪干解应宗丁宣邓郁单杭洪包诸左石崔吉钮龚程邢滑裴陆荣翁荀羊惠甄曲家封储靳段富焦巴牧山谷车侯全班仲伊宫宁仇栾暴甘厉戎祖武符刘景詹束龙叶幸司韶黎白怀蒲索赖卓屠蒙池乔胥能双闻党翟谭贡劳姬申扶堵冉宰雍桑桂濮牛寿通边扈燕冀浦尚农温别庄晏柴瞿阎充慕连茹习宦艾鱼容向古易慎戈廖居衡步都耿满弘匡国文寇广禄东欧沃利蔚越隆师聂晁勾敖融冷辛那简饶空曾沙养鞠丰巢关查后荆红游权益桓公'.split('');
var NAMES_M = ['伟','强','磊','洋','勇','军','杰','涛','超','明','刚','平','辉','鹏','华','飞','斌','浩','宇','志','建','文','博','天','海','龙','云','立','国','峰','浩然','志远','明哲','思远','宇航'];
var NAMES_F = ['芳','娜','敏','静','丽','秀英','娟','英','华','慧','巧','美','婷','玉','萍','红','娥','玲','琳','素','蓉','洁','瑶','璐','颖','雪','倩','馨','欣','蕾','薇','怡','悦','彤','妍','菲','琪','璇','萱','涵','诗涵','雨桐','梓萱','思琪','若曦','语嫣'];
var TITLE_CHARS = '星辰大地山河风云雨雪月日光影梦幻诗词歌赋春夏秋冬金木水火土龙凤鹤鹏瑞祥安康宁和平盛世华章文武德道理法术策略方案报告记录总结计划概论通史纲要志传经典集录编年纪事本末源流考证注疏解读评论叙述引言导论余韵新旧初续补别外附增修订正著译说讲义答疑辨析'.split('');

function randomName() {
  var s = SURNAMES[Math.floor(Math.random() * SURNAMES.length)];
  var pool = Math.random() > 0.5 ? NAMES_F : NAMES_M;
  var n = Math.random() > 0.4 ? 2 : 1; var name = '';
  for (var i = 0; i < n; i++) name += pool[Math.floor(Math.random() * pool.length)];
  return s + name;
}
function randomTitleText(len) {
  var n = Math.max(2, len); var r = '';
  for (var i = 0; i < n; i++) r += TITLE_CHARS[Math.floor(Math.random() * TITLE_CHARS.length)];
  return r;
}
function randomReplace() { document.getElementById('replaceText').value = randomName(); showToast('已生成随机替换文本', 'info'); }
function escHtml(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }

// ============ Rule Management (Tab 1) ============
function toggleRule(name) {
  if (name === 'bookTitle') {
    rules.bookTitle = document.getElementById('ruleBookTitle').checked;
    document.getElementById('ruleBookTitleOptions').style.display = rules.bookTitle ? 'block' : 'none';
    document.getElementById('ruleBookTitleRow').classList.toggle('active', rules.bookTitle);
  }
  if (name === 'table') {
    rules.table = document.getElementById('ruleTable').checked;
    document.getElementById('ruleTableOptions').style.display = rules.table ? 'block' : 'none';
    document.getElementById('ruleTableRow').classList.toggle('active', rules.table);
  }
  if (name === 'jsData') {
    rules.jsData = document.getElementById('ruleJsData').checked;
    document.getElementById('ruleJsDataOptions').style.display = rules.jsData ? 'block' : 'none';
    document.getElementById('ruleJsDataRow').classList.toggle('active', rules.jsData);
  }
  if (name === 'allCn') {
    rules.allCn = document.getElementById('ruleAllCn').checked;
    document.getElementById('ruleAllCnOptions').style.display = rules.allCn ? 'block' : 'none';
    document.getElementById('ruleAllCnRow').classList.toggle('active', rules.allCn);
  }
  updateRuleStat();
}
function updateRuleStat() {
  var c = (rules.bookTitle ? 1 : 0) + (rules.table ? 1 : 0) + (rules.jsData ? 1 : 0) + (rules.allCn ? 1 : 0);
  var el = document.getElementById('ruleStat');
  if (c > 0) { el.style.display = 'inline'; document.getElementById('ruleCount').textContent = c; }
  else { el.style.display = 'none'; }
}

// ============ CRUD (Tab 1) ============
function addPair() {
  var orig = document.getElementById('originalText').value.trim();
  var repl = document.getElementById('replaceText').value.trim();
  if (!orig || !repl) { showToast('请填写原始文本和替换文本', 'error'); return; }
  var dup = -1;
  for (var i = 0; i < pairs.length; i++) { if (pairs[i].original === orig) { dup = i; break; } }
  if (dup !== -1) {
    if (editingIndex === dup) { pairs[dup].replacement = repl; editingIndex = -1; showToast('已更新第 ' + (dup+1) + ' 组', 'success'); }
    else { showToast('该原始文本已存在于第 ' + (dup+1) + ' 行,请点击编辑修改', 'error'); return; }
  } else { pairs.push({ original: orig, replacement: repl }); showToast('已添加替换对', 'success'); }
  document.getElementById('originalText').value = ''; document.getElementById('replaceText').value = '';
  document.getElementById('originalText').focus(); renderTable();
}
function deletePair(index) {
  var p = pairs[index]; pairs.splice(index, 1);
  if (editingIndex === index) editingIndex = -1; else if (editingIndex > index) editingIndex--;
  renderTable(); showToast('已删除:' + p.original, 'info');
}
function editPair(index) {
  var p = pairs[index];
  document.getElementById('originalText').value = p.original; document.getElementById('replaceText').value = p.replacement;
  editingIndex = index; document.getElementById('originalText').focus();
  showToast('正在编辑第 ' + (index+1) + ' 组,修改后点击"添加"保存', 'info');
}
function clearAll() {
  if (pairs.length === 0) return;
  if (!confirm('确定清空全部 ' + pairs.length + ' 组替换对吗?')) return;
  pairs = []; editingIndex = -1; renderTable(); showToast('已清空', 'info');
}
function renderTable() {
  document.getElementById('pairCount').textContent = pairs.length;
  document.getElementById('localPairBadge').textContent = pairs.length;
  var c = document.getElementById('tableContainer');
  if (pairs.length === 0) { c.innerHTML = '<div class="empty-state"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zM6 20V4h7v5h5v11H6z"/><path d="M8 12h8v2H8zm0 4h5v2H8z"/></svg><div>暂无替换对,请在上方添加或从 Excel 导入</div></div>'; return; }
  var h = '<table><thead><tr><th>序号</th><th>原始文本</th><th>替换文本</th><th>操作</th></tr></thead><tbody>';
  for (var i = 0; i < pairs.length; i++) {
    var hl = (editingIndex === i) ? ' style="background:#fff3e0"' : '';
    h += '<tr' + hl + '><td>' + (i+1) + '</td><td>' + escHtml(pairs[i].original) + '</td><td>' + escHtml(pairs[i].replacement) + '</td><td style="text-align:center"><button class="btn btn-secondary btn-sm" onclick="editPair(' + i + ')">编辑</button> <button class="btn btn-danger" onclick="deletePair(' + i + ')">删除</button></td></tr>';
  }
  h += '</tbody></table>'; c.innerHTML = h;
}

// ============ Excel Export ============
function exportExcel() {
  if (pairs.length === 0 && !rules.bookTitle && !rules.table && !rules.jsData && !rules.allCn) { showToast('没有可导出的内容', 'error'); return; }
  var data = [['原始文本', '替换文本']];
  for (var i = 0; i < pairs.length; i++) data.push([pairs[i].original, pairs[i].replacement]);
  var ws1 = XLSX.utils.aoa_to_sheet(data); ws1['!cols'] = [{ wch: 30 }, { wch: 30 }];
  var wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws1, '替换映射');
  var ruleData = [['规则名称', '规则类型', '替换模式', '参数', '启用']];
  if (rules.bookTitle) {
    var mode = document.getElementById('bookTitleMode').value;
    var prefix = document.getElementById('bookTitlePrefix').value || '文档';
    ruleData.push(['书名号内容替换', 'book_title_replace', mode, prefix, '是']);
  }
  if (rules.table) {
    var tSep = document.getElementById('tableSepMode').value;
    var tKH = document.getElementById('tableKeepHeader').checked ? '是' : '否';
    var tKN = document.getElementById('tableKeepNumbers').checked ? '是' : '否';
    ruleData.push(['表格内容替换', 'table_content_replace', tSep, tKH + ',' + tKN, '是']);
  }
  if (rules.jsData) {
    var jMode = document.getElementById('jsDataMode').value;
    var jTarget = document.getElementById('jsDataTarget').value || 'data';
    ruleData.push(['JS数据块替换', 'js_data_replace', jMode, jTarget, '是']);
  }
  if (rules.allCn) {
    ruleData.push(['全量中文替换', 'all_cn_replace', 'random', '', '是']);
  }
  var ws2 = XLSX.utils.aoa_to_sheet(ruleData); ws2['!cols'] = [{ wch: 18 }, { wch: 20 }, { wch: 16 }, { wch: 12 }, { wch: 8 }];
  XLSX.utils.book_append_sheet(wb, ws2, '特殊规则');
  var name = (document.getElementById('fileName').value.trim() || '替换映射表') + '.xlsx';
  XLSX.writeFile(wb, name); showToast('已导出 ' + name, 'success');
}

// ============ Excel Import ============
function importExcel() { document.getElementById('importFileInput').click(); }
function handleImport(e) {
  var file = e.target.files[0]; if (!file) return;
  var reader = new FileReader();
  reader.onload = function(ev) {
    try {
      var wb = XLSX.read(ev.target.result, { type: 'array' });
      var addedCount = 0, skippedCount = 0, updatedCount = 0; var details = [];
      var ws = wb.Sheets[wb.SheetNames[0]];
      var rows = XLSX.utils.sheet_to_json(ws, { header: 1 });
      for (var i = 0; i < rows.length; i++) {
        var row = rows[i]; if (!row || row.length < 2) continue;
        var orig = String(row[0]).trim(), repl = String(row[1]).trim();
        if (!orig || !repl) continue;
        if (i === 0 && (orig === '原始文本' || orig.toLowerCase() === 'original')) continue;
        var dupIdx = -1;
        for (var j = 0; j < pairs.length; j++) { if (pairs[j].original === orig) { dupIdx = j; break; } }
        if (dupIdx !== -1) { if (pairs[dupIdx].replacement !== repl) { pairs[dupIdx].replacement = repl; updatedCount++; details.push('更新: ' + orig + ' \u2192 ' + repl); } else { skippedCount++; } }
        else { pairs.push({ original: orig, replacement: repl }); addedCount++; details.push('新增: ' + orig + ' \u2192 ' + repl); }
      }
      if (wb.SheetNames.length > 1 && wb.SheetNames[1] === '特殊规则') {
        var ws2 = wb.Sheets['特殊规则']; var rRows = XLSX.utils.sheet_to_json(ws2, { header: 1 });
        for (var k = 0; k < rRows.length; k++) {
          var rr = rRows[k]; if (!rr || rr.length < 2) continue;
          var rName = String(rr[0]).trim(), rType = String(rr[1]).trim();
          if (k === 0 && (rName === '规则名称' || rName.toLowerCase() === 'rule')) continue;
          var rEnabled = rr.length >= 5 ? String(rr[4]).trim() : '否';
          if (rType === 'table_content_replace' || rName.indexOf('表格') !== -1) {
            rules.table = (rEnabled === '是' || rEnabled === '1');
            document.getElementById('ruleTable').checked = rules.table;
            if (rules.table) {
              if (rr.length >= 3 && rr[2]) document.getElementById('tableSepMode').value = String(rr[2]).trim();
              if (rr.length >= 4 && rr[3]) { var params = String(rr[3]).trim().split(','); document.getElementById('tableKeepHeader').checked = (params[0] !== '否'); document.getElementById('tableKeepNumbers').checked = (params.length < 2 || params[1] !== '否'); }
              document.getElementById('ruleTableOptions').style.display = 'block'; document.getElementById('ruleTableRow').classList.add('active');
            } updateRuleStat();
          } else if (rType === 'book_title_replace' || rName.indexOf('书名号') !== -1) {
            rules.bookTitle = (rEnabled === '是' || rEnabled === '1');
            document.getElementById('ruleBookTitle').checked = rules.bookTitle;
            if (rules.bookTitle) {
              if (rr.length >= 3 && rr[2]) document.getElementById('bookTitleMode').value = String(rr[2]).trim();
              if (rr.length >= 4 && rr[3]) document.getElementById('bookTitlePrefix').value = String(rr[3]).trim();
              document.getElementById('ruleBookTitleOptions').style.display = 'block'; document.getElementById('ruleBookTitleRow').classList.add('active');
            } updateRuleStat();
          } else if (rType === 'js_data_replace' || rName.indexOf('JS数据') !== -1) {
            rules.jsData = (rEnabled === '是' || rEnabled === '1');
            document.getElementById('ruleJsData').checked = rules.jsData;
            if (rules.jsData) {
              if (rr.length >= 3 && rr[2]) document.getElementById('jsDataMode').value = String(rr[2]).trim();
              if (rr.length >= 4 && rr[3]) document.getElementById('jsDataTarget').value = String(rr[3]).trim();
              document.getElementById('ruleJsDataOptions').style.display = 'block'; document.getElementById('ruleJsDataRow').classList.add('active');
            } updateRuleStat();
          } else if (rType === 'all_cn_replace' || rName.indexOf('全量中文') !== -1) {
            rules.allCn = (rEnabled === '是' || rEnabled === '1');
            document.getElementById('ruleAllCn').checked = rules.allCn;
            if (rules.allCn) { document.getElementById('ruleAllCnOptions').style.display = 'block'; document.getElementById('ruleAllCnRow').classList.add('active'); }
            updateRuleStat();
          }
        }
      }
      renderTable(); showImportResult(file.name, addedCount, updatedCount, skippedCount, details);
    } catch(err) { showToast('导入失败: ' + err.message, 'error'); }
  };
  reader.readAsArrayBuffer(file); e.target.value = '';
}
function showImportResult(fileName, added, updated, skipped, details) {
  var h = '<p>文件:<b>' + escHtml(fileName) + '</b></p>';
  h += '<p>新增 <span class="highlight">' + added + '</span> 组,更新 <span class="highlight">' + updated + '</span> 组,跳过重复 <span class="highlight">' + skipped + '</span> 组</p>';
  h += '<p>当前共 <span class="highlight">' + pairs.length + '</span> 组替换对</p>';
  if (details.length > 0 && details.length <= 20) {
    h += '<div class="table-scroll"><table><thead><tr><th>#</th><th>操作</th><th>原始文本</th><th>替换文本</th></tr></thead><tbody>';
    for (var i = 0; i < details.length; i++) { var parts = details[i].split(': '); var kv = parts[1] ? parts[1].split(' \u2192 ') : ['', '']; h += '<tr><td>' + (i+1) + '</td><td>' + parts[0] + '</td><td>' + escHtml(kv[0]||'') + '</td><td>' + escHtml(kv[1]||'') + '</td></tr>'; }
    h += '</tbody></table></div>';
  } else if (details.length > 20) { h += '<p style="color:#90989f;font-size:12px">(变更明细过多,仅显示统计)</p>'; }
  document.getElementById('importResult').innerHTML = h; document.getElementById('importModal').style.display = 'flex';
}
function closeImportModal() { document.getElementById('importModal').style.display = 'none'; }

// ============ Tab 2: File Handlers ============
function handleSrcFile(e) {
  var file = e.target.files[0]; if (!file) return;
  srcFileName = file.name; var reader = new FileReader();
  reader.onload = function(ev) {
    srcContent = ev.target.result;
    document.getElementById('srcInfo').textContent = file.name + ' (' + (file.size/1024).toFixed(1) + ' KB)';
    document.getElementById('srcInfo').classList.remove('hidden'); document.getElementById('srcArea').classList.add('loaded');
    showToast('源文件已加载', 'success'); checkReady();
  }; reader.readAsText(file, 'UTF-8');
}

function useLocalMapping() {
  useLocal = true; mappingPairs = pairs.slice();
  bookTitleRule = rules.bookTitle ? { mode: rules.bookTitleMode, prefix: rules.bookTitlePrefix } : null;
  tableRule = rules.table ? { sepMode: rules.tableSepMode, keepHeader: rules.tableKeepHeader, keepNumbers: rules.tableKeepNumbers } : null;
  jsDataRule = rules.jsData ? { mode: rules.jsDataMode, target: rules.jsDataTarget } : null;
  allCnRule = rules.allCn ? { mode: 'random' } : null;
  document.getElementById('mapInfo').textContent = '已引用本地 ' + pairs.length + ' 组映射 + ' + ((bookTitleRule?1:0)+(tableRule?1:0)+(jsDataRule?1:0)+(allCnRule?1:0)) + ' 条规则';
  document.getElementById('mapInfo').classList.remove('hidden'); document.getElementById('mapArea').classList.add('loaded');
  showPreview(); showToast('已加载本地映射表', 'success'); checkReady();
}

function handleMapFile(e) {
  var file = e.target.files[0]; if (!file) return;
  useLocal = false; var reader = new FileReader();
  reader.onload = function(ev) {
    try {
      var wb = XLSX.read(ev.target.result, { type: 'array' });
      var ws = wb.Sheets[wb.SheetNames[0]]; var rows = XLSX.utils.sheet_to_json(ws, { header: 1 });
      mappingPairs = [];
      for (var i = 0; i < rows.length; i++) { var row = rows[i]; if (!row || row.length < 2) continue; var orig = String(row[0]).trim(), repl = String(row[1]).trim(); if (!orig || !repl) continue; if (i === 0 && orig === '原始文本') continue; mappingPairs.push({ original: orig, replacement: repl }); }
      bookTitleRule = null; tableRule = null; jsDataRule = null; allCnRule = null;
      if (wb.SheetNames.length > 1) {
        for (var si = 1; si < wb.SheetNames.length; si++) {
          if (wb.SheetNames[si] === '特殊规则') {
            var ws2 = wb.Sheets['特殊规则']; var rRows = XLSX.utils.sheet_to_json(ws2, { header: 1 });
            for (var k = 0; k < rRows.length; k++) {
              var rr = rRows[k]; if (!rr || rr.length < 2) continue;
              var rName = String(rr[0]).trim(), rType = String(rr[1]).trim();
              if (k === 0 && rName === '规则名称') continue;
              var rEn = rr.length >= 5 ? String(rr[4]).trim() : '否';
              if ((rType === 'book_title_replace' || rName.indexOf('书名号') !== -1) && (rEn === '是' || rEn === '1'))
                bookTitleRule = { mode: rr.length>=3?String(rr[2]).trim():'random_text', prefix: rr.length>=4?String(rr[3]).trim():'文档' };
              if ((rType === 'table_content_replace' || rName.indexOf('表格') !== -1) && (rEn === '是' || rEn === '1')) {
                var tp = rr.length>=4?String(rr[3]).trim():'是,是'; var tpa = tp.split(',');
                tableRule = { sepMode: rr.length>=3?String(rr[2]).trim():'auto', keepHeader: tpa[0]!=='否', keepNumbers: tpa.length<2||tpa[1]!=='否' };
              }
              if ((rType === 'js_data_replace' || rName.indexOf('JS数据') !== -1) && (rEn === '是' || rEn === '1'))
                jsDataRule = { mode: rr.length>=3?String(rr[2]).trim():'random', target: rr.length>=4?String(rr[3]).trim():'data' };
              if ((rType === 'all_cn_replace' || rName.indexOf('全量中文') !== -1) && (rEn === '是' || rEn === '1'))
                allCnRule = { mode: 'random' };
            }
          }
        }
      }
      var infoText = file.name + ' (' + (file.size/1024).toFixed(1) + ' KB, ' + mappingPairs.length + ' 组映射';
      if (bookTitleRule) infoText += ', 含书名号规则'; if (tableRule) infoText += ', 含表格规则'; if (jsDataRule) infoText += ', 含JS数据块规则'; if (allCnRule) infoText += ', 含全量中文规则'; infoText += ')';
      document.getElementById('mapInfo').textContent = infoText;
      document.getElementById('mapInfo').classList.remove('hidden'); document.getElementById('mapArea').classList.add('loaded');
      showPreview(); showToast('映射文件已加载', 'success'); checkReady();
    } catch(err) { showToast('解析映射文件失败: ' + err.message, 'error'); }
  }; reader.readAsArrayBuffer(file);
}

function checkReady() {
  // Also count Tab 1 rules as ready conditions (for fallback in executeReplace)
  var hasRule = bookTitleRule || tableRule || jsDataRule || allCnRule || rules.bookTitle || rules.table || rules.jsData || rules.allCn;
  var ready = srcContent !== null && (mappingPairs.length > 0 || hasRule);
  document.getElementById('replaceBtn').disabled = !ready;
  if (ready) { document.getElementById('step1').className = 'step done'; document.getElementById('step2').className = 'step active'; }
}

function showPreview() {
  document.getElementById('previewCard').classList.remove('hidden');
  var badge = document.getElementById('ruleBadge'); var ruleNames = [];
  if (bookTitleRule || rules.bookTitle) ruleNames.push('书名号'); if (tableRule || rules.table) ruleNames.push('表格'); if (jsDataRule || rules.jsData) ruleNames.push('JS数据块'); if (allCnRule || rules.allCn) ruleNames.push('全量中文');
  if (ruleNames.length > 0) { badge.textContent = ruleNames.join('+') + '规则已启用'; badge.classList.remove('hidden'); } else { badge.classList.add('hidden'); }
  var h = '<p style="font-size:13px;color:#607080;margin-bottom:8px">共 <b>' + mappingPairs.length + '</b> 组映射关系</p>';
  var btDisplay = bookTitleRule || (rules.bookTitle ? {mode: document.getElementById('bookTitleMode').value, prefix: document.getElementById('bookTitlePrefix').value} : null);
  var tbDisplay = tableRule || (rules.table ? {sepMode: document.getElementById('tableSepMode').value, keepHeader: document.getElementById('tableKeepHeader').checked, keepNumbers: document.getElementById('tableKeepNumbers').checked} : null);
  var jdDisplay = jsDataRule || (rules.jsData ? {mode: document.getElementById('jsDataMode').value, target: document.getElementById('jsDataTarget').value} : null);
  if (btDisplay) {
    var ml = {random_text:'随机文本', random_name:'随机人名', fixed_prefix:'固定前缀+序号'};
    h += '<div style="padding:10px 14px;background:#fff8f3;border-radius:8px;border-left:3px solid #d4875a;margin-bottom:12px;font-size:13px;color:#607080">';
    h += '特殊规则:<b>书名号内容替换</b> | 模式:' + (ml[btDisplay.mode]||btDisplay.mode);
    if (btDisplay.mode === 'fixed_prefix') h += ' | 前缀:' + escHtml(btDisplay.prefix);
    h += '</div>';
  }
  if (tbDisplay) {
    var sl = {auto:'自动识别', tab:'Tab', comma:'逗号', pipe:'竖线(|)'};
    h += '<div style="padding:10px 14px;background:#fff8f3;border-radius:8px;border-left:3px solid #d4875a;margin-bottom:12px;font-size:13px;color:#607080">';
    h += '特殊规则:<b>表格内容替换</b> | 分隔符:' + (sl[tbDisplay.sepMode]||tbDisplay.sepMode);
    h += ' | 保留表头:' + (tbDisplay.keepHeader?'是':'否') + ' | 保留数字:' + (tbDisplay.keepNumbers?'是':'否') + '</div>';
  }
  if (jdDisplay) {
    var jl = {random:'随机替换字符串值中的中文', mapping:'仅按映射对替换'};
    h += '<div style="padding:10px 14px;background:#fff8f3;border-radius:8px;border-left:3px solid #d4875a;margin-bottom:12px;font-size:13px;color:#607080">';
    h += '特殊规则:<b>JS数据块替换</b> | 模式:' + (jl[jdDisplay.mode]||jdDisplay.mode);
    h += ' | 目标变量:' + escHtml(jdDisplay.target || 'data') + '</div>';
  }
  if (allCnRule || rules.allCn) {
    h += '<div style="padding:10px 14px;background:#fff8f3;border-radius:8px;border-left:3px solid #d4875a;margin-bottom:12px;font-size:13px;color:#607080">';
    h += '特殊规则:<b>全量中文替换</b> | 先应用映射对,其余内容性中文全部随机替换,结构性中文(id/键名/变量名)自动保留</div>';
  }
  if (mappingPairs.length > 0) {
    var maxS = Math.min(mappingPairs.length, 10);
    if (mappingPairs.length > 10) h += '<p style="font-size:12px;color:#90989f">显示前 10 组:</p>';
    h += '<div class="table-scroll"><table class="preview-table"><thead><tr><th style="width:36px">#</th><th>原始文本</th><th>替换文本</th></tr></thead><tbody>';
    for (var i = 0; i < maxS; i++) h += '<tr><td>' + (i+1) + '</td><td>' + escHtml(mappingPairs[i].original) + '</td><td>' + escHtml(mappingPairs[i].replacement) + '</td></tr>';
    h += '</tbody></table></div>';
  }
  document.getElementById('previewContent').innerHTML = h;
}

// ============ Replace Engine (Tab 2) ============
function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }

function executeReplace() {
  // Fallback: use Tab 1 checkbox rules if Excel didn't provide them
  if (!bookTitleRule && rules.bookTitle) {
    bookTitleRule = { mode: document.getElementById('bookTitleMode').value, prefix: document.getElementById('bookTitlePrefix').value || '\u6587\u6863' };
  }
  if (!tableRule && rules.table) {
    tableRule = { sepMode: document.getElementById('tableSepMode').value, keepHeader: document.getElementById('tableKeepHeader').checked, keepNumbers: document.getElementById('tableKeepNumbers').checked };
  }
  if (!jsDataRule && rules.jsData) {
    jsDataRule = { mode: document.getElementById('jsDataMode').value, target: document.getElementById('jsDataTarget').value || 'data' };
  }
  if (!allCnRule && rules.allCn) {
    allCnRule = { mode: 'random' };
  }
  if (!srcContent) return; if (mappingPairs.length === 0 && !bookTitleRule && !tableRule && !jsDataRule && !allCnRule) return;
  document.getElementById('step2').className = 'step done'; document.getElementById('step3').className = 'step active';
  var t0 = performance.now(), content = srcContent, totalReplacements = 0, hitKeys = {};
  // Phase 1: Book title rule
  var btReplacements = [], btStatsHtml = '';
  if (bookTitleRule) {
    var btMap = {}, btCounter = 1, btTotalHits = 0, match;
    var btTempRegex = /\u300A([^\u300B]+)\u300B/g;
    while ((match = btTempRegex.exec(content)) !== null) {
      if (!(match[1] in btMap)) {
        var inner = match[1], len = inner.length, repl = '';
        if (bookTitleRule.mode === 'random_name') repl = randomName();
        else if (bookTitleRule.mode === 'fixed_prefix') repl = (bookTitleRule.prefix||'文档') + String(btCounter).padStart(3,'0');
        else repl = randomTitleText(len);
        btMap[inner] = repl; btCounter++;
        btReplacements.push({ original: '\u300A'+inner+'\u300B', replacement: '\u300A'+repl+'\u300B' });
      }
    }
    for (var key in btMap) {
      var from = '\u300A'+key+'\u300B', to = '\u300A'+btMap[key]+'\u300B';
      var fr = new RegExp(escapeRegex(from), 'g');
      var m = content.match(fr); if (m) { btTotalHits += m.length; totalReplacements += m.length; hitKeys[from] = to; }
      content = content.replace(fr, to);
    }
    if (btReplacements.length > 0) {
      btStatsHtml += '<div style="font-size:13px;font-weight:700;color:#d4875a;margin-bottom:8px">书名号规则:替换 ' + btTotalHits + ' 处,涉及 ' + btReplacements.length + ' 个不同书名</div>';
      if (btReplacements.length <= 15) {
        btStatsHtml += '<table style="width:100%;border-collapse:collapse"><thead><tr><th style="text-align:left;padding:4px 8px;font-size:12px;background:#f8f0eb">原始</th><th style="text-align:left;padding:4px 8px;font-size:12px;background:#f8f0eb">替换</th></tr></thead><tbody>';
        for (var bi = 0; bi < btReplacements.length; bi++) btStatsHtml += '<tr><td style="padding:3px 8px;font-size:12px;border-bottom:1px solid #eee">' + escHtml(btReplacements[bi].original) + '</td><td style="padding:3px 8px;font-size:12px;border-bottom:1px solid #eee">' + escHtml(btReplacements[bi].replacement) + '</td></tr>';
        btStatsHtml += '</tbody></table>';
      }
    }
  }
    // Phase 1.5: Table content rule
  var tStatsHtml = '';
  if (tableRule) {
    var isHtml = /<table[\s>]/i.test(content);
    if (isHtml) {
      // HTML file: parse <table> elements via regex on raw text
      var tblResult = randomizeHtmlTableText(content, tableRule);
      content = tblResult.html;
      if (tblResult.replaced > 0) {
        tStatsHtml = '<div style="font-size:13px;font-weight:700;color:#d4875a;margin-bottom:8px;' + (btStatsHtml?'margin-top:12px;padding-top:12px;border-top:1px solid #f0e0d0;':'') + '">HTML表格规则:随机替换 ' + tblResult.replaced + ' 个单元格,跳过 ' + tblResult.skipped + ' 个(数字/日期),涉及 ' + tblResult.tables + ' 个表格</div>';
        totalReplacements += tblResult.replaced;
      }
    } else if (mappingPairs.length > 0) {
      // Non-HTML: apply mapping pairs to separator-delimited rows
      var lines4t = content.split('\n'), sepMode = tableRule.sepMode, tableCellsAffected = 0, tableRowsAffected = 0;
      var cellSorted = mappingPairs.slice().sort(function(a,b){return b.original.length-a.original.length;});
      for (var ti = 0; ti < lines4t.length; ti++) {
        var tline = lines4t[ti], sep = null;
        if (sepMode === 'auto') { if (tline.indexOf('\t') !== -1) sep = '\t'; else if (tline.split('|').length >= 3) sep = '|'; else if (tline.split(',').length >= 3) sep = ','; }
        else if (sepMode === 'tab') { if (tline.indexOf('\t') !== -1) sep = '\t'; }
        else if (sepMode === 'comma') { if (tline.split(',').length >= 3) sep = ','; }
        else if (sepMode === 'pipe') { if (tline.split('|').length >= 3) sep = '|'; }
        if (!sep) continue;
        if (tableRule.keepHeader && ti === 0) continue;
        var cells = tline.split(sep), changed = false;
        for (var ci = 0; ci < cells.length; ci++) {
          var cell = cells[ci].trim(); if (!cell) continue;
          if (tableRule.keepNumbers && /^[\d\s.,\-+%]+$/.test(cell)) continue;
          if (/^[\s\-|:+.]+$/.test(cell)) continue;
          var newCell = cell;
          for (var pi = 0; pi < cellSorted.length; pi++) {
            var p = cellSorted[pi];
            var cr = new RegExp(escapeRegex(p.original), 'g');
            if (cr.test(newCell)) { newCell = newCell.replace(new RegExp(escapeRegex(p.original), 'g'), p.replacement); }
          }
          if (newCell !== cell) { cells[ci] = newCell; tableCellsAffected++; changed = true; }
        }
        if (changed) { lines4t[ti] = cells.join(sep); tableRowsAffected++; }
      }
      content = lines4t.join('\n');
      if (tableRowsAffected > 0) tStatsHtml = '<div style="font-size:13px;font-weight:700;color:#d4875a;margin-bottom:8px;' + (btStatsHtml?'margin-top:12px;padding-top:12px;border-top:1px solid #f0e0d0;':'') + '">表格规则:按映射对替换 ' + tableCellsAffected + ' 个单元格,涉及 ' + tableRowsAffected + ' 行</div>';
    }
  }

  // Phase 1.6: JS data block rule
  var jStatsHtml = '';
  if (jsDataRule) {
    var jsResult = randomizeJsDataText(content, jsDataRule, mappingPairs);
    content = jsResult.html;
    if (jsResult.replaced > 0) {
      jStatsHtml = '<div style="font-size:13px;font-weight:700;color:#d4875a;margin-bottom:8px;' + (btStatsHtml || tStatsHtml ? 'margin-top:12px;padding-top:12px;border-top:1px solid #f0e0d0;' : '') + '">JS数据块规则:替换 ' + jsResult.replaced + ' 处字符串值,涉及 ' + jsResult.variables + ' 个变量 / ' + jsResult.blocks + ' 个脚本块</div>';
      totalReplacements += jsResult.replaced;
    }
  }

  // Phase 1.7: All-Chinese replace (全量兜底)
  var aStatsHtml = '';
  if (allCnRule) {
    var acResult = randomizeAllChinese(content, mappingPairs);
    content = acResult.html;
    if (acResult.replaced > 0) {
      aStatsHtml = '<div style="font-size:13px;font-weight:700;color:#d4875a;margin-bottom:8px;' + (btStatsHtml || tStatsHtml || jStatsHtml ? 'margin-top:12px;padding-top:12px;border-top:1px solid #f0e0d0;' : '') + '">全量中文规则:随机替换 ' + acResult.replaced + ' 处中文片段(结构性中文已保留)</div>';
      totalReplacements += acResult.replaced;
    }
  }

  // Phase 2: Manual mapping pairs (longest key first)

  if (mappingPairs.length > 0) {
    var sorted = mappingPairs.slice().sort(function(a,b){return b.original.length-a.original.length;});
    var lines = content.split('\n'), resultLines = [];
    for (var li = 0; li < lines.length; li++) {
      var line = lines[li];
      for (var pi = 0; pi < sorted.length; pi++) {
        var p = sorted[pi], regex = new RegExp(escapeRegex(p.original), 'g');
        var matches = line.match(regex); if (matches && matches.length > 0) { totalReplacements += matches.length; hitKeys[p.original] = p.replacement; }
        line = line.replace(regex, p.replacement);
      }
      resultLines.push(line);
    }
    content = resultLines.join('\n');
  }
  var elapsed = Math.round(performance.now() - t0);
  var outLines = content.split('\n');
  var dotIdx = srcFileName.lastIndexOf('.');
  var baseName = dotIdx > 0 ? srcFileName.substring(0, dotIdx) : srcFileName;
  var ext = dotIdx > 0 ? srcFileName.substring(dotIdx) : '.txt';
  var outName = baseName + '\u5df2\u8131\u654f' + ext;
  downloadFile(outName, content);
  document.getElementById('statLines').textContent = outLines.length;
  document.getElementById('statReplacements').textContent = totalReplacements;
  document.getElementById('statKeys').textContent = Object.keys(hitKeys).length;
  document.getElementById('statTime').textContent = elapsed;
  var statsArea = document.getElementById('ruleStatsArea');
  if (btStatsHtml || tStatsHtml || jStatsHtml || aStatsHtml) { statsArea.innerHTML = btStatsHtml + tStatsHtml + jStatsHtml + aStatsHtml; statsArea.classList.remove('hidden'); }
  else { statsArea.classList.add('hidden'); }
  document.getElementById('resultCard').classList.remove('hidden');
  document.getElementById('step3').className = 'step done';
  showToast('\u66ff\u6362\u5b8c\u6210\uff01\u6587\u4ef6 ' + outName + ' \u5df2\u4e0b\u8f7d', 'success');
}

function downloadFile(name, content) {
  var blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
  var url = URL.createObjectURL(blob); var a = document.createElement('a');
  a.href = url; a.download = name; document.body.appendChild(a); a.click();
  setTimeout(function() { document.body.removeChild(a); URL.revokeObjectURL(url); }, 100);
}

// ============ Toast ============
var toastTimer;
function showToast(msg, type) {
  var el = document.getElementById('toast'); el.textContent = msg; el.className = 'toast toast-' + type + ' show';
  clearTimeout(toastTimer); toastTimer = setTimeout(function() { el.classList.remove('show'); }, 2500);
}

// ============ HTML Preview File Handler (Tab 1) ============
function handleHtmlPreviewFile(e) {
  var file = e.target.files[0]; if (!file) return;
  var reader = new FileReader();
  reader.onload = function(ev) {
    showHtmlPreview(ev.target.result);
    document.getElementById('htmlFileInfo').textContent = file.name + ' (' + (file.size/1024).toFixed(1) + ' KB)';
    document.getElementById('htmlFileInfo').classList.remove('hidden');
    document.getElementById('htmlFileArea').classList.add('loaded');
    showToast('HTML \u5df2\u52a0\u8f7d\uff0c\u9009\u4e2d\u6587\u5b57\u53ef\u5feb\u901f\u6dfb\u52a0\u66ff\u6362\u5bf9', 'success');
  };
  reader.readAsText(file, 'UTF-8');
}

// ============ HTML Table Text Randomize (for executeReplace) ============
function isNumericCell(text) {
  if (/^[\d\s.,\-+%]+$/.test(text)) return true;
  if (/^[\u00a5$\uff04\u20ac]?[\d,\.]+\s*[\u4e07\u4ebf\u5143\u7f8e\u6e2f\u65e5\u6b27\u4eba\u6c11\u5e01]*$/.test(text)) return true;
  if (/^[\u4e00-\u9fff]{0,4}[\d,\.]+[%]$/.test(text)) return true;
  return false;
}
function isDateCell(text) {
  if (!text || text.length < 4) return false;
  if (/^\d{4}[-\/\.](\d{1,2})([-\/\.](\d{1,2}))?$/.test(text)) return true;
  if (/^\d{4}\u5e74\d{1,2}\u6708(\d{1,2}\u65e5)?$/.test(text)) return true;
  if (/^\d{1,2}\u6708\d{1,2}\u65e5?$/.test(text)) return true;
  if (/^[Qq][1-4]$/.test(text) || /^\u7b2c[\u4e00\u4e8c\u4e09\u56db1-4]\u5b63\u5ea6?$/.test(text)) return true;
  if (/^[\u4e0a\u4e0b]\u534a\u5e74$|^[\u4e00\u4e8c\u4e09\u56db]\u5b63\u5ea6$/.test(text)) return true;
  if (/^\d{4}\u5e74\d{1,2}\u6708?\s*[-~\u81f3]\s*\d{1,2}\u6708?/.test(text)) return true;
  return false;
}
function shouldSkipCell(text) {
  if (!text || text.trim().length === 0) return true;
  var t = text.trim();
  if (isNumericCell(t)) return true;
  if (isDateCell(t)) return true;
  if (/^[\s\-|:+.]+$/.test(t)) return true;
  return false;
}
function stripHtmlTags(html) { return html.replace(/<[^>]*>/g, '').trim(); }
function replaceAllTextInCell(cellHtml) {
  if (!/[\u4e00-\u9fff]/.test(cellHtml)) return cellHtml;
  // Plain text cell (no inner HTML tags): replace Chinese chars directly
  if (!/<[a-zA-Z\/]/.test(cellHtml)) {
    return cellHtml.replace(/[\u4e00-\u9fff]+/g, function(match) { return randomTitleText(match.length); });
  }
  // Cell with inner HTML tags: protect tags, replace Chinese in text nodes, restore tags
  var protected = cellHtml.replace(/</g, '\x01').replace(/>/g, '\x02');
  protected = protected.replace(/[\u4e00-\u9fff]+/g, function(match) { return randomTitleText(match.length); });
  return protected.replace(/\x01/g, '<').replace(/\x02/g, '>');
}
function randomizeHtmlTableText(html, rule) {
  var result = { html: html, replaced: 0, skipped: 0, tables: 0 };
  var tableRegex = /<table[^>]*>[\s\S]*?<\/table>/gi;
  var match;
  while ((match = tableRegex.exec(html)) !== null) {
    result.tables++;
    var tableBlock = match[0];
    var theadMatch = tableBlock.match(/<thead[^>]*>[\s\S]*?<\/thead>/i);
    var theadHtml = theadMatch ? theadMatch[0] : '';
    var rows = tableBlock.split(/<\/tr>\s*/i);
    var headerRowCount = 0;
    if (theadHtml) { headerRowCount = (theadHtml.match(/<tr[\s>]/gi) || []).length; }
    else { var firstRow = rows[0] || ''; if (/<th[\s>]/i.test(firstRow)) headerRowCount = 1; }
    for (var ri = headerRowCount; ri < rows.length; ri++) {
      var row = rows[ri];
      var tdRegex = /<td[^>]*>([\s\S]*?)<\/td>/gi;
      var cellMatches = []; var cm;
      while ((cm = tdRegex.exec(row)) !== null) cellMatches.push({ full: cm[0], inner: cm[1], index: cm.index });
      for (var ci = cellMatches.length - 1; ci >= 0; ci--) {
        var cm2 = cellMatches[ci];
        var text = stripHtmlTags(cm2.inner);
        if (shouldSkipCell(text)) { result.skipped++; continue; }
        var newInner = replaceAllTextInCell(cm2.inner);
        var newText = stripHtmlTags(newInner);
        if (newText === text) { result.skipped++; continue; }
        row = row.substring(0, cm2.index) + cm2.full.replace(cm2.inner, newInner) + row.substring(cm2.index + cm2.full.length);
        result.replaced++;
      }
      rows[ri] = row;
    }
    var newTable = rows.join('</tr>');
    html = html.substring(0, match.index) + newTable + html.substring(match.index + match[0].length);
    tableRegex.lastIndex = match.index + newTable.length;
  }
  result.html = html;
  return result;
}

// ============ JS Data Block Randomize (for executeReplace) ============
// 目标:识别 <script> 内 const/var/let 声明的对象/数组数据,仅替换字符串「值」中的中文,
// 属性名(键)保持不变,从而保证 renderTable() 等按 key 取值时映射关系不被破坏。
function findMatchingBracket(s, openIdx, openChar, closeChar) {
  var depth = 0, inStr = false, strChar = '', i;
  for (i = openIdx; i < s.length; i++) {
    var c = s[i];
    if (inStr) {
      if (c === '\\') { i++; continue; }
      if (c === strChar) inStr = false;
      continue;
    }
    if (c === '"' || c === "'") { inStr = true; strChar = c; continue; }
    if (c === openChar) depth++;
    else if (c === closeChar) { depth--; if (depth === 0) return i; }
  }
  return -1;
}
function protectDatesInStr(s) {
  var tokens = [];
  s = s.replace(/\d{4}[-\/.]\d{1,2}([-\/.]\d{1,2})?/g, function(m){ tokens.push(m); return '\x01' + (tokens.length - 1) + '\x02'; });
  s = s.replace(/\d{1,4}年\d{1,2}月(\d{1,2}日)?/g, function(m){ tokens.push(m); return '\x01' + (tokens.length - 1) + '\x02'; });
  s = s.replace(/\d{1,2}月\d{1,2}日/g, function(m){ tokens.push(m); return '\x01' + (tokens.length - 1) + '\x02'; });
  return { text: s, tokens: tokens };
}
function restoreDatesInStr(s, tokens) {
  return s.replace(/\x01(\d+)\x02/g, function(m, idx){ return tokens[+idx] || m; });
}
function randomizeChineseInStr(s) {
  return s.replace(/[\u4e00-\u9fff]+/g, function(match){ return randomTitleText(match.length); });
}
function replaceMappingInStr(s, pairs) {
  if (!pairs || pairs.length === 0) return s;
  var sorted = pairs.slice().sort(function(a,b){return b.original.length - a.original.length;});
  for (var i = 0; i < sorted.length; i++) { s = s.split(sorted[i].original).join(sorted[i].replacement); }
  return s;
}
function processJsStringValue(str, rule, pairs) {
  if (!/[\u4e00-\u9fff]/.test(str)) return str;
  if (rule.mode === 'mapping') return replaceMappingInStr(str, pairs);
  // random 模式:先应用映射对(让指定项按需替换),再对剩余中文随机替换,日期/数字保留
  var mapped = (pairs && pairs.length) ? replaceMappingInStr(str, pairs) : str;
  if (!/[\u4e00-\u9fff]/.test(mapped)) return mapped;
  var prot = protectDatesInStr(mapped);
  var randomized = randomizeChineseInStr(prot.text);
  return restoreDatesInStr(randomized, prot.tokens);
}
function replaceInJsLiteral(literal, rule, pairs) {
  var res = '', idx = 0, n = literal.length, count = 0;
  while (idx < n) {
    var c = literal[idx];
    if (c === '"' || c === "'") {
      var quote = c, j = idx + 1, str = '';
      while (j < n) {
        var ch = literal[j];
        if (ch === '\\') { str += ch + (j + 1 < n ? literal[j + 1] : ''); j += 2; continue; }
        if (ch === quote) break;
        str += ch; j++;
      }
      var isKey = /^\s*:/.test(literal.substring(j + 1));
      if (isKey) { res += literal.substring(idx, j + 1); }
      else {
        var newStr = processJsStringValue(str, rule, pairs);
        if (newStr !== str) { res += quote + newStr + quote; count++; }
        else { res += literal.substring(idx, j + 1); }
      }
      idx = j + 1;
      continue;
    }
    res += c; idx++;
  }
  return { text: res, count: count };
}
function randomizeJsDataText(html, rule, pairs) {
  var result = { html: html, replaced: 0, blocks: 0, variables: 0 };
  var targets = (rule.target || 'data').split(',').map(function(s){return s.trim();}).filter(function(s){return s;});
  if (targets.length === 0) targets = ['data'];
  var scriptRegex = /<script([^>]*)>([\s\S]*?)<\/script>/gi;
  var sm;
  while ((sm = scriptRegex.exec(html)) !== null) {
    var attrs = sm[1], body = sm[2], origBody = body;
    if (/\bsrc\s*=/.test(attrs)) continue;
    if (!/[\u4e00-\u9fff]/.test(body)) continue; // 跳过无中文的脚本(第三方库等)
    var bodyChanged = false;
    for (var ti = 0; ti < targets.length; ti++) {
      var vn = targets[ti], searchFrom = 0;
      while (true) {
        var declRegex = new RegExp('(\\b(?:const|var|let)\\s+' + escapeRegex(vn) + '\\s*=\\s*)([\\[\\{])', 'g');
        declRegex.lastIndex = searchFrom;
        var dm = declRegex.exec(body);
        if (!dm) break;
        var openChar = dm[2], closeChar = openChar === '[' ? ']' : '}';
        var openIdx = dm.index + dm[0].length - 1;
        var endIdx = findMatchingBracket(body, openIdx, openChar, closeChar);
        if (endIdx === -1) { searchFrom = dm.index + 1; break; }
        var literal = body.substring(openIdx, endIdx + 1);
        var r = replaceInJsLiteral(literal, rule, pairs);
        if (r.count > 0) {
          body = body.substring(0, openIdx) + r.text + body.substring(endIdx + 1);
          result.replaced += r.count;
          result.variables++;
          bodyChanged = true;
        }
        searchFrom = openIdx + r.text.length;
      }
    }
    if (bodyChanged) {
      result.blocks++;
      var newScript = '<script' + attrs + '>' + body + '</scr' + 'ipt>';
      html = html.substring(0, sm.index) + newScript + html.substring(sm.index + sm[0].length);
      scriptRegex.lastIndex = sm.index + newScript.length;
    }
  }
  result.html = html;
  return result;
}

// ============ All-Chinese Randomize (全量中文替换) ============
// 目标:一键彻底脱敏。先应用映射对,再把页面里所有"内容性中文"随机替换,
// 同时自动保护"结构性中文"(HTML id/class/name 属性、JS 对象键名、data['键'] 引用、中文变量名),
// 避免页面因标识符被改而报错。日期与数字保留。
function randomizeAllChinese(html, pairs) {
  var store = [];
  var S = function(v) { store.push(v); return '\x03' + (store.length - 1) + '\x04'; };

  // 1. 收集中文变量/函数名(const/let/var/function 声明)
  var cnVars = {};
  var declRe = /\b(?:const|let|var|function)\s+([\u4e00-\u9fff][\u4e00-\u9fff\w]*)/g, dm;
  while ((dm = declRe.exec(html)) !== null) cnVars[dm[1]] = true;
  // 保护声明处的变量名
  html = html.replace(/\b((?:const|let|var|function)\s+)([\u4e00-\u9fff][\u4e00-\u9fff\w]*)/g, function(m, kw, name) {
    return cnVars[name] ? kw + S(name) : m;
  });
  // 保护引用处的变量名(裸标识符)
  Object.keys(cnVars).forEach(function(vn) { html = html.split(vn).join(S(vn)); });

  // 2. 保护 JS 对象键名 "中文键":
  html = html.replace(/(["'])([^"']*[\u4e00-\u9fff][^"']*)(["'])(?=\s*:)/g, function(m, q1, k, q2) {
    return q1 + S(k) + q2;
  });

  // 3. 保护键引用 obj['中文键'] / data["中文键"]
  html = html.replace(/(\b[A-Za-z_$][\w$]*\s*\[\s*["'])([^"']*[\u4e00-\u9fff][^"']*)(["']\s*\])/g, function(m, pre, k, post) {
    return pre + S(k) + post;
  });

  // 4. 保护 HTML 结构属性 id/class/name/for 的值(含中文)
  html = html.replace(/(\b(?:id|class|name|for)=")([^"]*[\u4e00-\u9fff][^"]*)(")/g, function(m, pre, v, post) {
    return pre + S(v) + post;
  });

  // 5. 应用映射对(替换结果用占位符保护,避免被后续"随机替换剩余中文"覆盖)
  if (pairs && pairs.length) {
    var sorted = pairs.slice().sort(function(a,b){return b.original.length - a.original.length;});
    for (var i = 0; i < sorted.length; i++) {
      var p = sorted[i];
      html = html.split(p.original).join(S(p.replacement));
    }
  }

  // 6. 保护日期
  var dateStore = [];
  html = html.replace(/\d{4}[-\/.]\d{1,2}([-\/.]\d{1,2})?/g, function(m){ dateStore.push(m); return '\x05' + (dateStore.length-1) + '\x06'; });
  html = html.replace(/\d{1,4}年\d{1,2}月(\d{1,2}日)?/g, function(m){ dateStore.push(m); return '\x05' + (dateStore.length-1) + '\x06'; });
  html = html.replace(/\d{1,2}月\d{1,2}日/g, function(m){ dateStore.push(m); return '\x05' + (dateStore.length-1) + '\x06'; });

  // 7. 随机替换剩余中文
  var replaced = 0;
  html = html.replace(/[\u4e00-\u9fff]+/g, function(m){ replaced++; return randomTitleText(m.length); });

  // 8. 还原日期
  html = html.replace(/\x05(\d+)\x06/g, function(m, idx){ return dateStore[+idx] || m; });

  // 9. 还原结构性中文
  html = html.replace(/\x03(\d+)\x04/g, function(m, idx){ return store[+idx] || m; });

  return { html: html, replaced: replaced };
}

// ============ HTML Preview & Select-to-Replace ============
var selPopupTimer = null;
function showHtmlPreview(htmlContent) {
  var card = document.getElementById('htmlPreviewCard'); card.classList.remove('hidden');
  var iframe = document.getElementById('htmlPreviewIframe'); iframe.srcdoc = htmlContent;
  iframe.onload = function() {
    try { var doc = iframe.contentDocument || iframe.contentWindow.document;
      doc.addEventListener('mouseup', handleIframeMouseUp); doc.addEventListener('touchend', handleIframeMouseUp); } catch(e) {}
  };
}
function closeHtmlPreview() { document.getElementById('htmlPreviewCard').classList.add('hidden'); }
function handleIframeMouseUp(e) {
  clearTimeout(selPopupTimer);
  selPopupTimer = setTimeout(function() {
    var iframe = document.getElementById('htmlPreviewIframe');
    var iWin = iframe.contentWindow || window; var sel = iWin.getSelection();
    if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
    var text = sel.toString().trim(); if (text.length < 1) return;
    var range = sel.getRangeAt(0); var rect = range.getBoundingClientRect();
    var iframeRect = iframe.getBoundingClientRect();
    showSelPopup(text, { left: rect.left+iframeRect.left, top: rect.top+iframeRect.top, right: rect.right+iframeRect.left, bottom: rect.bottom+iframeRect.top, width: rect.width, height: rect.height });
  }, 150);
}
function showSelPopup(text, rect) {
  var popup = document.getElementById('selPopup');
  document.getElementById('selOriginalText').textContent = text; document.getElementById('selReplaceInput').value = '';
  var pw=340, ph=200, left=rect.left+rect.width/2-pw/2, top=rect.top-ph-12;
  if (left<8) left=8; if (left+pw>window.innerWidth-8) left=window.innerWidth-pw-8; if (top<8) top=rect.bottom+12;
  popup.style.left=left+'px'; popup.style.top=top+'px';
  var arrow=popup.querySelector('.sel-arrow');
  if (top<rect.top) { var al=rect.left+rect.width/2-left-6; if(al<10)al=10; if(al>pw-20)al=pw-20; arrow.style.left=al+'px'; arrow.style.top='-6px'; arrow.style.bottom='auto'; arrow.style.borderLeft='1px solid #e0e4e8'; arrow.style.borderTop='1px solid #e0e4e8'; arrow.style.borderBottom='none'; arrow.style.borderRight='none'; }
  else { var al2=rect.left+rect.width/2-left-6; if(al2<10)al2=10; if(al2>pw-20)al2=pw-20; arrow.style.left=al2+'px'; arrow.style.top='auto'; arrow.style.bottom='-6px'; arrow.style.borderBottom='1px solid #e0e4e8'; arrow.style.borderRight='1px solid #e0e4e8'; arrow.style.borderLeft='none'; arrow.style.borderTop='none'; }
  popup.classList.add('show'); setTimeout(function(){document.getElementById('selReplaceInput').focus();},100);
}
function hideSelPopup() {
  document.getElementById('selPopup').classList.remove('show');
  try { var iframe=document.getElementById('htmlPreviewIframe'); var iSel=(iframe.contentWindow||window).getSelection(); if(iSel)iSel.removeAllRanges(); } catch(e) {}
  var sel=window.getSelection(); if(sel)sel.removeAllRanges();
}
function selRandomFill() { document.getElementById('selReplaceInput').value=randomName(); }
function confirmSelReplace() {
  var origText=document.getElementById('selOriginalText').textContent;
  var replText=document.getElementById('selReplaceInput').value.trim();
  if (!replText) { showToast('\u8bf7\u8f93\u5165\u66ff\u6362\u6587\u672c','error'); return; }
  if (origText===replText) { showToast('\u66ff\u6362\u6587\u672c\u4e0d\u80fd\u4e0e\u539f\u6587\u76f8\u540c','error'); return; }
  for (var i=0;i<pairs.length;i++) { if(pairs[i].original===origText){showToast('\u8be5\u539f\u6587\u5df2\u5b58\u5728\u4e8e\u66ff\u6362\u5217\u8868\u4e2d','error');return;} }
  pairs.push({original:origText,replacement:replText}); renderTable(); updateLocalBadge(); hideSelPopup();
  showToast('\u5df2\u6dfb\u52a0\u66ff\u6362\u5bf9\uff1a'+origText+' \u2192 '+replText,'success');
}
function updateLocalBadge() { var b=document.getElementById('localPairBadge'); if(b)b.textContent=pairs.length; }
function closeSelPopup(e) {
  var popup=document.getElementById('selPopup'); if(!popup.classList.contains('show'))return; if(popup.contains(e.target))return;
  setTimeout(function(){ try{var iframe=document.getElementById('htmlPreviewIframe');var iSel=(iframe.contentWindow||window).getSelection();if(iSel&&!iSel.isCollapsed&&iSel.toString().trim())return;}catch(e){} var sel=window.getSelection();if(!sel||sel.isCollapsed||!sel.toString().trim()){hideSelPopup();} },250);
}
document.addEventListener('mousedown',closeSelPopup);
document.addEventListener('keydown',function(e){ var popup=document.getElementById('selPopup'); if(!popup.classList.contains('show'))return; if(e.key==='Escape'){hideSelPopup();e.preventDefault();} if(e.key==='Enter'&&e.target.id==='selReplaceInput'){confirmSelReplace();e.preventDefault();} });

// ============ Keyboard ============
document.addEventListener('keydown',function(e){ if(e.key==='Enter'&&(e.target.id==='originalText'||e.target.id==='replaceText'))addPair(); });
</script>
</body>
</html>
相关推荐
zzzll111110 小时前
Langfuse 全面解析:开源 LLM 应用监控与评估平台
microsoft·开源
Code_流苏11 小时前
Pot:免费开源的跨平台划词翻译和 OCR 工具,选中文字就能翻译
开源·ocr·pot·自动翻译·翻译工具·划词翻译
冬奇Lab12 小时前
开源项目第185期:Orca — YC 支持的 AI Agent 开发环境,并行运行多个 Coding Agent、手机远程监控、Design Mode 直接截图喂给 AI
人工智能·开源·资讯
dong_junshuai15 小时前
每天一个开源项目#68 Needle:14MB 工具调用小模型装进口袋设备
程序员·开源·github
东方小月15 小时前
从零开发一个 Coding Agent(十):实现 CLI 参数解析与静态命令
前端·人工智能·开源
Bifrost16 小时前
使用飞书机器人让 Codex 24 小时在线:随时随地给你的 AI 工程师派活
chatgpt·开源·openai
Joy T16 小时前
Agent 开源项目全景解析(下):LlamaIndex、Dify、FastGPT 与真实工程选型
langchain·开源·框架·agent·springai·langgraph·mcp
小饕18 小时前
ONNX与国产AI芯片生态全解析:从格式标准到工具开源状态
人工智能·开源·大模型端侧部署
QQ54164512118 小时前
【驿帮查件助手python开源】开源聚合查件机器人,实现 24 类驿站批量查件取代短信通知。技术方案分享
python·机器人·开源·驿站查件机器人