789 lines
30 KiB
HTML
789 lines
30 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||
<title>Shudao · OCR 表格转可编辑表格</title>
|
||
<style>
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0; padding: 0;
|
||
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
|
||
background: #f5f6f8;
|
||
color: #222;
|
||
}
|
||
|
||
/* ----- Header ----- */
|
||
header {
|
||
background: #fff;
|
||
border-bottom: 1px solid #e6e8eb;
|
||
padding: 14px 24px;
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
position: sticky; top: 0; z-index: 100;
|
||
}
|
||
header h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
||
header .sub { color: #888; font-size: 12px; margin-left: 8px; }
|
||
header .actions { display: flex; gap: 8px; }
|
||
header button, .btn {
|
||
padding: 7px 14px; border: 1px solid #d9d9d9; background: #fff;
|
||
border-radius: 4px; cursor: pointer; font-size: 13px;
|
||
transition: all 0.15s;
|
||
}
|
||
header button:hover, .btn:hover { border-color: #1677ff; color: #1677ff; }
|
||
header button.primary, .btn.primary {
|
||
background: #1677ff; color: #fff; border-color: #1677ff;
|
||
}
|
||
header button.primary:hover, .btn.primary:hover {
|
||
background: #4096ff; border-color: #4096ff;
|
||
}
|
||
header button:disabled, .btn:disabled {
|
||
background: #f5f5f5; color: #bbb; border-color: #e8e8e8; cursor: not-allowed;
|
||
}
|
||
|
||
/* ----- Upload panel ----- */
|
||
.upload-panel {
|
||
background: #fff;
|
||
margin: 20px;
|
||
padding: 20px 24px;
|
||
border-radius: 8px;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||
}
|
||
.upload-panel.hidden { display: none; }
|
||
.upload-panel h2 {
|
||
margin: 0 0 14px; font-size: 15px; font-weight: 600;
|
||
}
|
||
.upload-panel .grid {
|
||
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px;
|
||
align-items: end;
|
||
}
|
||
.upload-panel .field { display: flex; flex-direction: column; gap: 6px; }
|
||
.upload-panel label { font-size: 12px; color: #666; }
|
||
.upload-panel input[type="text"],
|
||
.upload-panel input[type="password"],
|
||
.upload-panel input[type="file"] {
|
||
padding: 7px 10px; border: 1px solid #d9d9d9; border-radius: 4px;
|
||
font-size: 13px; font-family: inherit;
|
||
}
|
||
.upload-panel input:focus { outline: none; border-color: #1677ff; }
|
||
.upload-panel .help { font-size: 11px; color: #999; margin-top: 4px; }
|
||
.upload-panel .actions {
|
||
display: flex; gap: 10px; margin-top: 18px; align-items: center;
|
||
}
|
||
.upload-panel .status {
|
||
font-size: 13px; color: #666; margin-left: 8px;
|
||
}
|
||
.upload-panel .status.error { color: #cf1322; }
|
||
.upload-panel .status.ok { color: #389e0d; }
|
||
.upload-panel .error-detail {
|
||
margin: 12px 0 0;
|
||
padding: 10px 12px;
|
||
background: #fff2f0;
|
||
border: 1px solid #ffccc7;
|
||
border-radius: 4px;
|
||
color: #5c0011;
|
||
font-size: 12px;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
max-height: 240px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
/* ----- Result area ----- */
|
||
.result-area { display: none; padding: 0 20px 20px; }
|
||
.result-area.visible { display: flex; gap: 16px; align-items: flex-start; }
|
||
.spreadsheet-wrap {
|
||
flex: 1 1 auto; min-width: 0;
|
||
background: #fff; border-radius: 8px;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||
padding: 0; overflow: hidden;
|
||
}
|
||
#xspreadsheet {
|
||
width: 100%; height: calc(100vh - 120px); min-height: 500px;
|
||
}
|
||
|
||
/* ----- TOC (fixed, compact) ----- */
|
||
.toc {
|
||
flex: 0 0 200px; /* hard cap; sidebar never grows */
|
||
position: sticky;
|
||
top: 70px;
|
||
max-height: calc(100vh - 90px);
|
||
overflow-y: auto;
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||
padding: 12px 0; /* no horizontal padding — items align flush */
|
||
}
|
||
.toc h3 {
|
||
margin: 0 14px 8px;
|
||
font-size: 12px; font-weight: 600; color: #333;
|
||
letter-spacing: 0.5px;
|
||
text-transform: uppercase;
|
||
border-bottom: 1px solid #eee;
|
||
padding-bottom: 8px;
|
||
}
|
||
.toc-list { list-style: none; padding: 0; margin: 0; }
|
||
.toc-item { display: block; }
|
||
.toc-link {
|
||
display: block; /* block so the click target is full row width,
|
||
but the visible "chip" hugs content via padding */
|
||
padding: 5px 14px;
|
||
color: #555; text-decoration: none;
|
||
font-size: 12.5px; line-height: 1.5;
|
||
cursor: pointer; user-select: none;
|
||
border-left: 2px solid transparent;
|
||
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.toc-link:hover { background: #f0f7ff; color: #1677ff; }
|
||
.toc-link.active {
|
||
background: #e6f4ff; color: #1677ff;
|
||
border-left-color: #1677ff; font-weight: 600;
|
||
}
|
||
.toc-link .row-tag {
|
||
color: #bbb; font-size: 10.5px; margin-left: 4px;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
}
|
||
.toc-link.active .row-tag { color: #69b1ff; }
|
||
.toc-list.nested { margin-left: 0; }
|
||
.toc-list.nested .toc-link { padding-left: 28px; font-size: 12px; color: #666; }
|
||
.toc-list.nested .toc-link.active { color: #1677ff; }
|
||
|
||
.toc-empty {
|
||
padding: 12px 14px; color: #999; font-size: 12px; font-style: italic;
|
||
}
|
||
|
||
/* ----- Loading overlay ----- */
|
||
.loading {
|
||
display: none; position: fixed; inset: 0;
|
||
background: rgba(255,255,255,0.7);
|
||
z-index: 200; align-items: center; justify-content: center;
|
||
flex-direction: column; gap: 12px;
|
||
}
|
||
.loading.visible { display: flex; }
|
||
.spinner {
|
||
width: 36px; height: 36px;
|
||
border: 3px solid #e6e8eb; border-top-color: #1677ff;
|
||
border-radius: 50%;
|
||
animation: spin 0.9s linear infinite;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
.loading .text { color: #555; font-size: 14px; }
|
||
|
||
/* ----- Dropdown menu ----- */
|
||
.menu-anchor { position: relative; }
|
||
.menu {
|
||
position: absolute; right: 0; top: calc(100% + 6px);
|
||
background: #fff; border: 1px solid #e6e8eb; border-radius: 6px;
|
||
box-shadow: 0 4px 16px rgba(0,0,0,0.10);
|
||
min-width: 200px; padding: 6px 0;
|
||
display: none; z-index: 150;
|
||
}
|
||
.menu.open { display: block; }
|
||
.menu-item {
|
||
display: block; width: 100%; text-align: left;
|
||
padding: 8px 14px; border: 0; background: transparent;
|
||
font-size: 13px; cursor: pointer; color: #333;
|
||
}
|
||
.menu-item:hover { background: #f0f7ff; color: #1677ff; }
|
||
.menu-item .desc { display: block; color: #999; font-size: 11px; margin-top: 2px; font-weight: normal; }
|
||
|
||
/* ----- TOC edit mode ----- */
|
||
.toc-toolbar {
|
||
display: flex; align-items: center; gap: 6px;
|
||
padding: 0 14px 8px; border-bottom: 1px solid #eee; margin-bottom: 6px;
|
||
}
|
||
.toc-toolbar h3 { margin: 0; border: 0; padding: 0; flex: 1; }
|
||
.toc-toolbar button {
|
||
padding: 3px 8px; font-size: 11px; border: 1px solid #d9d9d9;
|
||
background: #fff; border-radius: 3px; cursor: pointer; color: #555;
|
||
}
|
||
.toc-toolbar button:hover { border-color: #1677ff; color: #1677ff; }
|
||
.toc-toolbar button.active { background: #e6f4ff; border-color: #1677ff; color: #1677ff; }
|
||
|
||
.toc-list .toc-item-row {
|
||
display: flex; align-items: center;
|
||
padding: 4px 14px;
|
||
color: #555; font-size: 12.5px; line-height: 1.5;
|
||
cursor: pointer; user-select: none;
|
||
border-left: 2px solid transparent;
|
||
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.toc-list .toc-item-row:hover { background: #f0f7ff; color: #1677ff; }
|
||
.toc-list .toc-item-row.active {
|
||
background: #e6f4ff; color: #1677ff;
|
||
border-left-color: #1677ff; font-weight: 600;
|
||
}
|
||
.toc-list .toc-item-row .toc-label { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
||
.toc-list .toc-item-row .row-tag {
|
||
color: #bbb; font-size: 10.5px; margin-left: 4px;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
}
|
||
.toc-list .toc-item-row.active .row-tag { color: #69b1ff; }
|
||
.toc-list .toc-item-row .toc-actions {
|
||
display: none; gap: 2px; margin-left: 4px;
|
||
}
|
||
.toc.edit-mode .toc-item-row:hover { background: #fafafa; color: #333; }
|
||
.toc.edit-mode .toc-item-row.active { background: #fffbe6; color: #333; border-left-color: #faad14; }
|
||
.toc.edit-mode .toc-item-row .toc-actions { display: inline-flex; }
|
||
.toc-action-btn {
|
||
width: 20px; height: 20px;
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
border: 1px solid #e6e8eb; background: #fff; border-radius: 3px;
|
||
cursor: pointer; color: #888; font-size: 11px; line-height: 1;
|
||
}
|
||
.toc-action-btn:hover { border-color: #1677ff; color: #1677ff; }
|
||
.toc-action-btn.danger:hover { border-color: #ff4d4f; color: #ff4d4f; }
|
||
.toc-children {
|
||
list-style: none; padding: 0; margin: 0;
|
||
}
|
||
.toc-children .toc-item-row { padding-left: 28px; font-size: 12px; color: #666; }
|
||
.toc-children .toc-children .toc-item-row { padding-left: 42px; }
|
||
|
||
.toc-add-row {
|
||
margin: 4px 14px 8px;
|
||
padding: 6px 10px;
|
||
border: 1px dashed #d9d9d9; border-radius: 4px;
|
||
color: #999; font-size: 12px; cursor: pointer;
|
||
display: none; text-align: center;
|
||
}
|
||
.toc-add-row:hover { border-color: #1677ff; color: #1677ff; background: #f0f7ff; }
|
||
.toc.edit-mode .toc-add-row { display: block; }
|
||
|
||
.toc-row-form {
|
||
display: flex; gap: 4px; align-items: center; padding: 4px 14px;
|
||
}
|
||
.toc-row-form input {
|
||
flex: 1; min-width: 0;
|
||
padding: 4px 6px; border: 1px solid #1677ff; border-radius: 3px;
|
||
font-size: 12px; font-family: inherit; outline: none;
|
||
}
|
||
.toc-row-form input.row-input { flex: 0 0 50px; }
|
||
.toc-row-form .save, .toc-row-form .cancel {
|
||
padding: 4px 8px; font-size: 12px; cursor: pointer;
|
||
border-radius: 3px; border: 1px solid;
|
||
}
|
||
.toc-row-form .save {
|
||
background: #1677ff; border-color: #1677ff; color: #fff;
|
||
}
|
||
.toc-row-form .cancel {
|
||
background: #fff; border-color: #d9d9d9; color: #555;
|
||
}
|
||
</style>
|
||
<link rel="stylesheet" href="https://unpkg.com/x-data-spreadsheet@1.1.9/dist/xspreadsheet.css">
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<div>
|
||
<h1>Shudao <span class="sub">OCR 表格 → 可编辑在线表格</span></h1>
|
||
</div>
|
||
<div class="actions">
|
||
<div class="menu-anchor">
|
||
<button id="btn-import" class="primary">导入表格 ▾</button>
|
||
<div class="menu" id="import-menu">
|
||
<button class="menu-item" data-action="xlsx">
|
||
导入 .xlsx
|
||
<span class="desc">跳过 OCR,直接读取本地表格</span>
|
||
</button>
|
||
<button class="menu-item" data-action="json">
|
||
导入 .json
|
||
<span class="desc">读取阿里云 OCR 输出 JSON</span>
|
||
</button>
|
||
<button class="menu-item" data-action="demo">
|
||
加载示例(111.html / 111.xlsx)
|
||
<span class="desc">使用项目内置的演示数据</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<button id="btn-toggle-upload">新建转换</button>
|
||
</div>
|
||
</header>
|
||
|
||
<input type="file" id="file-xlsx" accept=".xlsx" style="display:none" />
|
||
<input type="file" id="file-json" accept=".json" style="display:none" />
|
||
|
||
<section class="upload-panel" id="upload-panel">
|
||
<h2>上传图片并转换</h2>
|
||
<form id="upload-form">
|
||
<div class="grid">
|
||
<div class="field">
|
||
<label for="image-input">表格图片 *</label>
|
||
<input id="image-input" type="file" name="image" accept="image/*" required />
|
||
<div class="help">支持 PNG / JPG / JPEG,建议清晰可读</div>
|
||
</div>
|
||
<div class="field">
|
||
<label for="ak-id">阿里云 AccessKey ID *</label>
|
||
<input id="ak-id" type="text" name="access_key_id" required placeholder="LTAI..." autocomplete="off"/>
|
||
</div>
|
||
<div class="field">
|
||
<label for="ak-secret">阿里云 AccessKey Secret *</label>
|
||
<input id="ak-secret" type="password" name="access_key_secret" required placeholder="••••••" autocomplete="off"/>
|
||
</div>
|
||
</div>
|
||
<div class="actions">
|
||
<button type="submit" class="primary" id="btn-convert">开始转换</button>
|
||
<span class="status" id="upload-status"></span>
|
||
</div>
|
||
<pre id="error-detail" class="error-detail" style="display:none"></pre>
|
||
</form>
|
||
</section>
|
||
|
||
<section class="result-area" id="result-area">
|
||
<div class="spreadsheet-wrap">
|
||
<div id="xspreadsheet"></div>
|
||
</div>
|
||
<aside class="toc" id="toc">
|
||
<div class="toc-toolbar">
|
||
<h3>目录</h3>
|
||
<button id="btn-toc-add-top" title="添加顶级条目">+ 顶级</button>
|
||
<button id="btn-toc-edit-toggle" title="编辑/查看模式切换">编辑</button>
|
||
<button id="btn-toc-reset" title="重置为自动提取">重置</button>
|
||
</div>
|
||
<div id="toc-container"></div>
|
||
</aside>
|
||
</section>
|
||
|
||
<div class="loading" id="loading">
|
||
<div class="spinner"></div>
|
||
<div class="text" id="loading-text">正在识别表格…</div>
|
||
</div>
|
||
|
||
<script src="https://unpkg.com/x-data-spreadsheet@1.1.9/dist/xspreadsheet.js"></script>
|
||
<script>
|
||
// ============================================================
|
||
// State
|
||
// ============================================================
|
||
let __ss = null; // spreadsheet instance (x-spreadsheet)
|
||
let __sheets = []; // normalized sheet array
|
||
let __tocState = []; // user-editable TOC, {sheetIdx, items: [nested]}
|
||
let __tocEditMode = false;
|
||
let __nextId = 1;
|
||
|
||
const $ = id => document.getElementById(id);
|
||
const uploadPanel = $('upload-panel');
|
||
const resultArea = $('result-area');
|
||
const loading = $('loading');
|
||
const loadingText = $('loading-text');
|
||
const statusEl = $('upload-status');
|
||
const tocContainer = $('toc-container');
|
||
const tocEl = $('toc');
|
||
const form = $('upload-form');
|
||
const importMenu = $('import-menu');
|
||
const fileXlsxInput = $('file-xlsx');
|
||
const fileJsonInput = $('file-json');
|
||
|
||
const newId = () => 'i' + (__nextId++) + '_' + Math.random().toString(36).slice(2, 6);
|
||
|
||
// ============================================================
|
||
// TOC model: { sheetIdx, items: [{id, label, row, children: [...]}] }
|
||
// ============================================================
|
||
function buildAutoToc(sheets) {
|
||
// Auto-extract from data (per sheet)
|
||
return sheets.map((sheet, sheetIdx) => ({
|
||
sheetIdx,
|
||
items: extractTocForSheet(sheet).map(it => ({ ...it, id: newId(), children: [] })),
|
||
}));
|
||
}
|
||
|
||
function extractTocForSheet(sheet) {
|
||
const items = [];
|
||
if (!sheet || !sheet.rows) return items;
|
||
const rows = sheet.rows;
|
||
const len = rows.len || 0;
|
||
const seen = new Set();
|
||
for (let i = 0; i < len; i++) {
|
||
const r = rows[i];
|
||
if (!r || !r.cells) continue;
|
||
const c0 = cellText(r, 0);
|
||
const c1 = cellText(r, 1);
|
||
const c2 = cellText(r, 2);
|
||
if (c0 && !seen.has('c0:' + c0)) {
|
||
items.push({ label: c0, row: i });
|
||
seen.add('c0:' + c0);
|
||
continue;
|
||
}
|
||
if (c1 && /^[0-9]+[A-Za-z]?$/.test(c1)) {
|
||
const label = c1 + (c2 ? ' ' + c2 : '');
|
||
if (!seen.has('c1:' + c1)) {
|
||
items.push({ label, row: i });
|
||
seen.add('c1:' + c1);
|
||
}
|
||
}
|
||
}
|
||
return items;
|
||
}
|
||
|
||
function cellText(row, col) {
|
||
if (!row || !row.cells) return null;
|
||
const c = row.cells[col];
|
||
if (!c) return null;
|
||
const t = (c.text || '').trim();
|
||
return t || null;
|
||
}
|
||
|
||
// ---- find/mutate helpers ----
|
||
function findItemById(items, id) {
|
||
for (const it of items) {
|
||
if (it.id === id) return { item: it, parent: items };
|
||
if (it.children && it.children.length) {
|
||
const found = findItemById(it.children, id);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function makeNewItem(label, row) {
|
||
return { id: newId(), label: label || '新条目', row: typeof row === 'number' ? row : 0, children: [] };
|
||
}
|
||
|
||
// ============================================================
|
||
// Render TOC
|
||
// ============================================================
|
||
function renderToc() {
|
||
tocContainer.innerHTML = '';
|
||
tocEl.classList.toggle('edit-mode', __tocEditMode);
|
||
$('btn-toc-edit-toggle').classList.toggle('active', __tocEditMode);
|
||
$('btn-toc-edit-toggle').textContent = __tocEditMode ? '完成' : '编辑';
|
||
|
||
let any = false;
|
||
__tocState.forEach(group => {
|
||
if (!group.items || !group.items.length) return;
|
||
any = true;
|
||
const ul = document.createElement('ul');
|
||
ul.className = 'toc-list';
|
||
group.items.forEach(item => ul.appendChild(buildItemEl(item, group.sheetIdx, 0)));
|
||
tocContainer.appendChild(ul);
|
||
});
|
||
if (!any) {
|
||
const e = document.createElement('div');
|
||
e.className = 'toc-empty';
|
||
e.textContent = '(此表未识别到目录项,点“+ 顶级”手动添加)';
|
||
tocContainer.appendChild(e);
|
||
}
|
||
}
|
||
|
||
function buildItemEl(item, sheetIdx, depth) {
|
||
const li = document.createElement('li');
|
||
li.className = 'toc-item';
|
||
li.dataset.id = item.id;
|
||
|
||
const row = document.createElement('div');
|
||
row.className = 'toc-item-row';
|
||
row.title = item.label + ' (row ' + item.row + ')';
|
||
row.addEventListener('click', (e) => {
|
||
if (e.target.closest('.toc-action-btn')) return; // action clicks don't navigate
|
||
if (__tocEditMode) return; // edit mode = no nav
|
||
scrollToRow(sheetIdx, item.row, row);
|
||
});
|
||
|
||
const lbl = document.createElement('span');
|
||
lbl.className = 'toc-label';
|
||
lbl.textContent = item.label;
|
||
const tag = document.createElement('span');
|
||
tag.className = 'row-tag';
|
||
tag.textContent = '·' + item.row;
|
||
row.appendChild(lbl);
|
||
row.appendChild(tag);
|
||
|
||
// edit actions
|
||
const actions = document.createElement('span');
|
||
actions.className = 'toc-actions';
|
||
actions.appendChild(iconBtn('✎', '编辑', () => startInlineEdit(item, row, sheetIdx)));
|
||
actions.appendChild(iconBtn('+', '添加子项', () => addChild(item), 'primary'));
|
||
actions.appendChild(iconBtn('↑', '上移', () => moveItem(item, -1)));
|
||
actions.appendChild(iconBtn('↓', '下移', () => moveItem(item, +1)));
|
||
actions.appendChild(iconBtn('×', '删除', () => deleteItem(item), 'danger'));
|
||
row.appendChild(actions);
|
||
|
||
li.appendChild(row);
|
||
|
||
if (item.children && item.children.length) {
|
||
const sub = document.createElement('ul');
|
||
sub.className = 'toc-children';
|
||
item.children.forEach(c => sub.appendChild(buildItemEl(c, sheetIdx, depth + 1)));
|
||
li.appendChild(sub);
|
||
}
|
||
|
||
return li;
|
||
}
|
||
|
||
function iconBtn(text, title, onclick, kind) {
|
||
const b = document.createElement('button');
|
||
b.className = 'toc-action-btn' + (kind ? ' ' + kind : '');
|
||
b.textContent = text;
|
||
b.title = title;
|
||
b.addEventListener('click', (e) => { e.stopPropagation(); onclick(); });
|
||
return b;
|
||
}
|
||
|
||
function startInlineEdit(item, rowEl, sheetIdx) {
|
||
// Replace row contents with editable inputs
|
||
const formEl = document.createElement('div');
|
||
formEl.className = 'toc-row-form';
|
||
const lblInput = document.createElement('input');
|
||
lblInput.type = 'text';
|
||
lblInput.value = item.label;
|
||
const rowInput = document.createElement('input');
|
||
rowInput.type = 'number';
|
||
rowInput.className = 'row-input';
|
||
rowInput.value = item.row;
|
||
rowInput.min = 0;
|
||
const save = document.createElement('button');
|
||
save.className = 'save'; save.textContent = '✓';
|
||
const cancel = document.createElement('button');
|
||
cancel.className = 'cancel'; cancel.textContent = '×';
|
||
formEl.appendChild(lblInput);
|
||
formEl.appendChild(rowInput);
|
||
formEl.appendChild(save);
|
||
formEl.appendChild(cancel);
|
||
rowEl.replaceWith(formEl);
|
||
lblInput.focus();
|
||
lblInput.select();
|
||
const commit = () => {
|
||
item.label = lblInput.value.trim() || '未命名';
|
||
const newRow = parseInt(rowInput.value, 10);
|
||
if (!isNaN(newRow) && newRow >= 0) item.row = newRow;
|
||
renderToc();
|
||
};
|
||
save.addEventListener('click', commit);
|
||
cancel.addEventListener('click', renderToc);
|
||
lblInput.addEventListener('keydown', e => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') renderToc(); });
|
||
rowInput.addEventListener('keydown', e => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') renderToc(); });
|
||
}
|
||
|
||
function moveItem(item, dir) {
|
||
const found = findItemById(__tocState.flatMap(g => g.items), item.id);
|
||
if (!found) return;
|
||
const arr = found.parent;
|
||
const idx = arr.indexOf(item);
|
||
const newIdx = idx + dir;
|
||
if (newIdx < 0 || newIdx >= arr.length) return;
|
||
arr.splice(idx, 1);
|
||
arr.splice(newIdx, 0, item);
|
||
renderToc();
|
||
}
|
||
|
||
function deleteItem(item) {
|
||
const all = __tocState.flatMap(g => g.items);
|
||
const found = findItemById(all, item.id);
|
||
if (!found) return;
|
||
if (!confirm('删除"' + item.label + '"及其子项?')) return;
|
||
found.parent.splice(found.parent.indexOf(item), 1);
|
||
renderToc();
|
||
}
|
||
|
||
function addChild(parentItem) {
|
||
parentItem.children = parentItem.children || [];
|
||
const newItem = makeNewItem('新子项', 0);
|
||
parentItem.children.push(newItem);
|
||
renderToc();
|
||
// immediately let user edit
|
||
setTimeout(() => {
|
||
const li = tocContainer.querySelector('[data-id="' + newItem.id + '"]');
|
||
if (li) {
|
||
const rowEl = li.querySelector('.toc-item-row');
|
||
if (rowEl) startInlineEdit(newItem, rowEl, 0);
|
||
}
|
||
}, 0);
|
||
}
|
||
|
||
function addTopLevel() {
|
||
if (!__tocState.length) {
|
||
__tocState.push({ sheetIdx: 0, items: [] });
|
||
}
|
||
const newItem = makeNewItem('新顶级项', 0);
|
||
__tocState[0].items.push(newItem);
|
||
renderToc();
|
||
setTimeout(() => {
|
||
const li = tocContainer.querySelector('[data-id="' + newItem.id + '"]');
|
||
if (li) {
|
||
const rowEl = li.querySelector('.toc-item-row');
|
||
if (rowEl) startInlineEdit(newItem, rowEl, 0);
|
||
}
|
||
}, 0);
|
||
}
|
||
|
||
function resetToc() {
|
||
if (!confirm('重置为自动提取的目录?自定义条目会丢失。')) return;
|
||
__tocState = buildAutoToc(__sheets);
|
||
renderToc();
|
||
}
|
||
|
||
// ============================================================
|
||
// Scroll to row (drives the library's vertical scrollbar)
|
||
// ============================================================
|
||
function getLiveSheetData(sheetIdx) {
|
||
if (!__ss) return null;
|
||
if (Array.isArray(__ss.datas) && __ss.datas[sheetIdx]) return __ss.datas[sheetIdx];
|
||
if (sheetIdx === 0 && __ss.data) return __ss.data;
|
||
return null;
|
||
}
|
||
|
||
function scrollToRow(sheetIdx, rowIndex, rowEl) {
|
||
if (!__ss) return;
|
||
document.querySelectorAll('.toc-item-row.active').forEach(el => el.classList.remove('active'));
|
||
if (rowEl) rowEl.classList.add('active');
|
||
try {
|
||
const live = getLiveSheetData(sheetIdx);
|
||
if (!live || !live.rows || typeof live.rows.sumHeight !== 'function') return;
|
||
const y = live.rows.sumHeight(0, rowIndex);
|
||
const vbar = __ss.sheet && __ss.sheet.verticalScrollbar;
|
||
if (vbar && vbar.el && vbar.el[0]) vbar.el[0].scrollTop = y;
|
||
} catch (e) { console.warn(e); }
|
||
}
|
||
|
||
// ============================================================
|
||
// Render spreadsheet
|
||
// ============================================================
|
||
function renderSheet(sheetData) {
|
||
const arr = Array.isArray(sheetData) ? sheetData : [sheetData];
|
||
__sheets = arr;
|
||
__tocState = buildAutoToc(arr);
|
||
if (typeof x_spreadsheet === 'undefined') {
|
||
alert('x-spreadsheet 库加载失败,请检查网络');
|
||
return;
|
||
}
|
||
document.getElementById('xspreadsheet').innerHTML = '';
|
||
__ss = x_spreadsheet('#xspreadsheet');
|
||
__ss.loadData(arr);
|
||
renderToc();
|
||
}
|
||
|
||
// ============================================================
|
||
// Common loader: fetch -> handle errors -> render
|
||
// ============================================================
|
||
async function loadFromUrl(url, opts) {
|
||
opts = opts || {};
|
||
setStatus(opts.startMsg || '加载中…');
|
||
hideError();
|
||
loading.classList.add('visible');
|
||
loadingText.textContent = opts.loadingText || '加载中…';
|
||
try {
|
||
const res = await fetch(url, opts.fetchOpts || {});
|
||
let data;
|
||
try { data = await res.json(); }
|
||
catch (e) {
|
||
setStatus('响应解析失败(HTTP ' + res.status + ')', 'error');
|
||
showError('服务器返回的不是 JSON');
|
||
loading.classList.remove('visible');
|
||
return;
|
||
}
|
||
if (!res.ok || !data.ok) {
|
||
const stage = data.stage ? '[' + data.stage + '] ' : '';
|
||
setStatus('失败:' + stage + (data.error || res.statusText), 'error');
|
||
let detail = '';
|
||
if (data.error) detail += 'error: ' + data.error + '\n';
|
||
if (data.stage) detail += 'stage: ' + data.stage + '\n';
|
||
if (data.trace) detail += '\n--- trace ---\n' + data.trace;
|
||
if (detail) showError(detail);
|
||
loading.classList.remove('visible');
|
||
return;
|
||
}
|
||
renderSheet(data.sheet_data);
|
||
resultArea.classList.add('visible');
|
||
uploadPanel.classList.add('hidden');
|
||
setStatus((opts.okMsg || '完成') + ' ✓', 'ok');
|
||
loading.classList.remove('visible');
|
||
} catch (err) {
|
||
setStatus('网络错误:' + err.message, 'error');
|
||
showError(String(err && err.stack || err));
|
||
loading.classList.remove('visible');
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Header buttons
|
||
// ============================================================
|
||
$('btn-toggle-upload').addEventListener('click', () => uploadPanel.classList.toggle('hidden'));
|
||
|
||
// Import menu toggle
|
||
$('btn-import').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
importMenu.classList.toggle('open');
|
||
});
|
||
document.addEventListener('click', () => importMenu.classList.remove('open'));
|
||
importMenu.addEventListener('click', (e) => e.stopPropagation());
|
||
|
||
importMenu.querySelectorAll('.menu-item').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
importMenu.classList.remove('open');
|
||
const action = btn.dataset.action;
|
||
if (action === 'xlsx') fileXlsxInput.click();
|
||
else if (action === 'json') fileJsonInput.click();
|
||
else if (action === 'demo') loadFromUrl('/api/load_demo', {
|
||
startMsg: '加载示例…', loadingText: '加载 111.html / 111.xlsx…', okMsg: '已加载示例',
|
||
});
|
||
});
|
||
});
|
||
|
||
// File pickers -> upload
|
||
fileXlsxInput.addEventListener('change', async () => {
|
||
if (!fileXlsxInput.files.length) return;
|
||
const fd = new FormData();
|
||
fd.append('file', fileXlsxInput.files[0]);
|
||
fileXlsxInput.value = '';
|
||
await loadFromUrl('/api/load_xlsx', {
|
||
fetchOpts: { method: 'POST', body: fd },
|
||
startMsg: '解析 xlsx…', loadingText: '正在解析 .xlsx…', okMsg: 'xlsx 已加载',
|
||
});
|
||
});
|
||
fileJsonInput.addEventListener('change', async () => {
|
||
if (!fileJsonInput.files.length) return;
|
||
const fd = new FormData();
|
||
fd.append('file', fileJsonInput.files[0]);
|
||
fileJsonInput.value = '';
|
||
await loadFromUrl('/api/load_json', {
|
||
fetchOpts: { method: 'POST', body: fd },
|
||
startMsg: '解析 json…', loadingText: '正在解析 .json…', okMsg: 'json 已加载',
|
||
});
|
||
});
|
||
|
||
// ============================================================
|
||
// OCR upload form
|
||
// ============================================================
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(form);
|
||
if (!fd.get('image') || !fd.get('image').name) { setStatus('请选择图片', 'error'); return; }
|
||
if (!fd.get('access_key_id') || !fd.get('access_key_secret')) {
|
||
setStatus('请填写 AccessKey ID 和 Secret', 'error'); return;
|
||
}
|
||
await loadFromUrl('/api/convert', {
|
||
fetchOpts: { method: 'POST', body: fd },
|
||
startMsg: '上传中…', loadingText: '正在上传并调用 OCR…', okMsg: '转换完成',
|
||
});
|
||
});
|
||
|
||
// ============================================================
|
||
// TOC toolbar
|
||
// ============================================================
|
||
$('btn-toc-edit-toggle').addEventListener('click', () => {
|
||
__tocEditMode = !__tocEditMode;
|
||
renderToc();
|
||
});
|
||
$('btn-toc-add-top').addEventListener('click', addTopLevel);
|
||
$('btn-toc-reset').addEventListener('click', resetToc);
|
||
|
||
// ============================================================
|
||
// Status helpers
|
||
// ============================================================
|
||
function setStatus(msg, kind) {
|
||
statusEl.textContent = msg;
|
||
statusEl.className = 'status' + (kind ? ' ' + kind : '');
|
||
}
|
||
function showError(text) {
|
||
const el = $('error-detail');
|
||
el.textContent = text;
|
||
el.style.display = 'block';
|
||
}
|
||
function hideError() {
|
||
const el = $('error-detail');
|
||
el.textContent = '';
|
||
el.style.display = 'none';
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|