Files
Shudao/index.html
T

789 lines
30 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>