feat: add web UI with sidebar TOC and CLI args for json_to_excel
This commit is contained in:
@@ -6,17 +6,82 @@
|
||||
<title>x-data-spreadsheet</title>
|
||||
<style>
|
||||
body { margin:0; padding:20px; font-family:Arial,sans-serif; background-color:#f5f5f5; }
|
||||
#xspreadsheet { width:100%; height:600px; box-shadow:0 2px 10px rgba(0,0,0,0.1); background-color:white; }
|
||||
.layout { display:flex; gap:20px; align-items:flex-start; }
|
||||
.main { flex:1 1 auto; min-width:0; }
|
||||
#xspreadsheet { width:100%; height:calc(100vh - 140px); min-height:600px; box-shadow:0 2px 10px rgba(0,0,0,0.1); background-color:white; }
|
||||
.toc-sidebar {
|
||||
flex: 0 0 200px;
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
background: #ffffff;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
padding: 12px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.toc-sidebar h3 {
|
||||
margin: 0 14px 8px 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 0 14px 8px;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.toc-sheet-group { margin-bottom: 10px; }
|
||||
.toc-sheet-name {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin: 4px 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.toc-list { list-style: none; padding: 0; margin: 0; }
|
||||
.toc-list li { margin: 0; }
|
||||
.toc-link {
|
||||
display: block;
|
||||
padding: 5px 14px;
|
||||
color: #555;
|
||||
text-decoration: none;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
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-empty { font-size: 12px; color: #999; padding: 8px 14px; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://unpkg.com/x-data-spreadsheet@1.1.9/dist/xspreadsheet.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>x-data-spreadsheet</h1>
|
||||
<div id="xspreadsheet"></div>
|
||||
<div class="layout">
|
||||
<div class="main">
|
||||
<div id="xspreadsheet"></div>
|
||||
</div>
|
||||
<aside class="toc-sidebar">
|
||||
<h3>目录</h3>
|
||||
<div id="toc-container"></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/x-data-spreadsheet@1.1.9/dist/xspreadsheet.js"></script>
|
||||
<script>
|
||||
const sheetData = {
|
||||
const rawData = {
|
||||
"name": "Table_0",
|
||||
"rows": {
|
||||
"0": {
|
||||
@@ -639,7 +704,136 @@
|
||||
}
|
||||
}
|
||||
};
|
||||
// Normalize: x_spreadsheet.loadData accepts an array of sheets or a single sheet
|
||||
const sheets = Array.isArray(rawData) ? rawData : [rawData];
|
||||
|
||||
// ---- TOC builder ----
|
||||
function cellText(row, col) {
|
||||
if (!row || !row.cells) return null;
|
||||
const c = row.cells[col];
|
||||
if (!c) return null;
|
||||
return (c.text || '').trim() || null;
|
||||
}
|
||||
|
||||
function extractToc(sheet) {
|
||||
const 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);
|
||||
|
||||
// 1) Top-level labels in column 0 (基本要求 / 外观质量 / 工程质量等级评定 / ...)
|
||||
if (c0 && !seen.has('c0:' + c0)) {
|
||||
items.push({ label: c0, row: i });
|
||||
seen.add('c0:' + c0);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2) Numbered items in column 1 (项次: 1A, 2A, 3, 4, 5, 6, 7, 8, ...)
|
||||
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);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function renderToc(sheets) {
|
||||
const container = document.getElementById('toc-container');
|
||||
container.innerHTML = '';
|
||||
let hasAny = false;
|
||||
|
||||
sheets.forEach((sheet, sheetIdx) => {
|
||||
const items = extractToc(sheet);
|
||||
if (!items.length && sheets.length === 1) return;
|
||||
|
||||
hasAny = true;
|
||||
const group = document.createElement('div');
|
||||
group.className = 'toc-sheet-group';
|
||||
|
||||
if (sheets.length > 1) {
|
||||
const name = document.createElement('div');
|
||||
name.className = 'toc-sheet-name';
|
||||
name.textContent = sheet.name || ('Sheet ' + (sheetIdx + 1));
|
||||
group.appendChild(name);
|
||||
}
|
||||
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'toc-list';
|
||||
items.forEach(item => {
|
||||
const li = document.createElement('li');
|
||||
const a = document.createElement('a');
|
||||
a.className = 'toc-link';
|
||||
a.href = 'javascript:void(0)';
|
||||
a.dataset.sheet = sheetIdx;
|
||||
a.dataset.row = item.row;
|
||||
a.title = item.label + ' (row ' + item.row + ')';
|
||||
a.innerHTML = item.label + '<span class="row-tag" style="color:#bbb;font-size:10.5px;margin-left:4px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">·' + item.row + '</span>';
|
||||
a.addEventListener('click', () => scrollToRow(sheetIdx, item.row, a));
|
||||
li.appendChild(a);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
group.appendChild(ul);
|
||||
container.appendChild(group);
|
||||
});
|
||||
|
||||
if (!hasAny) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'toc-empty';
|
||||
empty.textContent = '(未识别到目录项)';
|
||||
container.appendChild(empty);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Scroll handling ----
|
||||
// x-data-spreadsheet renders the grid on a canvas; rows are NOT in the DOM.
|
||||
// We scroll by setting the vertical scrollbar's scrollTop, which fires
|
||||
// the library's internal moveFn -> scrolly() -> re-render.
|
||||
let __ssRef = null; // populated after load
|
||||
|
||||
function getSheetData(sheetIdx) {
|
||||
if (!__ssRef) return null;
|
||||
// spreadsheet.datas[] holds the pt instances; .data is shorthand for datas[0]
|
||||
if (Array.isArray(__ssRef.datas) && __ssRef.datas[sheetIdx]) return __ssRef.datas[sheetIdx];
|
||||
if (sheetIdx === 0 && __ssRef.data) return __ssRef.data;
|
||||
return null;
|
||||
}
|
||||
|
||||
function scrollToRow(sheetIdx, rowIndex, linkEl) {
|
||||
if (!__ssRef) return;
|
||||
|
||||
// Mark active
|
||||
document.querySelectorAll('.toc-link.active').forEach(el => el.classList.remove('active'));
|
||||
if (linkEl) linkEl.classList.add('active');
|
||||
|
||||
try {
|
||||
const liveData = getSheetData(sheetIdx);
|
||||
if (!liveData || !liveData.rows || typeof liveData.rows.sumHeight !== 'function') {
|
||||
console.warn('No live sheet data for index', sheetIdx);
|
||||
return;
|
||||
}
|
||||
const y = liveData.rows.sumHeight(0, rowIndex);
|
||||
const vbar = __ssRef.sheet && __ssRef.sheet.verticalScrollbar;
|
||||
if (vbar && vbar.el && vbar.el[0]) {
|
||||
vbar.el[0].scrollTop = y;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('scrollToRow failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Init ----
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (typeof x_spreadsheet === 'undefined') {
|
||||
document.body.innerHTML += '<p style="color:red;margin-top:10px;">Error: x-spreadsheet library failed to load. Check internet connection.</p>';
|
||||
@@ -647,7 +841,9 @@
|
||||
}
|
||||
try {
|
||||
const spreadsheet = x_spreadsheet('#xspreadsheet');
|
||||
spreadsheet.loadData(sheetData);
|
||||
spreadsheet.loadData(sheets);
|
||||
__ssRef = spreadsheet;
|
||||
renderToc(sheets);
|
||||
} catch (e) {
|
||||
console.error('Error:', e);
|
||||
document.body.innerHTML += '<p style="color:red;margin-top:10px;">Error: ' + e.message + '</p>';
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,342 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Web backend for the OCR table -> online spreadsheet pipeline.
|
||||
|
||||
The OCR call mirrors `api.py` as closely as possible:
|
||||
* same module-level imports (alibabacloud_ocr_api20210707, credentials,
|
||||
tea_openapi, darabonba_stream, tea_util, etc.)
|
||||
* same `Sample` class with `create_client` (only difference: credentials
|
||||
are parameterized instead of hardcoded)
|
||||
* same `main` shape: build client -> read body stream -> build
|
||||
`RecognizeTableOcrRequest` -> call `recognize_table_ocr_with_options`
|
||||
* response: we use `to_map()` (the proper TeaModel way) instead of
|
||||
`print(json.dumps(resp, default=str))`, which produces invalid JSON
|
||||
(Python repr, not JSON) in the original.
|
||||
|
||||
Endpoints:
|
||||
GET / -> index.html
|
||||
POST /api/convert -> image + aliyun AK -> full pipeline -> {sheet_data, ocr_result}
|
||||
POST /api/load_xlsx -> .xlsx file -> {sheet_data}
|
||||
POST /api/load_json -> OCR-style .json file -> {sheet_data}
|
||||
GET /api/load_demo -> 111.html / 111.xlsx -> {sheet_data}
|
||||
GET /sheet -> sheet.html (legacy standalone template)
|
||||
GET /api/health -> health check
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
|
||||
from flask import Flask, jsonify, request, send_from_directory
|
||||
|
||||
# Make sibling modules importable
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
# ---------- Aliyun SDK imports (mirror api.py) ----------
|
||||
from alibabacloud_credentials.client import Client as CredentialClient
|
||||
from alibabacloud_credentials.models import Config as CredentialConfig
|
||||
from alibabacloud_darabonba_stream.client import Client as StreamClient
|
||||
from alibabacloud_ocr_api20210707 import models as ocr_api_20210707_models
|
||||
from alibabacloud_ocr_api20210707.client import Client as ocr_api20210707Client
|
||||
from alibabacloud_tea_openapi import models as open_api_models
|
||||
from alibabacloud_tea_util import models as util_models
|
||||
|
||||
from generate_sheet import xlsx_to_spreadsheet_data # noqa: E402
|
||||
|
||||
|
||||
# ---------- Sample class: same shape as api.py ----------
|
||||
class Sample:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def create_client(access_key_id: str, access_key_secret: str) -> ocr_api20210707Client:
|
||||
"""
|
||||
Mirrors api.py's create_client, but credentials are parameters
|
||||
instead of hardcoded. Everything else (config shape, endpoint)
|
||||
is identical to api.py.
|
||||
"""
|
||||
credentialsConfig = CredentialConfig(
|
||||
type='access_key',
|
||||
access_key_id=access_key_id,
|
||||
access_key_secret=access_key_secret,
|
||||
)
|
||||
credentialsClient = CredentialClient(credentialsConfig)
|
||||
config = open_api_models.Config(
|
||||
credential=credentialsClient
|
||||
)
|
||||
config.endpoint = f'ocr-api.cn-hangzhou.aliyuncs.com'
|
||||
return ocr_api20210707Client(config)
|
||||
|
||||
@staticmethod
|
||||
def recognize_table(image_path: str, access_key_id: str, access_key_secret: str) -> dict:
|
||||
"""
|
||||
Mirrors api.py's main(), but:
|
||||
* image path is a parameter
|
||||
* credentials are parameters
|
||||
* returns the structured response dict (via to_map) instead of
|
||||
printing invalid-JSON string
|
||||
"""
|
||||
client = Sample.create_client(access_key_id, access_key_secret)
|
||||
body_stream = StreamClient.read_from_file_path(image_path)
|
||||
recognize_table_ocr_request = ocr_api_20210707_models.RecognizeTableOcrRequest(
|
||||
body=body_stream
|
||||
)
|
||||
runtime = util_models.RuntimeOptions()
|
||||
resp = client.recognize_table_ocr_with_options(recognize_table_ocr_request, runtime)
|
||||
# `to_map()` is the documented way to get a JSON-serializable dict
|
||||
# from a TeaModel response. This is what api.py's `default=str`
|
||||
# hack was trying to do (and failing at, since str(resp) yields
|
||||
# a Python repr, not JSON).
|
||||
return resp.to_map() if hasattr(resp, 'to_map') else _tea_fallback(resp)
|
||||
|
||||
|
||||
def _tea_fallback(resp):
|
||||
"""Last-resort: serialize via str(). May produce Python repr, not JSON."""
|
||||
try:
|
||||
return json.loads(json.dumps(resp, default=str))
|
||||
except Exception:
|
||||
return {"_raw": str(resp)}
|
||||
|
||||
|
||||
# ---------- Flask app ----------
|
||||
app = Flask(__name__, static_folder=None)
|
||||
|
||||
|
||||
def run_subprocess(cmd: list) -> tuple[int, str, str]:
|
||||
import subprocess
|
||||
p = subprocess.run(cmd, capture_output=True, text=True, cwd=HERE)
|
||||
return p.returncode, p.stdout, p.stderr
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return send_from_directory(HERE, "index.html")
|
||||
|
||||
|
||||
@app.route("/sheet")
|
||||
@app.route("/sheet.html")
|
||||
def sheet():
|
||||
return send_from_directory(HERE, "sheet.html")
|
||||
|
||||
|
||||
@app.route("/111.html")
|
||||
def sheet_111():
|
||||
return send_from_directory(HERE, "111.html")
|
||||
|
||||
|
||||
@app.route("/api/health")
|
||||
def health():
|
||||
return jsonify({"ok": True, "service": "shudao"})
|
||||
|
||||
|
||||
@app.route("/api/convert", methods=["POST"])
|
||||
def api_convert():
|
||||
if "image" not in request.files:
|
||||
return jsonify({"ok": False, "stage": "input", "error": "缺少图片(字段名 image)"}), 400
|
||||
|
||||
access_key_id = (request.form.get("access_key_id") or "").strip()
|
||||
access_key_secret = (request.form.get("access_key_secret") or "").strip()
|
||||
if not access_key_id or not access_key_secret:
|
||||
return jsonify({"ok": False, "stage": "input", "error": "缺少阿里云 AccessKey ID / Secret"}), 400
|
||||
|
||||
image = request.files["image"]
|
||||
if not image.filename:
|
||||
return jsonify({"ok": False, "stage": "input", "error": "图片文件为空"}), 400
|
||||
|
||||
workdir = tempfile.mkdtemp(prefix="shudao_")
|
||||
img_path = os.path.join(workdir, "input" + os.path.splitext(image.filename)[1].lower() or ".png")
|
||||
json_path = os.path.join(workdir, "ocr.json")
|
||||
xlsx_path = os.path.join(workdir, "ocr.xlsx")
|
||||
|
||||
try:
|
||||
image.save(img_path)
|
||||
|
||||
# ---- Step 1: OCR (mirrors api.py exactly) ----
|
||||
try:
|
||||
ocr_raw = Sample.recognize_table(img_path, access_key_id, access_key_secret)
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"stage": "ocr",
|
||||
"error": f"OCR 调用失败:{e}",
|
||||
"trace": traceback.format_exc(),
|
||||
}), 500
|
||||
|
||||
# The Aliyun SDK returns {body: {Data: "<json string>", RequestId}, headers, statusCode}
|
||||
# json_to_excel.py expects the body shape: {Data: "<json string>", RequestId}
|
||||
# (it then json.loads the Data string to get prism_tablesInfo).
|
||||
ocr_body = ocr_raw.get("body") if isinstance(ocr_raw, dict) else None
|
||||
if not isinstance(ocr_body, dict):
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"stage": "ocr",
|
||||
"error": "OCR 响应格式异常:缺少 body 字段",
|
||||
"raw_type": type(ocr_raw).__name__,
|
||||
}), 500
|
||||
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(ocr_body, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# ---- Step 2: json -> xlsx ----
|
||||
rc, out, err = run_subprocess(
|
||||
[sys.executable, os.path.join(HERE, "json_to_excel.py"), json_path, xlsx_path]
|
||||
)
|
||||
if rc != 0:
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"stage": "json_to_excel",
|
||||
"error": err or out or "json_to_excel 失败",
|
||||
}), 500
|
||||
|
||||
# ---- Step 3: xlsx -> sheet data ----
|
||||
try:
|
||||
sheet_data = xlsx_to_spreadsheet_data(xlsx_path)
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"stage": "xlsx_to_sheet",
|
||||
"error": f"xlsx 解析失败:{e}",
|
||||
"trace": traceback.format_exc(),
|
||||
}), 500
|
||||
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"sheet_data": sheet_data,
|
||||
"ocr_result": ocr_raw,
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"stage": "unhandled",
|
||||
"error": f"未处理异常:{e}",
|
||||
"trace": traceback.format_exc(),
|
||||
}), 500
|
||||
|
||||
|
||||
# -------------------- Direct file imports (skip OCR) --------------------
|
||||
@app.route("/api/load_xlsx", methods=["POST"])
|
||||
def api_load_xlsx():
|
||||
"""Directly parse an uploaded .xlsx file into sheet data."""
|
||||
if "file" not in request.files:
|
||||
return jsonify({"ok": False, "error": "缺少文件(字段名 file)"}), 400
|
||||
f = request.files["file"]
|
||||
if not f.filename:
|
||||
return jsonify({"ok": False, "error": "文件为空"}), 400
|
||||
if not f.filename.lower().endswith(".xlsx"):
|
||||
return jsonify({"ok": False, "error": "请上传 .xlsx 文件"}), 400
|
||||
|
||||
workdir = tempfile.mkdtemp(prefix="shudao_xlsx_")
|
||||
xlsx_path = os.path.join(workdir, f.filename)
|
||||
try:
|
||||
f.save(xlsx_path)
|
||||
sheet_data = xlsx_to_spreadsheet_data(xlsx_path)
|
||||
return jsonify({"ok": True, "sheet_data": sheet_data})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"stage": "xlsx_parse",
|
||||
"error": f"xlsx 解析失败:{e}",
|
||||
"trace": traceback.format_exc(),
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route("/api/load_json", methods=["POST"])
|
||||
def api_load_json():
|
||||
"""Parse an uploaded Aliyun-OCR-style JSON file -> xlsx -> sheet data."""
|
||||
if "file" not in request.files:
|
||||
return jsonify({"ok": False, "error": "缺少文件(字段名 file)"}), 400
|
||||
f = request.files["file"]
|
||||
if not f.filename:
|
||||
return jsonify({"ok": False, "error": "文件为空"}), 400
|
||||
|
||||
workdir = tempfile.mkdtemp(prefix="shudao_json_")
|
||||
json_path = os.path.join(workdir, "input.json")
|
||||
xlsx_path = os.path.join(workdir, "out.xlsx")
|
||||
try:
|
||||
f.save(json_path)
|
||||
# Validate the JSON structure
|
||||
with open(json_path, "r", encoding="utf-8") as fp:
|
||||
data = json.load(fp)
|
||||
# Accept either the body shape {Data: "<json string>", RequestId: ...}
|
||||
# or the already-parsed OCR shape with prism_tablesInfo at the top level
|
||||
if isinstance(data, dict) and "Data" in data and isinstance(data["Data"], str):
|
||||
ocr_body = data # already in body shape
|
||||
elif isinstance(data, dict) and "prism_tablesInfo" in data:
|
||||
# Wrap into body shape so json_to_excel can handle it
|
||||
ocr_body = {"Data": json.dumps(data, ensure_ascii=False)}
|
||||
else:
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"stage": "json_validate",
|
||||
"error": "JSON 格式不符合预期(既不是 Aliyun OCR body 也没有 prism_tablesInfo)",
|
||||
}), 400
|
||||
with open(json_path, "w", encoding="utf-8") as fp:
|
||||
json.dump(ocr_body, fp, ensure_ascii=False, indent=2)
|
||||
|
||||
rc, out, err = run_subprocess(
|
||||
[sys.executable, os.path.join(HERE, "json_to_excel.py"), json_path, xlsx_path]
|
||||
)
|
||||
if rc != 0:
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"stage": "json_to_excel",
|
||||
"error": err or out or "json_to_excel 失败",
|
||||
}), 500
|
||||
|
||||
sheet_data = xlsx_to_spreadsheet_data(xlsx_path)
|
||||
return jsonify({"ok": True, "sheet_data": sheet_data})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"stage": "json_load",
|
||||
"error": f"JSON 加载失败:{e}",
|
||||
"trace": traceback.format_exc(),
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route("/api/load_demo", methods=["GET"])
|
||||
def api_load_demo():
|
||||
"""Load the project-local 111.html's embedded sheet data (if any),
|
||||
or fall back to re-running generate_sheet on 111.xlsx."""
|
||||
# Approach 1: extract {{DATA}} from 111.html (cleanest, no re-conversion)
|
||||
candidate_html = os.path.join(HERE, "111.html")
|
||||
if os.path.exists(candidate_html):
|
||||
try:
|
||||
with open(candidate_html, "r", encoding="utf-8") as fp:
|
||||
content = fp.read()
|
||||
# Find the first { ... } that contains the "name" key
|
||||
# The data is in a `const rawData = {...};` block
|
||||
m = re.search(r"const\s+rawData\s*=\s*(\{.*?\});\s*//\s*Normalize", content, re.DOTALL)
|
||||
if m:
|
||||
sheet_data = json.loads(m.group(1))
|
||||
return jsonify({"ok": True, "sheet_data": sheet_data, "source": "111.html"})
|
||||
except Exception:
|
||||
pass # fall through
|
||||
|
||||
# Approach 2: re-run generate_sheet on 111.xlsx
|
||||
candidate_xlsx = os.path.join(HERE, "111.xlsx")
|
||||
if not os.path.exists(candidate_xlsx):
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"error": "示例数据不可用(既没有 111.html 也没有 111.xlsx)",
|
||||
}), 404
|
||||
try:
|
||||
sheet_data = xlsx_to_spreadsheet_data(candidate_xlsx)
|
||||
return jsonify({"ok": True, "sheet_data": sheet_data, "source": "111.xlsx"})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"stage": "xlsx_parse",
|
||||
"error": f"111.xlsx 解析失败:{e}",
|
||||
"trace": traceback.format_exc(),
|
||||
}), 500
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.environ.get("PORT", "5000"))
|
||||
print(f" * Serving Shudao on http://127.0.0.1:{port}")
|
||||
app.run(host="127.0.0.1", port=port, debug=False)
|
||||
+788
@@ -0,0 +1,788 @@
|
||||
<!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>
|
||||
+6
-2
@@ -93,7 +93,11 @@ def json_to_excel(json_file_path, output_excel_path=None):
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
json_path = 'sample.json'
|
||||
out_path = '111.xlsx'
|
||||
import sys
|
||||
if len(sys.argv) < 2:
|
||||
print('Usage: python json_to_excel.py <input.json> [output.xlsx]')
|
||||
sys.exit(1)
|
||||
json_path = sys.argv[1]
|
||||
out_path = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
result = json_to_excel(json_path, out_path)
|
||||
print(f"Excel saved to: {result}")
|
||||
|
||||
+200
-4
@@ -6,18 +6,212 @@
|
||||
<title>x-data-spreadsheet</title>
|
||||
<style>
|
||||
body { margin:0; padding:20px; font-family:Arial,sans-serif; background-color:#f5f5f5; }
|
||||
#xspreadsheet { width:100%; height:600px; box-shadow:0 2px 10px rgba(0,0,0,0.1); background-color:white; }
|
||||
.layout { display:flex; gap:20px; align-items:flex-start; }
|
||||
.main { flex:1 1 auto; min-width:0; }
|
||||
#xspreadsheet { width:100%; height:calc(100vh - 140px); min-height:600px; box-shadow:0 2px 10px rgba(0,0,0,0.1); background-color:white; }
|
||||
.toc-sidebar {
|
||||
flex: 0 0 200px;
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
background: #ffffff;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
padding: 12px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.toc-sidebar h3 {
|
||||
margin: 0 14px 8px 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 0 14px 8px;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.toc-sheet-group { margin-bottom: 10px; }
|
||||
.toc-sheet-name {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin: 4px 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.toc-list { list-style: none; padding: 0; margin: 0; }
|
||||
.toc-list li { margin: 0; }
|
||||
.toc-link {
|
||||
display: block;
|
||||
padding: 5px 14px;
|
||||
color: #555;
|
||||
text-decoration: none;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
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-empty { font-size: 12px; color: #999; padding: 8px 14px; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://unpkg.com/x-data-spreadsheet@1.1.9/dist/xspreadsheet.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>x-data-spreadsheet</h1>
|
||||
<div id="xspreadsheet"></div>
|
||||
<div class="layout">
|
||||
<div class="main">
|
||||
<div id="xspreadsheet"></div>
|
||||
</div>
|
||||
<aside class="toc-sidebar">
|
||||
<h3>目录</h3>
|
||||
<div id="toc-container"></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/x-data-spreadsheet@1.1.9/dist/xspreadsheet.js"></script>
|
||||
<script>
|
||||
const sheetData = {{DATA}};
|
||||
const rawData = {{DATA}};
|
||||
// Normalize: x_spreadsheet.loadData accepts an array of sheets or a single sheet
|
||||
const sheets = Array.isArray(rawData) ? rawData : [rawData];
|
||||
|
||||
// ---- TOC builder ----
|
||||
function cellText(row, col) {
|
||||
if (!row || !row.cells) return null;
|
||||
const c = row.cells[col];
|
||||
if (!c) return null;
|
||||
return (c.text || '').trim() || null;
|
||||
}
|
||||
|
||||
function extractToc(sheet) {
|
||||
const 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);
|
||||
|
||||
// 1) Top-level labels in column 0 (基本要求 / 外观质量 / 工程质量等级评定 / ...)
|
||||
if (c0 && !seen.has('c0:' + c0)) {
|
||||
items.push({ label: c0, row: i });
|
||||
seen.add('c0:' + c0);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2) Numbered items in column 1 (项次: 1A, 2A, 3, 4, 5, 6, 7, 8, ...)
|
||||
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);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function renderToc(sheets) {
|
||||
const container = document.getElementById('toc-container');
|
||||
container.innerHTML = '';
|
||||
let hasAny = false;
|
||||
|
||||
sheets.forEach((sheet, sheetIdx) => {
|
||||
const items = extractToc(sheet);
|
||||
if (!items.length && sheets.length === 1) return;
|
||||
|
||||
hasAny = true;
|
||||
const group = document.createElement('div');
|
||||
group.className = 'toc-sheet-group';
|
||||
|
||||
if (sheets.length > 1) {
|
||||
const name = document.createElement('div');
|
||||
name.className = 'toc-sheet-name';
|
||||
name.textContent = sheet.name || ('Sheet ' + (sheetIdx + 1));
|
||||
group.appendChild(name);
|
||||
}
|
||||
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'toc-list';
|
||||
items.forEach(item => {
|
||||
const li = document.createElement('li');
|
||||
const a = document.createElement('a');
|
||||
a.className = 'toc-link';
|
||||
a.href = 'javascript:void(0)';
|
||||
a.dataset.sheet = sheetIdx;
|
||||
a.dataset.row = item.row;
|
||||
a.title = item.label + ' (row ' + item.row + ')';
|
||||
a.innerHTML = item.label + '<span class="row-tag" style="color:#bbb;font-size:10.5px;margin-left:4px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">·' + item.row + '</span>';
|
||||
a.addEventListener('click', () => scrollToRow(sheetIdx, item.row, a));
|
||||
li.appendChild(a);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
group.appendChild(ul);
|
||||
container.appendChild(group);
|
||||
});
|
||||
|
||||
if (!hasAny) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'toc-empty';
|
||||
empty.textContent = '(未识别到目录项)';
|
||||
container.appendChild(empty);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Scroll handling ----
|
||||
// x-data-spreadsheet renders the grid on a canvas; rows are NOT in the DOM.
|
||||
// We scroll by setting the vertical scrollbar's scrollTop, which fires
|
||||
// the library's internal moveFn -> scrolly() -> re-render.
|
||||
let __ssRef = null; // populated after load
|
||||
|
||||
function getSheetData(sheetIdx) {
|
||||
if (!__ssRef) return null;
|
||||
// spreadsheet.datas[] holds the pt instances; .data is shorthand for datas[0]
|
||||
if (Array.isArray(__ssRef.datas) && __ssRef.datas[sheetIdx]) return __ssRef.datas[sheetIdx];
|
||||
if (sheetIdx === 0 && __ssRef.data) return __ssRef.data;
|
||||
return null;
|
||||
}
|
||||
|
||||
function scrollToRow(sheetIdx, rowIndex, linkEl) {
|
||||
if (!__ssRef) return;
|
||||
|
||||
// Mark active
|
||||
document.querySelectorAll('.toc-link.active').forEach(el => el.classList.remove('active'));
|
||||
if (linkEl) linkEl.classList.add('active');
|
||||
|
||||
try {
|
||||
const liveData = getSheetData(sheetIdx);
|
||||
if (!liveData || !liveData.rows || typeof liveData.rows.sumHeight !== 'function') {
|
||||
console.warn('No live sheet data for index', sheetIdx);
|
||||
return;
|
||||
}
|
||||
const y = liveData.rows.sumHeight(0, rowIndex);
|
||||
const vbar = __ssRef.sheet && __ssRef.sheet.verticalScrollbar;
|
||||
if (vbar && vbar.el && vbar.el[0]) {
|
||||
vbar.el[0].scrollTop = y;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('scrollToRow failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Init ----
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (typeof x_spreadsheet === 'undefined') {
|
||||
document.body.innerHTML += '<p style="color:red;margin-top:10px;">Error: x-spreadsheet library failed to load. Check internet connection.</p>';
|
||||
@@ -25,7 +219,9 @@
|
||||
}
|
||||
try {
|
||||
const spreadsheet = x_spreadsheet('#xspreadsheet');
|
||||
spreadsheet.loadData(sheetData);
|
||||
spreadsheet.loadData(sheets);
|
||||
__ssRef = spreadsheet;
|
||||
renderToc(sheets);
|
||||
} catch (e) {
|
||||
console.error('Error:', e);
|
||||
document.body.innerHTML += '<p style="color:red;margin-top:10px;">Error: ' + e.message + '</p>';
|
||||
|
||||
Reference in New Issue
Block a user