销售单据 OCR 处理中心,Fastapi+Html, MiMo-V2-Omni、豆包怎理图片转Json数据

销售单据 OCR 处理中心

一个基于 FastAPI 的销售单据 OCR 处理系统,支持图片上传、JSON 数据导入、单据管理和统计分析。

功能特性

  • 图片管理: 上传、预览、删除销售单据扫描图片

  • OCR 处理: 配合 AI 工具将图片识别为结构化 JSON 数据

  • 单据管理: 创建、编辑、删除、搜索销售单据

  • 状态跟踪: 支持 pending/completed/cancelled 三种状态

  • 统计分析: 收入统计、订单分析、客户排行

  • 数据导出: 支持 CSV 导出和打印

目录结构

复制代码
销售中心/
├── app.py           # 主程序(FastAPI + 前端 HTML)
├── documents.db     # SQLite 数据库
├── pending/         # 待处理图片目录
├── completed/       # 已处理图片目录
└── data/            # JSON 数据文件目录

快速开始

环境要求

  • Python 3.10+

  • 依赖包:fastapi, uvicorn, pydantic

安装依赖

复制代码
pip install fastapi uvicorn pydantic

启动服务

复制代码
python app.py

访问 http://localhost:8000 即可使用。

使用流程

1. 上传图片

  • 将销售单据扫描图片放入 pending 目录

  • 或通过网页界面上传图片

2. OCR 识别

  1. 选择待处理的图片

  2. 点击"复制提示词"按钮

  3. 将提示词和图片发送给 AI(如 MiMo-V2-Omni、豆包 等)

  4. 将 AI 返回的 JSON 粘贴到输入框

  5. 点击"导入单据"

JSON 格式

复制代码
{
  "doc_number": "SO-2024-0001",
  "customer_name": "客户名称",
  "customer_contact": "联系方式",
  "doc_date": "2024-01-15",
  "items": [
    {
      "product_name": "商品名称",
      "quantity": 10,
      "unit_price": 100.00,
      "subtotal": 1000.00
    }
  ],
  "discount": 0,
  "tax_rate": 0.13,
  "notes": "备注信息"
}

3. 单据管理

  • 搜索: 支持按客户名、单据号、商品名搜索

  • 筛选: 按状态、时间、客户筛选

  • 编辑: 修改单据信息和状态

  • 删除: 删除不需要的单据

API 接口

方法 路径 说明
GET /api/files/pending 获取待处理图片列表
GET /api/files/completed 获取已处理图片列表
POST /api/files/upload 上传图片
DELETE /api/files/{folder}/{filename} 删除图片
POST /api/documents/import 从 OCR JSON 导入单据
POST /api/documents 创建单据
GET /api/documents 获取单据列表
GET /api/documents/{id} 获取单个单据
PUT /api/documents/{id} 更新单据
DELETE /api/documents/{id} 删除单据
GET /api/stats 获取统计数据

技术栈

  • 后端: FastAPI + SQLite

  • 前端: 原生 HTML/CSS/JavaScript

  • UI 风格: 深色主题,琥珀色强调

快捷键

  • Ctrl + Enter: 导入 JSON 数据

  • Esc: 关闭弹窗

未来需求

  • 批处理 10张(因为chat 免费只支持10张)

  • api 自动处理 (ai api自动处理)

  • 增加CLI 操作 ( 让ai 助手 如:Openclaw 自动处理)

App.py代码:

python 复制代码
"""
销售单据 OCR 处理中心
启动: python main.py
访问: http://localhost:8000
"""

import uvicorn
from fastapi import FastAPI, HTTPException, Query, UploadFile, File
from fastapi.responses import HTMLResponse, FileResponse
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
from enum import Enum
from pathlib import Path
import uuid
import json
import shutil
import sqlite3

app = FastAPI(title="销售单据 OCR 处理中心")

# ── 目录设置 ──────────────────────────────────────────────
BASE_DIR = Path(__file__).parent
PENDING_DIR = BASE_DIR / "pending"
COMPLETED_DIR = BASE_DIR / "completed"
DATA_DIR = BASE_DIR / "data"
DB_PATH = BASE_DIR / "documents.db"

for d in [PENDING_DIR, COMPLETED_DIR, DATA_DIR]:
    d.mkdir(exist_ok=True)

# ── SQLite 数据库 ─────────────────────────────────────────
def get_db():
    conn = sqlite3.connect(DB_PATH, check_same_thread=False)
    conn.row_factory = sqlite3.Row
    return conn

def init_db():
    conn = get_db()
    conn.execute("""
        CREATE TABLE IF NOT EXISTS documents (
            id TEXT PRIMARY KEY,
            doc_number TEXT UNIQUE,
            data TEXT NOT NULL,
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL
        )
    """)
    conn.commit()
    conn.close()
    migrate_json_files()

def migrate_json_files():
    if not DATA_DIR.exists():
        return
    conn = get_db()
    for json_file in DATA_DIR.glob("*.json"):
        try:
            doc = json.loads(json_file.read_text(encoding="utf-8"))
            doc_id = doc.get("id")
            if doc_id:
                existing = conn.execute("SELECT id FROM documents WHERE id = ?", (doc_id,)).fetchone()
                if not existing:
                    conn.execute(
                        "INSERT OR REPLACE INTO documents (id, doc_number, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
                        (doc["id"], doc.get("doc_number",""), json.dumps(doc, ensure_ascii=False), doc.get("created_at",""), doc.get("updated_at",""))
                    )
        except (json.JSONDecodeError, KeyError):
            continue
    conn.commit()
    conn.close()

init_db()

# ── 数据模型 ──────────────────────────────────────────────

class DocumentStatus(str, Enum):
    PENDING = "pending"
    COMPLETED = "completed"
    CANCELLED = "cancelled"

class OrderItem(BaseModel):
    product_name: str
    quantity: int = Field(gt=0)
    unit_price: float = Field(ge=0)
    subtotal: Optional[float] = None

class SalesDocument(BaseModel):
    customer_name: str
    customer_contact: str = ""
    items: list[OrderItem]
    discount: float = 0.0
    tax_rate: float = 0.0
    status: DocumentStatus = DocumentStatus.PENDING
    notes: str = ""
    source_file: Optional[str] = None
    doc_date: Optional[str] = None
    doc_number: Optional[str] = None

class DocumentUpdate(BaseModel):
    customer_name: Optional[str] = None
    customer_contact: Optional[str] = None
    items: Optional[list[OrderItem]] = None
    discount: Optional[float] = None
    tax_rate: Optional[float] = None
    status: Optional[DocumentStatus] = None
    notes: Optional[str] = None
    doc_date: Optional[str] = None

# ── 数据库操作 ────────────────────────────────────────────

def doc_save(doc: dict) -> None:
    conn = get_db()
    conn.execute(
        "INSERT OR REPLACE INTO documents (id, doc_number, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
        (doc["id"], doc["doc_number"], json.dumps(doc, ensure_ascii=False), doc["created_at"], doc["updated_at"])
    )
    conn.commit()
    conn.close()

def doc_load(doc_id: str) -> Optional[dict]:
    conn = get_db()
    row = conn.execute("SELECT data FROM documents WHERE id = ?", (doc_id,)).fetchone()
    conn.close()
    return json.loads(row["data"]) if row else None

def doc_delete(doc_id: str) -> bool:
    conn = get_db()
    cursor = conn.execute("DELETE FROM documents WHERE id = ?", (doc_id,))
    affected = cursor.rowcount
    conn.commit()
    conn.close()
    return affected > 0

def doc_list_all() -> list[dict]:
    conn = get_db()
    rows = conn.execute("SELECT data FROM documents ORDER BY created_at DESC").fetchall()
    conn.close()
    return [json.loads(row["data"]) for row in rows]

def doc_count() -> int:
    conn = get_db()
    count = conn.execute("SELECT COUNT(*) FROM documents").fetchone()[0]
    conn.close()
    return count

def doc_find_by_source(stem: str) -> Optional[dict]:
    conn = get_db()
    rows = conn.execute("SELECT data FROM documents").fetchall()
    conn.close()
    for row in rows:
        doc = json.loads(row["data"])
        if doc.get("source_file", "").startswith(stem):
            return doc
    return None

def doc_number_exists(doc_number: str) -> bool:
    conn = get_db()
    row = conn.execute("SELECT id FROM documents WHERE doc_number = ?", (doc_number,)).fetchone()
    conn.close()
    return row is not None

def calc_item_subtotals(items: list[dict]) -> list[dict]:
    for item in items:
        item["subtotal"] = round(item["quantity"] * item["unit_price"], 2)
    return items

def calc_doc_totals(doc: dict) -> dict:
    items = doc.get("items", [])
    doc["subtotal"] = round(sum(i.get("subtotal", 0) for i in items), 2)
    doc["discount_amount"] = round(doc["subtotal"] * doc.get("discount", 0), 2)
    after = doc["subtotal"] - doc["discount_amount"]
    doc["tax_amount"] = round(after * doc.get("tax_rate", 0.13), 2)
    doc["total"] = round(after + doc["tax_amount"], 2)
    return doc

def gen_doc_number() -> str:
    from datetime import date
    today = date.today().strftime("%Y%m%d")
    return f"SO-{today}-{doc_count()+1:04d}"

def get_image_files(directory: Path) -> list[dict]:
    """扫描目录中的图片文件"""
    files = []
    if not directory.exists():
        return files
    for f in sorted(directory.iterdir()):
        if f.suffix.lower() in ('.jpg', '.jpeg', '.png', '.webp'):
            stat = f.stat()
            files.append({
                "filename": f.name,
                "size": stat.st_size,
                "size_text": f"{stat.st_size/1024:.0f} KB" if stat.st_size < 1024*1024 else f"{stat.st_size/1024/1024:.1f} MB",
                "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
            })
    return files

# ── API: 文件管理 ─────────────────────────────────────────

@app.get("/api/files/pending")
async def list_pending_files():
    """获取待处理图片列表"""
    files = get_image_files(PENDING_DIR)
    for f in files:
        stem = Path(f["filename"]).stem
        f["has_record"] = doc_find_by_source(stem) is not None
    return files

@app.get("/api/files/completed")
async def list_completed_files():
    """获取已处理图片列表"""
    files = get_image_files(COMPLETED_DIR)
    for f in files:
        stem = Path(f["filename"]).stem
        f["record"] = doc_find_by_source(stem)
    return files

@app.get("/api/files/image/{folder}/{filename}")
async def serve_image(folder: str, filename: str):
    """提供图片文件"""
    directory = PENDING_DIR if folder == "pending" else COMPLETED_DIR
    path = directory / filename
    if not path.exists():
        raise HTTPException(404, "文件不存在")
    return FileResponse(path)

@app.post("/api/files/upload")
async def upload_files(files: list[UploadFile] = File(...)):
    """上传图片到待处理目录"""
    saved = []
    for f in files:
        if not f.filename.lower().endswith(('.jpg','.jpeg','.png','.webp')):
            continue
        dest = PENDING_DIR / f.filename
        # 避免重名
        if dest.exists():
            stem, ext = dest.stem, dest.suffix
            dest = PENDING_DIR / f"{stem}_{uuid.uuid4().hex[:4]}{ext}"
        content = await f.read()
        dest.write_bytes(content)
        saved.append(dest.name)
    return {"saved": saved, "count": len(saved)}

@app.delete("/api/files/{folder}/{filename}")
async def delete_file(folder: str, filename: str):
    """删除文件"""
    directory = PENDING_DIR if folder == "pending" else COMPLETED_DIR
    path = directory / filename
    if path.exists():
        path.unlink()
        return {"message": "已删除"}
    raise HTTPException(404, "文件不存在")

# ── API: 单据管理 ─────────────────────────────────────────

@app.post("/api/documents/import")
async def import_document(data: SalesDocument):
    """从 OCR JSON 导入单据"""
    doc_id = str(uuid.uuid4())[:8]
    now = datetime.now().isoformat()

    doc = data.model_dump()
    doc["id"] = doc_id
    input_doc_number = doc.get("doc_number")
    if input_doc_number and not doc_number_exists(input_doc_number):
        doc["doc_number"] = input_doc_number
    else:
        doc["doc_number"] = gen_doc_number()
    doc["created_at"] = now
    doc["updated_at"] = now
    if not doc.get("doc_date"):
        doc["doc_date"] = datetime.now().strftime("%Y-%m-%d")
    doc["items"] = calc_item_subtotals(doc["items"])
    doc = calc_doc_totals(doc)

    # 移动图片到已完成目录
    source_file = doc.get("source_file")
    if source_file:
        src = PENDING_DIR / source_file
        if src.exists():
            dst = COMPLETED_DIR / source_file
            if dst.exists():
                stem, ext = dst.stem, dst.suffix
                dst = COMPLETED_DIR / f"{stem}_{doc_id}{ext}"
            shutil.move(str(src), str(dst))
            doc["source_file"] = dst.name

        # 保存 JSON 数据文件
        json_path = DATA_DIR / f"{doc_id}.json"
        json_path.write_text(json.dumps(doc, ensure_ascii=False, indent=2), encoding="utf-8")

    doc_save(doc)
    return doc

@app.post("/api/documents")
async def create_document(data: SalesDocument):
    """手动创建单据"""
    doc_id = str(uuid.uuid4())[:8]
    now = datetime.now().isoformat()
    doc = data.model_dump()
    doc["id"] = doc_id
    input_doc_number = doc.get("doc_number")
    if input_doc_number and not doc_number_exists(input_doc_number):
        doc["doc_number"] = input_doc_number
    else:
        doc["doc_number"] = gen_doc_number()
    doc["created_at"] = now
    doc["updated_at"] = now
    if not doc.get("doc_date"):
        doc["doc_date"] = datetime.now().strftime("%Y-%m-%d")
    doc["items"] = calc_item_subtotals(doc["items"])
    doc = calc_doc_totals(doc)
    doc_save(doc)
    return doc

@app.get("/api/documents")
async def list_documents(
    status: Optional[str] = None,
    search: Optional[str] = None,
):
    docs = doc_list_all()
    if status and status != "all":
        docs = [d for d in docs if d["status"] == status]
    if search:
        q = search.lower()
        def match_doc(d):
            if q in d.get("customer_name","").lower(): return True
            if q in d.get("doc_number","").lower(): return True
            for item in d.get("items", []):
                if q in item.get("product_name","").lower(): return True
            return False
        docs = [d for d in docs if match_doc(d)]
    docs.sort(key=lambda d: d.get("created_at",""), reverse=True)
    return docs

@app.get("/api/documents/{doc_id}")
async def get_document(doc_id: str):
    doc = doc_load(doc_id)
    if doc is None:
        raise HTTPException(404, "单据不存在")
    return doc

@app.put("/api/documents/{doc_id}")
async def update_document(doc_id: str, update: DocumentUpdate):
    doc = doc_load(doc_id)
    if doc is None:
        raise HTTPException(404, "单据不存在")
    update_data = update.model_dump(exclude_unset=True)
    if "items" in update_data and update_data["items"] is not None:
        update_data["items"] = calc_item_subtotals([i if isinstance(i,dict) else i.model_dump() for i in update_data["items"]])
    doc.update(update_data)
    doc["updated_at"] = datetime.now().isoformat()
    doc = calc_doc_totals(doc)
    doc_save(doc)
    return doc

@app.delete("/api/documents/{doc_id}")
async def delete_document(doc_id: str):
    if doc_load(doc_id) is None:
        raise HTTPException(404, "单据不存在")
    json_path = DATA_DIR / f"{doc_id}.json"
    if json_path.exists():
        json_path.unlink()
    doc_delete(doc_id)
    return {"message": "已删除"}

@app.get("/api/stats")
async def get_stats():
    docs = doc_list_all()
    total = len(docs)
    completed = [d for d in docs if d["status"] == "completed"]
    pending_count = sum(1 for d in docs if d["status"] == "pending")
    cancelled_count = sum(1 for d in docs if d["status"] == "cancelled")
    revenue = sum(d["total"] for d in completed)
    avg = revenue / len(completed) if completed else 0
    pending_files = len(get_image_files(PENDING_DIR))
    return {
        "total_docs": total,
        "total_revenue": round(revenue, 2),
        "completed_count": len(completed),
        "pending_count": pending_count,
        "cancelled_count": cancelled_count,
        "avg_order": round(avg, 2),
        "pending_files": pending_files,
    }

# ── HTML 前端 ─────────────────────────────────────────────

HTML_CONTENT = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>销售单据 OCR 处理中心</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600;700;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
  --bg:#0a0908;--surface:#171412;--surface2:#211e1a;--surface3:#2e2a24;
  --border:#3a352e;--border-lt:#4a443c;
  --amber:#d97706;--amber-lt:#f59e0b;--amber-glow:rgba(217,119,6,.12);
  --green:#22c55e;--red:#ef4444;--blue:#3b82f6;--purple:#a855f7;
  --txt:#faf7f2;--txt2:#d4cfc8;--txt3:#9c9689;--txt4:#6b655c;
  --ff:'Noto Serif SC',serif;--fm:'JetBrains Mono',monospace;
  --r:8px;--rl:12px;
}
html{font-size:14px}
body{font-family:var(--ff);color:var(--txt);background:var(--bg);min-height:100vh;overflow-x:hidden}
a{color:var(--amber);text-decoration:none}
button{font-family:inherit;cursor:pointer;border:none;outline:none;background:none;color:inherit}
input,select,textarea{font-family:inherit;outline:none;color:var(--txt)}
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
::-webkit-scrollbar-thumb:hover{background:var(--border-lt)}

/* ── Layout ── */
.app{display:flex;min-height:100vh}
.sidebar{
  width:260px;background:var(--surface);border-right:1px solid var(--border);
  display:flex;flex-direction:column;position:fixed;top:0;left:0;bottom:0;z-index:100;
  transition:transform .3s ease;
}
.main{margin-left:260px;flex:1;display:flex;flex-direction:column;min-height:100vh}

/* ── Sidebar ── */
.sb-brand{padding:20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px}
.sb-brand .icon{
  width:38px;height:38px;border-radius:var(--r);
  background:linear-gradient(135deg,var(--amber),#92400e);
  display:flex;align-items:center;justify-content:center;
  font-size:18px;font-weight:900;color:var(--bg);flex-shrink:0;
}
.sb-brand h1{font-size:15px;font-weight:700;line-height:1.3}
.sb-brand .sub{font-size:10px;color:var(--txt4);font-weight:400;display:block;margin-top:2px}

.sb-section{padding:16px 12px 8px}
.sb-section-title{font-size:10px;font-weight:600;color:var(--txt4);text-transform:uppercase;letter-spacing:.08em;padding:0 8px;margin-bottom:8px;display:flex;align-items:center;justify-content:space-between}
.sb-section-title .badge{background:var(--amber-glow);color:var(--amber-lt);padding:2px 8px;border-radius:10px;font-size:10px}

.file-list{flex:1;overflow-y:auto;padding:0 12px 12px;display:flex;flex-direction:column;gap:4px}
.file-card{
  display:flex;gap:10px;padding:8px;border-radius:var(--r);cursor:pointer;
  border:1px solid transparent;transition:all .2s;align-items:center;
}
.file-card:hover{background:var(--surface2);border-color:var(--border)}
.file-card.active{background:var(--amber-glow);border-color:rgba(217,119,6,.3)}
.file-card.done{opacity:.5}
.file-thumb{
  width:48px;height:48px;border-radius:6px;object-fit:cover;flex-shrink:0;
  background:var(--surface3);
}
.file-info{flex:1;min-width:0}
.file-name{font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.file-meta{font-size:10px;color:var(--txt4);margin-top:2px}
.file-status{font-size:9px;font-weight:600;margin-top:3px;display:inline-block;padding:1px 6px;border-radius:8px}
.file-status.waiting{background:rgba(217,119,6,.12);color:var(--amber-lt)}
.file-status.done{background:rgba(34,197,94,.12);color:var(--green)}

.sb-actions{padding:12px;border-top:1px solid var(--border);display:flex;gap:8px}
.sb-actions .btn{flex:1;justify-content:center}

.sb-footer{padding:12px 20px;border-top:1px solid var(--border);font-size:10px;color:var(--txt4)}

/* ── Topbar ── */
.topbar{
  padding:14px 28px;border-bottom:1px solid var(--border);background:var(--surface);
  display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:50;
}
.topbar-left{display:flex;align-items:center;gap:12px}
.topbar h2{font-size:18px;font-weight:700}
.topbar-right{display:flex;align-items:center;gap:10px}
.mobile-toggle{
  display:none;width:34px;height:34px;align-items:center;justify-content:center;
  background:var(--surface2);border-radius:var(--r);color:var(--txt2);
}

/* ── Buttons ── */
.btn{
  display:inline-flex;align-items:center;gap:6px;padding:7px 14px;
  border-radius:var(--r);font-size:12px;font-weight:600;transition:all .2s;white-space:nowrap;
}
.btn svg{width:15px;height:15px;flex-shrink:0}
.btn-p{background:var(--amber);color:var(--bg)}
.btn-p:hover{background:var(--amber-lt);transform:translateY(-1px);box-shadow:0 4px 16px rgba(217,119,6,.3)}
.btn-g{background:transparent;color:var(--txt3);border:1px solid var(--border)}
.btn-g:hover{background:var(--surface2);color:var(--txt2);border-color:var(--border-lt)}
.btn-d{background:rgba(239,68,68,.12);color:var(--red)}
.btn-d:hover{background:rgba(239,68,68,.22)}
.btn-s{padding:4px 10px;font-size:11px}
.btn-icon{padding:6px;width:32px;height:32px;justify-content:center}

/* ── Nav ── */
.nav{padding:12px;display:flex;flex-direction:column;gap:2px}
.nav-item{
  display:flex;align-items:center;gap:9px;padding:9px 12px;border-radius:var(--r);
  font-size:12px;font-weight:500;color:var(--txt3);cursor:pointer;transition:all .2s;
}
.nav-item:hover{background:var(--surface2);color:var(--txt2)}
.nav-item.active{background:var(--amber-glow);color:var(--amber-lt)}
.nav-item svg{width:16px;height:16px;flex-shrink:0}
.nav-sep{height:1px;background:var(--border);margin:6px 8px}

/* ── Content ── */
.content{padding:24px 28px;flex:1}

/* ── Processing View ── */
.proc-view{display:flex;gap:20px;min-height:calc(100vh - 120px)}
.proc-left{width:320px;display:flex;flex-direction:column;gap:16px;flex-shrink:0}
.proc-right{flex:1;display:flex;flex-direction:column;gap:16px;min-width:0}

.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--rl);overflow:hidden}
.panel-head{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.panel-head h3{font-size:14px;font-weight:700}
.panel-body{padding:16px}

/* Image Preview */
.img-preview{position:relative;background:var(--surface2);border-radius:var(--r);overflow:hidden;cursor:pointer;min-height:200px;display:flex;align-items:center;justify-content:center}
.img-preview img{width:100%;max-height:450px;object-fit:contain;display:block}
.img-preview .placeholder{color:var(--txt4);font-size:13px;text-align:center;padding:40px 20px}
.img-preview .placeholder svg{width:40px;height:40px;margin-bottom:8px;opacity:.4}
.img-preview:hover .zoom-hint{opacity:1}
.zoom-hint{
  position:absolute;top:8px;right:8px;background:rgba(0,0,0,.6);color:#fff;
  padding:4px 10px;border-radius:6px;font-size:11px;opacity:0;transition:opacity .2s;
}
.img-filename{
  padding:10px 16px;background:var(--surface2);border-top:1px solid var(--border);
  display:flex;align-items:center;justify-content:space-between;
}
.img-filename span{font-size:12px;font-family:var(--fm);color:var(--txt3)}

/* JSON Editor */
.json-area{
  width:100%;min-height:280px;background:var(--surface2);border:1px solid var(--border);
  border-radius:var(--r);padding:14px;font-family:var(--fm);font-size:12px;
  line-height:1.6;color:var(--txt2);resize:vertical;transition:border-color .2s;
}
.json-area:focus{border-color:var(--amber)}
.json-area::placeholder{color:var(--txt4)}
.json-toolbar{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}

/* Preview Card */
.preview-card{
  background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);padding:16px;
  animation:fadeUp .3s ease;
}
.preview-card h4{font-size:13px;font-weight:700;margin-bottom:12px;color:var(--amber-lt);display:flex;align-items:center;gap:6px}
.preview-row{display:flex;justify-content:space-between;padding:4px 0;font-size:12px}
.preview-row .lbl{color:var(--txt3)}
.preview-row .val{font-weight:600}
.preview-items{margin-top:10px;border-top:1px solid var(--border);padding-top:10px}
.preview-items table{width:100%;border-collapse:collapse}
.preview-items th{text-align:left;font-size:10px;color:var(--txt4);padding:4px;text-transform:uppercase;letter-spacing:.05em;font-weight:600}
.preview-items td{padding:5px 4px;font-size:12px;border-bottom:1px solid var(--border)}
.preview-items .mono{font-family:var(--fm);text-align:right}
.preview-totals{margin-top:10px;border-top:1px solid var(--border);padding-top:10px;text-align:right}
.preview-total-line{font-size:12px;color:var(--txt3);padding:2px 0;display:flex;justify-content:flex-end;gap:16px}
.preview-total-final{font-size:16px;font-weight:900;color:var(--amber-lt);margin-top:4px;font-family:var(--fm)}

/* ── Stats ── */
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:24px}
.stat{
  background:var(--surface);border:1px solid var(--border);border-radius:var(--rl);
  padding:18px;position:relative;overflow:hidden;transition:transform .2s;
}
.stat:hover{transform:translateY(-2px)}
.stat::after{content:'';position:absolute;top:0;left:0;right:0;height:2px}
.stat:nth-child(1)::after{background:var(--amber)}
.stat:nth-child(2)::after{background:var(--green)}
.stat:nth-child(3)::after{background:var(--blue)}
.stat:nth-child(4)::after{background:var(--purple)}
.stat-lbl{font-size:11px;color:var(--txt4);font-weight:500;margin-bottom:6px}
.stat-val{font-size:26px;font-weight:900;font-family:var(--fm);letter-spacing:-.03em}
.stat-sub{font-size:10px;color:var(--txt4);margin-top:4px}
.c-amber{color:var(--amber-lt)}.c-green{color:var(--green)}.c-blue{color:var(--blue)}.c-purple{color:var(--purple)}

/* ── Table ── */
.section-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}
.section-title{font-size:15px;font-weight:700}
.filters{display:flex;gap:6px;flex-wrap:wrap}
.fbtn{
  padding:5px 12px;border-radius:16px;font-size:11px;font-weight:500;
  background:var(--surface2);color:var(--txt3);border:1px solid transparent;
  transition:all .2s;cursor:pointer;
}
.fbtn:hover{color:var(--txt2);border-color:var(--border)}
.fbtn.on{background:var(--amber-glow);color:var(--amber-lt);border-color:rgba(217,119,6,.3)}
.fsep{width:1px;height:20px;background:var(--border);margin:0 6px}

@media print{
  body{background:#fff;color:#000}
  .sidebar,.topbar,.filters,.fsep,.btn,.section-hdr button{display:none!important}
  .main{margin-left:0}
  .content{padding:10px}
  .stats{grid-template-columns:repeat(4,1fr);border:1px solid #ddd}
  .stat{border-right:1px solid #ddd}
  .stat-val{color:#000}
  .tbl{border:1px solid #ddd}
  .tbl th,.tbl td{border:1px solid #ddd;color:#000}
  .badge{border:1px solid #888}
}

.search-box{
  display:flex;align-items:center;gap:6px;padding:7px 12px;
  background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);width:260px;
  transition:border-color .2s;
}
.search-box:focus-within{border-color:var(--amber)}
.search-box input{background:none;border:none;width:100%;font-size:12px}
.search-box svg{width:14px;height:14px;color:var(--txt4);flex-shrink:0}

.tbl{
  width:100%;border-collapse:separate;border-spacing:0;
  background:var(--surface);border:1px solid var(--border);border-radius:var(--rl);overflow:hidden;
}
.tbl thead{background:var(--surface2)}
.tbl th{padding:10px 14px;text-align:left;font-size:10px;font-weight:600;color:var(--txt4);text-transform:uppercase;letter-spacing:.06em;border-bottom:1px solid var(--border)}
.tbl td{padding:12px 14px;font-size:12px;border-bottom:1px solid var(--border);vertical-align:middle}
.tbl tbody tr{transition:background .15s}
.tbl tbody tr:hover{background:rgba(217,119,6,.03)}
.tbl tbody tr:last-child td{border-bottom:none}
.doc-num{font-family:var(--fm);font-size:11px;color:var(--amber-lt);font-weight:500}
.amt{font-family:var(--fm);font-weight:600}
.act-grp{display:flex;gap:4px}

/* ── Status ── */
.badge{display:inline-flex;align-items:center;gap:4px;padding:2px 9px;border-radius:16px;font-size:10px;font-weight:600}
.badge::before{content:'';width:5px;height:5px;border-radius:50%}
.badge-pending{background:rgba(217,119,6,.12);color:var(--amber-lt)}.badge-pending::before{background:var(--amber-lt)}
.badge-completed{background:rgba(34,197,94,.12);color:var(--green)}.badge-completed::before{background:var(--green)}
.badge-cancelled{background:rgba(239,68,68,.12);color:var(--red)}.badge-cancelled::before{background:var(--red)}

/* ── Modal ── */
.modal-bg{
  position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:200;
  display:flex;align-items:center;justify-content:center;
  opacity:0;pointer-events:none;transition:opacity .25s;backdrop-filter:blur(4px);
}
.modal-bg.show{opacity:1;pointer-events:auto}
.modal{
  background:var(--surface);border:1px solid var(--border);border-radius:var(--rl);
  width:700px;max-width:95vw;max-height:85vh;overflow-y:auto;
  transform:translateY(16px) scale(.97);transition:transform .3s cubic-bezier(.4,0,.2,1);
}
.modal-bg.show .modal{transform:translateY(0) scale(1)}
.modal-hd{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;background:var(--surface);z-index:1}
.modal-hd h3{font-size:16px;font-weight:700}
.modal-x{width:30px;height:30px;border-radius:var(--r);background:var(--surface2);color:var(--txt3);display:flex;align-items:center;justify-content:center;font-size:18px;transition:all .2s}
.modal-x:hover{background:var(--surface3);color:var(--txt)}
.modal-bd{padding:20px}
.modal-ft{padding:14px 20px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:8px;position:sticky;bottom:0;background:var(--surface)}

/* ── Image Modal ── */
.img-modal .modal{width:auto;max-width:90vw;background:transparent;border:none;box-shadow:none}
.img-modal .modal img{max-height:85vh;max-width:90vw;border-radius:var(--r);display:block}

/* ── Form ── */
.fgrid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.fg{display:flex;flex-direction:column;gap:5px}
.fg.full{grid-column:1/-1}
.fl{font-size:11px;font-weight:600;color:var(--txt3)}
.fi,.fs,.ft{
  padding:8px 11px;background:var(--surface2);border:1px solid var(--border);
  border-radius:var(--r);font-size:12px;transition:border-color .2s;
}
.fi:focus,.fs:focus,.ft:focus{border-color:var(--amber)}
.ft{resize:vertical;min-height:50px}
.fs{appearance:none;cursor:pointer}

.itbl{width:100%;border-collapse:collapse;margin-top:8px}
.itbl th{padding:6px;text-align:left;font-size:10px;font-weight:600;color:var(--txt4);text-transform:uppercase;letter-spacing:.04em}
.itbl td{padding:4px 6px}
.itbl .fi{width:100%;padding:6px 8px}
.itbl .num{width:85px}
.itbl .sub{font-family:var(--fm);font-size:11px;color:var(--txt3);text-align:right;padding:6px 8px}

/* ── Detail ── */
.dgrid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.dsec{background:var(--surface2);border-radius:var(--r);padding:14px}
.dsec h4{font-size:11px;font-weight:600;color:var(--txt4);margin-bottom:10px;text-transform:uppercase;letter-spacing:.05em}
.drow{display:flex;justify-content:space-between;padding:5px 0;font-size:12px}
.drow .dl{color:var(--txt3)}.drow .dv{font-weight:600}
.ditems{grid-column:1/-1}
.ditems table{width:100%;border-collapse:collapse}
.ditems th{text-align:left;padding:6px;font-size:10px;color:var(--txt4);border-bottom:1px solid var(--border);text-transform:uppercase;letter-spacing:.05em;font-weight:600}
.ditems td{padding:8px 6px;border-bottom:1px solid var(--border);font-size:12px}
.ditems .mono{font-family:var(--fm)}
.dtotals{grid-column:1/-1;display:flex;justify-content:flex-end}
.tbox{background:var(--surface2);border-radius:var(--r);padding:14px 20px;min-width:260px}
.trow{display:flex;justify-content:space-between;padding:4px 0;font-size:12px}
.trow .dl{color:var(--txt3)}.trow .dv{font-family:var(--fm);font-weight:500}
.trow.final{border-top:1px solid var(--border);margin-top:6px;padding-top:8px}
.trow.final .dv{font-size:17px;font-weight:700;color:var(--amber-lt)}

/* ── Empty ── */
.empty{text-align:center;padding:40px 20px;color:var(--txt4)}
.empty svg{width:40px;height:40px;margin-bottom:8px;opacity:.35}
.empty p{font-size:13px}

/* ── Drop Zone ── */
.dropzone{
  border:2px dashed var(--border);border-radius:var(--r);padding:20px;text-align:center;
  color:var(--txt4);font-size:12px;transition:all .2s;cursor:pointer;
}
.dropzone:hover,.dropzone.over{border-color:var(--amber);color:var(--amber-lt);background:var(--amber-glow)}
.dropzone svg{width:24px;height:24px;margin-bottom:6px;opacity:.5}

/* ── Toast ── */
.toasts{position:fixed;top:16px;right:16px;z-index:300;display:flex;flex-direction:column;gap:6px}
.toast{
  padding:10px 18px;border-radius:var(--r);font-size:12px;font-weight:500;
  color:var(--bg);background:var(--amber);animation:fadeUp .3s ease;
  box-shadow:0 6px 20px rgba(0,0,0,.3);max-width:360px;
}
.toast.ok{background:var(--green)}.toast.err{background:var(--red)}

/* ── Anim ── */
@keyframes fadeUp{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
.anim{animation:fadeUp .4s ease forwards}
.stat{opacity:0;animation:fadeUp .5s ease forwards}
.stat:nth-child(1){animation-delay:.05s}.stat:nth-child(2){animation-delay:.1s}
.stat:nth-child(3){animation-delay:.15s}.stat:nth-child(4){animation-delay:.2s}

/* ── Responsive ── */
@media(max-width:1024px){
  .proc-view{flex-direction:column}
  .proc-left{width:100%}
  .stats{grid-template-columns:repeat(2,1fr)}
}
@media(max-width:768px){
  .sidebar{transform:translateX(-100%)}
  .sidebar.open{transform:translateX(0)}
  .sidebar-overlay{
    display:block;position:fixed;inset:0;background:rgba(0,0,0,.5);
    z-index:99;opacity:0;pointer-events:none;transition:opacity .3s;
  }
  .sidebar-overlay.show{opacity:1;pointer-events:auto}
  .main{margin-left:0}
  .mobile-toggle{display:flex}
  .stats{grid-template-columns:1fr}
  .fgrid{grid-template-columns:1fr}
  .dgrid{grid-template-columns:1fr}
  .content{padding:16px}
  .topbar{padding:10px 16px}
  .search-box{width:160px}
  .tbl{font-size:11px}
  .tbl th,.tbl td{padding:8px}
}
.sidebar-overlay{display:none}
</style>
</head>
<body>
<div class="sidebar-overlay" id="sidebarOverlay" onclick="closeSidebar()"></div>

<div class="app">
<!-- ════ Sidebar ════ -->
<aside class="sidebar" id="sidebar">
  <div class="sb-brand">
    <div class="icon">票</div>
    <div><h1>单据处理中心</h1><span class="sub">OCR Document Center</span></div>
  </div>
  <nav class="nav">
    <div class="nav-item active" data-view="processing" onclick="switchView('processing')">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
      OCR 处理
    </div>
    <div class="nav-item" data-view="records" onclick="switchView('records')">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
      单据记录
    </div>
    <div class="nav-item" data-view="completed" onclick="switchView('completed')">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
      已完成文件
    </div>
    <div class="nav-sep"></div>
    <div class="nav-item" onclick="showCreateModal()">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
      手动新建
    </div>
  </nav>

  <!-- Pending Files (shown on processing view) -->
  <div id="pendingSidebar" style="flex:1;display:flex;flex-direction:column;overflow:hidden">
    <div class="sb-section">
      <div class="sb-section-title">
        待处理文件
        <span class="badge" id="pendingBadge">0</span>
      </div>
    </div>
    <div class="file-list" id="pendingList"></div>
    <div class="sb-actions">
      <button class="btn btn-g btn-s" onclick="refreshFiles()">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
        刷新
      </button>
      <button class="btn btn-p btn-s" onclick="document.getElementById('fileInput').click()">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
        上传
      </button>
      <input type="file" id="fileInput" multiple accept=".jpg,.jpeg,.png,.webp" style="display:none" onchange="uploadFiles(this.files)">
    </div>
  </div>

  <div class="sb-footer">FastAPI + HTML &middot; v2.0</div>
</aside>

<!-- ════ Main ════ -->
<main class="main">
  <header class="topbar">
    <div class="topbar-left">
      <button class="mobile-toggle" onclick="toggleSidebar()">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
      </button>
      <h2 id="pageTitle">OCR 处理</h2>
    </div>
    <div class="topbar-right">
      <div class="search-box" id="searchBox" style="display:none">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
        <input placeholder="搜索客户、单号或商品名..." oninput="handleSearch(this.value)">
      </div>
      <button class="btn btn-p" onclick="showCreateModal()">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
        新建单据
      </button>
    </div>
  </header>

  <div class="content">
    <!-- ═══ Processing View ═══ -->
    <div id="viewProcessing">
      <div class="stats" id="statsGrid"></div>
      <div class="proc-view">
        <div class="proc-left">
          <!-- Image Preview -->
          <div class="panel">
            <div class="panel-head"><h3>单据图片</h3></div>
            <div class="panel-body" style="padding:0">
              <div class="img-preview" id="imgPreview" onclick="openImgModal()">
                <div class="placeholder" id="imgPlaceholder">
                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
                  <p>从左侧选择待处理文件<br><span style="font-size:11px;color:var(--txt4)">或拖拽图片到此处上传</span></p>
                </div>
                <img id="imgEl" style="display:none" alt="单据图片">
                <div class="zoom-hint">点击放大</div>
              </div>
              <div class="img-filename" id="imgFilename" style="display:none">
                <span id="imgName"></span>
                <div style="display:flex;gap:6px">
                  <button class="btn btn-g btn-s" onclick="copyImage()" title="复制图片到剪贴板,粘贴给 AI">
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
                    复制
                  </button>
                  <button class="btn btn-d btn-s" onclick="deleteSelectedFile()" title="删除此文件">
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
                  </button>
                </div>
              </div>
            </div>
          </div>
        </div>

        <div class="proc-right">
          <!-- JSON Input -->
          <div class="panel">
            <div class="panel-head">
              <h3>OCR JSON 数据</h3>
              <span style="font-size:11px;color:var(--txt4)">将 AI 识别结果粘贴到下方</span>
            </div>
            <div class="panel-body">
              <textarea class="json-area" id="jsonInput" placeholder='将 OCR 识别的 JSON 数据粘贴在此处...

示例格式:
{
  "customer_name": "客户公司名称",
  "customer_contact": "联系人 电话",
  "doc_date": "2024-01-15",
  "items": [
    {"product_name": "商品A", "quantity": 10, "unit_price": 99.5},
    {"product_name": "商品B", "quantity": 5, "unit_price": 200}
  ],
  "discount": 0.05,
  "tax_rate": 0.13,
  "notes": "备注信息"
}'></textarea>
              <div class="json-toolbar">
                <button class="btn btn-g btn-s" onclick="fillTemplate()">
                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
                  填入模板
                </button>
                <button class="btn btn-g btn-s" onclick="formatJson()">
                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
                  格式化
                </button>
                <button class="btn btn-g btn-s" onclick="clearJson()">清空</button>
                <button class="btn btn-g btn-s" onclick="copyPrompt()">
                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
                  复制提示词
                </button>
                <div style="flex:1"></div>
                <button class="btn btn-p" onclick="importJson()" style="padding:7px 20px">
                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
                  导入系统
                </button>
              </div>
            </div>
          </div>
          <!-- Preview -->
          <div id="previewArea"></div>
        </div>
      </div>
    </div>

    <!-- ═══ Records View ═══ -->
    <div id="viewRecords" style="display:none">
      <div class="section-hdr">
        <div class="section-title" id="recTitle">全部单据</div>
        <div class="filters">
          <select class="fs" id="customerFilter" onchange="setCustomerFilter(this.value)" style="width:150px">
            <option value="all">全部客户</option>
          </select>
          <select class="fs" id="timeFilter" onchange="setTimeFilter(this.value)" style="width:120px">
            <option value="all">全部时间</option>
            <option value="this_month">本月</option>
            <option value="last_month">上月</option>
            <option value="this_year">今年</option>
            <option value="last_year">去年</option>
            <option value="custom">自定义</option>
          </select>
          <span id="customDateRange" style="display:none;gap:6px;align-items:center">
            <input type="date" id="dateStart" class="fi" style="width:130px;padding:6px 8px" onchange="applyCustomDate()">
            <span style="color:var(--txt4)">至</span>
            <input type="date" id="dateEnd" class="fi" style="width:130px;padding:6px 8px" onchange="applyCustomDate()">
          </span>
          <button class="fbtn on" data-s="all" onclick="setFilter('all',this)">全部</button>
          <button class="fbtn" data-s="pending" onclick="setFilter('pending',this)">未审核</button>
          <button class="fbtn" data-s="completed" onclick="setFilter('completed',this)">已审核</button>
          <button class="fbtn" data-s="cancelled" onclick="setFilter('cancelled',this)">已取消</button>
          <span class="fsep"></span>
          <button class="btn btn-g btn-s" onclick="exportCSV()">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
            CSV
          </button>
          <button class="btn btn-g btn-s" onclick="exportPDF()">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
            PDF
          </button>
          <button class="btn btn-g btn-s" onclick="printRecords()">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
            打印
          </button>
        </div>
      </div>
      <div id="recStats" class="stats" style="margin-bottom:16px"></div>
      <div id="recCustomerStats" style="margin-bottom:16px"></div>
      <div id="recTable"></div>
    </div>

    <!-- ═══ Completed Files View ═══ -->
    <div id="viewCompleted" style="display:none">
      <div class="section-hdr">
        <div class="section-title">已处理文件</div>
        <button class="btn btn-g btn-s" onclick="loadCompletedFiles()">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
          刷新
        </button>
      </div>
      <div id="completedGrid"></div>
    </div>
  </div>
</main>
</div>

<!-- ═══ Detail Modal ═══ -->
<div class="modal-bg" id="detailModal">
  <div class="modal">
    <div class="modal-hd"><h3>单据详情</h3><button class="modal-x" onclick="closeModal('detailModal')">&times;</button></div>
    <div class="modal-bd" id="detailBody"></div>
    <div class="modal-ft">
      <button class="btn btn-g" onclick="closeModal('detailModal')">关闭</button>
      <button class="btn btn-p" id="detailEditBtn">编辑</button>
    </div>
  </div>
</div>

<!-- ═══ Edit/Create Modal ═══ -->
<div class="modal-bg" id="formModal">
  <div class="modal">
    <div class="modal-hd"><h3 id="formTitle">新建单据</h3><button class="modal-x" onclick="closeModal('formModal')">&times;</button></div>
    <div class="modal-bd">
      <input type="hidden" id="editId">
      <div class="fgrid">
        <div class="fg"><label class="fl">客户名称 *</label><input class="fi" id="fName" placeholder="客户公司名称"></div>
        <div class="fg"><label class="fl">联系方式</label><input class="fi" id="fContact" placeholder="联系人 / 电话"></div>
        <div class="fg"><label class="fl">单据日期</label><input class="fi" id="fDocDate" type="date"></div>
        <div class="fg"><label class="fl">折扣率 (0~1)</label><input class="fi" id="fDiscount" type="number" min="0" max="1" step="0.01" value="0"></div>
        <div class="fg"><label class="fl">税率 (0~1)</label><input class="fi" id="fTax" type="number" min="0" max="1" step="0.01" value="0.13"></div>
        <div class="fg"><label class="fl">状态</label><select class="fs" id="fStatus"><option value="pending">未审核</option><option value="completed">已审核</option><option value="cancelled">已取消</option></select></div>
        <div class="fg full"><label class="fl">备注</label><textarea class="ft" id="fNotes" placeholder="备注信息..."></textarea></div>
      </div>
      <div style="margin-top:16px">
        <label class="fl">商品明细</label>
        <table class="itbl"><thead><tr><th>商品名称</th><th class="num">数量</th><th class="num">单价</th><th class="num">小计</th><th></th></tr></thead>
        <tbody id="formItems"></tbody></table>
        <button class="btn btn-g btn-s" style="margin-top:6px" onclick="addFormRow()">+ 添加商品</button>
      </div>
    </div>
    <div class="modal-ft">
      <button class="btn btn-g" onclick="closeModal('formModal')">取消</button>
      <button class="btn btn-p" onclick="saveForm()">保存</button>
    </div>
  </div>
</div>

<!-- ═══ Image Zoom Modal ═══ -->
<div class="modal-bg img-modal" id="imgModal" onclick="closeModal('imgModal')">
  <div class="modal" style="background:transparent;border:none;box-shadow:none" onclick="event.stopPropagation()">
    <img id="zoomImg" src="" alt="放大查看">
  </div>
</div>

<!-- Drop Zone Overlay -->
<div id="dropOverlay" style="display:none;position:fixed;inset:0;z-index:250;background:rgba(0,0,0,.7);backdrop-filter:blur(6px);display:none;align-items:center;justify-content:center">
  <div class="dropzone over" style="width:400px;padding:40px">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
    <p style="font-size:16px;font-weight:600;margin-top:8px">松开鼠标上传图片</p>
    <p style="font-size:12px;margin-top:4px">支持 JPG / PNG / WebP</p>
  </div>
</div>

<div class="toasts" id="toasts"></div>

<script>
// ═══════════════════════════════════════════════════════════
// State
// ═══════════════════════════════════════════════════════════
let currentView = 'processing';
let selectedFile = null;
let currentFilter = 'all';
let pendingFiles = [];
let cachedDocs = [];

// ═══════════════════════════════════════════════════════════
// API
// ═══════════════════════════════════════════════════════════
async function api(path, opts = {}) {
  const res = await fetch('/api' + path, {
    headers: opts.body instanceof FormData ? {} : { 'Content-Type': 'application/json' },
    ...opts,
    body: opts.body instanceof FormData ? opts.body : (opts.body ? JSON.stringify(opts.body) : undefined),
  });
  if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.detail || '请求失败'); }
  return res.json();
}

// ═══════════════════════════════════════════════════════════
// Init
// ═══════════════════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', () => {
  loadStats(); loadPendingFiles();
  setupDragDrop();
  // JSON 输入实时预览
  document.getElementById('jsonInput').addEventListener('input', debounce(previewJson, 500));
});

// ═══════════════════════════════════════════════════════════
// Views
// ═══════════════════════════════════════════════════════════
function toggleSidebar() {
  const sb = document.getElementById('sidebar');
  const ov = document.getElementById('sidebarOverlay');
  sb.classList.toggle('open');
  ov.classList.toggle('show');
}

function closeSidebar() {
  document.getElementById('sidebar').classList.remove('open');
  document.getElementById('sidebarOverlay').classList.remove('show');
}

function switchView(view) {
  currentView = view;
  document.getElementById('viewProcessing').style.display = view === 'processing' ? '' : 'none';
  document.getElementById('viewRecords').style.display = view === 'records' ? '' : 'none';
  document.getElementById('viewCompleted').style.display = view === 'completed' ? '' : 'none';
  document.getElementById('pendingSidebar').style.display = view === 'processing' ? '' : 'none';
  document.getElementById('searchBox').style.display = view === 'records' ? '' : 'none';

  const titles = { processing: 'OCR 处理', records: '单据记录', completed: '已完成文件' };
  document.getElementById('pageTitle').textContent = titles[view] || '';

  document.querySelectorAll('.nav-item[data-view]').forEach(el => {
    el.classList.toggle('active', el.dataset.view === view);
  });

  if (view === 'processing') { loadStats(); loadPendingFiles(); }
  if (view === 'records') loadRecords();
  if (view === 'completed') loadCompletedFiles();
  closeSidebar();
}

// ═══════════════════════════════════════════════════════════
// Stats
// ═══════════════════════════════════════════════════════════
async function loadStats() {
  const s = await api('/stats');
  document.getElementById('statsGrid').innerHTML = `
    <div class="stat"><div class="stat-lbl">待处理文件</div><div class="stat-val c-amber">${s.pending_files}</div><div class="stat-sub">等待 OCR 识别</div></div>
    <div class="stat"><div class="stat-lbl">单据总数</div><div class="stat-val c-green">${s.total_docs}</div><div class="stat-sub">${s.completed_count} 已审核</div></div>
    <div class="stat"><div class="stat-lbl">已审核收入</div><div class="stat-val c-blue">¥${fmtN(s.total_revenue)}</div><div class="stat-sub">平均 ¥${fmtN(s.avg_order)}/单</div></div>
    <div class="stat"><div class="stat-lbl">未审核</div><div class="stat-val c-purple">${s.pending_count}</div><div class="stat-sub">${s.cancelled_count} 已取消</div></div>
  `;
}

// ═══════════════════════════════════════════════════════════
// Pending Files
// ═══════════════════════════════════════════════════════════
async function loadPendingFiles() {
  pendingFiles = await api('/files/pending');
  document.getElementById('pendingBadge').textContent = pendingFiles.length;
  const list = document.getElementById('pendingList');
  if (!pendingFiles.length) {
    list.innerHTML = '<div class="empty" style="padding:20px"><p>暂无待处理文件</p><p style="font-size:11px;margin-top:4px">将扫描的单据 JPG 放入 pending 文件夹</p></div>';
    return;
  }
  list.innerHTML = pendingFiles.map(f => `
    <div class="file-card ${selectedFile === f.filename ? 'active' : ''} ${f.has_record ? 'done' : ''}" onclick="selectFile('${f.filename}')">
      <img class="file-thumb" src="/api/files/image/pending/${f.filename}" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 48 48%22><rect fill=%22%232e2a24%22 width=%2248%22 height=%2248%22 rx=%226%22/><text x=%2224%22 y=%2228%22 text-anchor=%22middle%22 fill=%22%236b655c%22 font-size=%2212%22>IMG</text></svg>'">
      <div class="file-info">
        <div class="file-name">${f.filename}</div>
        <div class="file-meta">${f.size_text}</div>
        <span class="file-status ${f.has_record ? 'done' : 'waiting'}">${f.has_record ? '已录入' : '待处理'}</span>
      </div>
    </div>
  `).join('');
}

function selectFile(filename) {
  selectedFile = filename;
  const img = document.getElementById('imgEl');
  const ph = document.getElementById('imgPlaceholder');
  const fn = document.getElementById('imgFilename');
  img.src = `/api/files/image/pending/${filename}`;
  img.style.display = 'block';
  ph.style.display = 'none';
  fn.style.display = 'flex';
  document.getElementById('imgName').textContent = filename;
  loadPendingFiles(); // refresh selection highlight
}

function refreshFiles() { loadPendingFiles(); toast('已刷新文件列表'); }

async function uploadFiles(fileList) {
  if (!fileList.length) return;
  const fd = new FormData();
  for (const f of fileList) fd.append('files', f);
  try {
    const r = await api('/files/upload', { method: 'POST', body: fd });
    toast(`已上传 ${r.count} 个文件`, 'ok');
    loadPendingFiles();
  } catch (e) { toast(e.message, 'err'); }
}

async function deleteSelectedFile() {
  if (!selectedFile) return;
  if (!confirm(`删除文件 ${selectedFile} ?`)) return;
  await api(`/files/pending/${selectedFile}`, { method: 'DELETE' });
  selectedFile = null;
  document.getElementById('imgEl').style.display = 'none';
  document.getElementById('imgPlaceholder').style.display = '';
  document.getElementById('imgFilename').style.display = 'none';
  loadPendingFiles();
  toast('文件已删除');
}

function openImgModal() {
  if (!selectedFile) return;
  document.getElementById('zoomImg').src = `/api/files/image/pending/${selectedFile}`;
  openModal('imgModal');
}

async function copyImage() {
  if (!selectedFile) return;
  try {
    const resp = await fetch(`/api/files/image/pending/${selectedFile}`);
    const blob = await resp.blob();
    await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
    toast('图片已复制到剪贴板,可粘贴给 AI', 'ok');
  } catch (e) {
    toast('复制失败,请右键图片另存为', 'err');
  }
}

// Drag & Drop Upload
function setupDragDrop() {
  let dragCounter = 0;
  const overlay = document.getElementById('dropOverlay');
  document.addEventListener('dragenter', (e) => {
    e.preventDefault();
    dragCounter++;
    overlay.style.display = 'flex';
  });
  document.addEventListener('dragleave', (e) => {
    e.preventDefault();
    dragCounter--;
    if (dragCounter <= 0) { overlay.style.display = 'none'; dragCounter = 0; }
  });
  document.addEventListener('dragover', (e) => e.preventDefault());
  document.addEventListener('drop', async (e) => {
    e.preventDefault();
    overlay.style.display = 'none';
    dragCounter = 0;
    const files = [...e.dataTransfer.files].filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f.name));
    if (!files.length) { toast('请拖入 JPG/PNG/WebP 图片', 'err'); return; }
    const fd = new FormData();
    files.forEach(f => fd.append('files', f));
    try {
      const r = await api('/files/upload', { method: 'POST', body: fd });
      toast(`已上传 ${r.count} 个文件`, 'ok');
      loadPendingFiles();
    } catch (err) { toast(err.message, 'err'); }
  });
}

// ═══════════════════════════════════════════════════════════
// JSON Import
// ═══════════════════════════════════════════════════════════
function fillTemplate() {
  document.getElementById('jsonInput').value = JSON.stringify({
    customer_name: "示例客户有限公司",
    customer_contact: "张三 138-0000-0000",
    doc_date: new Date().toISOString().split('T')[0],
    items: [
      { product_name: "商品A", quantity: 10, unit_price: 99.5 },
      { product_name: "商品B", quantity: 5, unit_price: 200 }
    ],
    discount: 0.05,
    tax_rate: 0.13,
    notes: "示例备注"
  }, null, 2);
  previewJson();
}

function formatJson() {
  try {
    const raw = cleanJson(document.getElementById('jsonInput').value);
    const obj = JSON.parse(raw);
    document.getElementById('jsonInput').value = JSON.stringify(obj, null, 2);
    toast('JSON 已格式化', 'ok');
  } catch (e) { toast('JSON 格式错误: ' + e.message, 'err'); }
}

function clearJson() {
  document.getElementById('jsonInput').value = '';
  document.getElementById('previewArea').innerHTML = '';
}

function copyPrompt() {
  const prompt = `请将以下销售单据OCR内容转换为标准JSON格式:

转换要求:
- 只输出JSON,不要任何解释
- 字段:doc_number(单据编号), customer_name(客户名称), customer_contact(联系方式), doc_date(单据日期,格式YYYY-MM-DD), items(商品列表,每项含product_name商品名, quantity数量, unit_price单价), discount(折扣率0-1), tax_rate(税率), notes(备注)
- 金额计算:subtotal=quantity×unit_price,请在JSON中一并计算好

输出格式示例:
{
  "doc_number": "SO-2024-0001",
  "customer_name": "xxx",
  "customer_contact": "xxx",
  "doc_date": "2024-01-15",
  "items": [{"product_name": "xxx", "quantity": x, "unit_price": x.xx, "subtotal": x.xx}],
  "discount": 0,
  "tax_rate": 0,
  "notes": ""
}

请直接输出JSON:`;

  navigator.clipboard.writeText(prompt).then(() => {
    toast('提示词已复制到剪贴板', 'ok');
  }).catch(() => {
    toast('复制失败,请手动复制', 'err');
  });
}

function cleanJson(text) {
  return text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '').trim();
}

function parseJsonInput() {
  const raw = cleanJson(document.getElementById('jsonInput').value);
  if (!raw) return null;
  return JSON.parse(raw);
}

function previewJson() {
  const area = document.getElementById('previewArea');
  try {
    const data = parseJsonInput();
    if (!data) { area.innerHTML = ''; return; }
    // Validate
    const errors = validateDoc(data);
    if (errors.length) {
      area.innerHTML = `<div class="preview-card" style="border-color:rgba(239,68,68,.3)">
        <h4 style="color:var(--red)">⚠ 数据校验问题</h4>
        ${errors.map(e => `<div style="font-size:12px;color:var(--red);padding:2px 0">• ${e}</div>`).join('')}
      </div>`;
      return;
    }
    // Preview
    const items = data.items || [];
    const subtotal = items.reduce((s, i) => s + i.quantity * i.unit_price, 0);
    const discountAmt = subtotal * (data.discount || 0);
    const afterDisc = subtotal - discountAmt;
    const taxAmt = afterDisc * (data.tax_rate || 0.13);
    const total = afterDisc + taxAmt;
    area.innerHTML = `<div class="preview-card">
      <h4>📋 数据预览</h4>
      ${data.doc_number ? `<div class="preview-row"><span class="lbl">单号</span><span class="val" style="font-family:var(--fm);color:var(--amber-lt)">${data.doc_number}</span></div>` : ''}
      <div class="preview-row"><span class="lbl">客户</span><span class="val">${data.customer_name}</span></div>
      <div class="preview-row"><span class="lbl">联系</span><span class="val">${data.customer_contact || '-'}</span></div>
      <div class="preview-row"><span class="lbl">日期</span><span class="val">${data.doc_date || '-'}</span></div>
      <div class="preview-items">
        <table><thead><tr><th>商品</th><th style="text-align:right">数量</th><th style="text-align:right">单价</th><th style="text-align:right">小计</th></tr></thead>
        <tbody>${items.map(i => `<tr><td>${i.product_name}</td><td class="mono">${i.quantity}</td><td class="mono">¥${fmtN(i.unit_price)}</td><td class="mono">¥${fmtN(i.quantity*i.unit_price)}</td></tr>`).join('')}</tbody></table>
      </div>
      <div class="preview-totals">
        <div class="preview-total-line"><span>小计</span><span>¥${fmtN(subtotal)}</span></div>
        ${data.discount ? `<div class="preview-total-line"><span>折扣 ${(data.discount*100).toFixed(0)}%</span><span style="color:var(--red)">-¥${fmtN(discountAmt)}</span></div>` : ''}
        <div class="preview-total-line"><span>税额 ${((data.tax_rate||0.13)*100).toFixed(0)}%</span><span>¥${fmtN(taxAmt)}</span></div>
        <div class="preview-total-final">合计 ¥${fmtN(total)}</div>
      </div>
    </div>`;
  } catch (e) {
    if (document.getElementById('jsonInput').value.trim()) {
      area.innerHTML = `<div class="preview-card" style="border-color:rgba(239,68,68,.3)"><h4 style="color:var(--red)">⚠ JSON 解析错误</h4><div style="font-size:12px;color:var(--red)">${e.message}</div></div>`;
    } else {
      area.innerHTML = '';
    }
  }
}

function validateDoc(data) {
  const errs = [];
  if (!data.customer_name || !data.customer_name.trim()) errs.push('缺少 customer_name(客户名称)');
  if (!data.items || !Array.isArray(data.items) || data.items.length === 0) errs.push('缺少 items(商品明细)或为空');
  (data.items || []).forEach((it, i) => {
    if (!it.product_name) errs.push(`第 ${i+1} 项缺少 product_name`);
    if (!it.quantity || it.quantity <= 0) errs.push(`第 ${i+1} 项 quantity 无效`);
    if (it.unit_price == null || it.unit_price < 0) errs.push(`第 ${i+1} 项 unit_price 无效`);
  });
  return errs;
}

async function importJson() {
  try {
    const data = parseJsonInput();
    if (!data) { toast('请先粘贴 JSON 数据', 'err'); return; }
    const errors = validateDoc(data);
    if (errors.length) { toast('数据校验失败: ' + errors[0], 'err'); return; }

    data.source_file = selectedFile || null;
    const result = await api('/documents/import', { method: 'POST', body: data });
    toast(`单据 ${result.doc_number} 已导入!`, 'ok');

    // Reset
    selectedFile = null;
    document.getElementById('jsonInput').value = '';
    document.getElementById('previewArea').innerHTML = '';
    document.getElementById('imgEl').style.display = 'none';
    document.getElementById('imgPlaceholder').style.display = '';
    document.getElementById('imgFilename').style.display = 'none';
    loadStats();
    loadPendingFiles();
  } catch (e) {
    toast('导入失败: ' + e.message, 'err');
  }
}

// ═══════════════════════════════════════════════════════════
// Records
// ═══════════════════════════════════════════════════════════
let searchQuery = '';
let timeFilter = 'all';
let customerFilter = 'all';
let customDateStart = '';
let customDateEnd = '';

function setTimeFilter(v) {
  timeFilter = v;
  const customRange = document.getElementById('customDateRange');
  if (v === 'custom') {
    customRange.style.display = 'flex';
  } else {
    customRange.style.display = 'none';
    customDateStart = '';
    customDateEnd = '';
  }
  loadRecords();
}

function setCustomerFilter(v) {
  customerFilter = v;
  loadRecords();
}

function applyCustomDate() {
  customDateStart = document.getElementById('dateStart').value;
  customDateEnd = document.getElementById('dateEnd').value;
  if (customDateStart || customDateEnd) {
    loadRecords();
  }
}

function updateCustomerFilter(docs) {
  const customers = [...new Set(docs.map(d => d.customer_name).filter(Boolean))].sort();
  const select = document.getElementById('customerFilter');
  const currentVal = select.value;
  select.innerHTML = '<option value="all">全部客户</option>' + 
    customers.map(c => `<option value="${c}">${c}</option>`).join('');
  if (customers.includes(currentVal)) {
    select.value = currentVal;
  }
}

function filterByTime(docs) {
  if (timeFilter === 'all' && customerFilter === 'all') return docs;
  const now = new Date();
  const thisYear = now.getFullYear();
  const thisMonth = now.getMonth();
  
  return docs.filter(d => {
    if (customerFilter !== 'all' && d.customer_name !== customerFilter) return false;
    if (timeFilter === 'all') return true;
    
    const dateStr = d.doc_date || d.created_at?.split('T')[0];
    if (!dateStr) return false;
    const [yearStr, monthStr] = dateStr.split('-');
    const year = parseInt(yearStr);
    const month = parseInt(monthStr) - 1;
    
    if (timeFilter === 'this_month') return year === thisYear && month === thisMonth;
    if (timeFilter === 'last_month') {
      const lastMonth = thisMonth === 0 ? 11 : thisMonth - 1;
      const lastMonthYear = thisMonth === 0 ? thisYear - 1 : thisYear;
      return year === lastMonthYear && month === lastMonth;
    }
    if (timeFilter === 'this_year') return year === thisYear;
    if (timeFilter === 'last_year') return year === thisYear - 1;
    if (timeFilter === 'custom') {
      if (customDateStart && dateStr < customDateStart) return false;
      if (customDateEnd && dateStr > customDateEnd) return false;
      return true;
    }
    return true;
  });
}

function calcRecordsStats(docs) {
  const completed = docs.filter(d => d.status === 'completed');
  const pending = docs.filter(d => d.status === 'pending');
  const cancelled = docs.filter(d => d.status === 'cancelled');
  const totalRevenue = completed.reduce((s, d) => s + (d.total || 0), 0);
  const avgOrder = completed.length ? totalRevenue / completed.length : 0;
  
  return {
    total: docs.length,
    completed: completed.length,
    pending: pending.length,
    cancelled: cancelled.length,
    revenue: totalRevenue,
    avgOrder: avgOrder
  };
}

function calcCustomerStats(docs) {
  const customers = {};
  docs.forEach(d => {
    const name = d.customer_name || '未知客户';
    if (!customers[name]) customers[name] = { count: 0, revenue: 0, pending: 0 };
    customers[name].count++;
    customers[name].revenue += (d.total || 0);
    if (d.status === 'pending') customers[name].pending++;
  });
  
  return Object.entries(customers)
    .map(([name, data]) => ({ name, ...data }))
    .sort((a, b) => b.revenue - a.revenue)
    .slice(0, 10);
}

async function loadRecords() {
  let url = '/documents?';
  if (currentFilter !== 'all') url += `status=${currentFilter}&`;
  if (searchQuery) url += `search=${encodeURIComponent(searchQuery)}&`;
  const allDocs = await api(url);
  
  updateCustomerFilter(allDocs);
  
  const docs = filterByTime(allDocs);
  cachedDocs = docs;
  
  const stats = calcRecordsStats(docs);
  const customerStats = calcCustomerStats(docs);
  
  document.getElementById('recStats').innerHTML = `
    <div class="stat"><div class="stat-lbl">单据数量</div><div class="stat-val c-amber">${stats.total}</div><div class="stat-sub">${stats.completed} 已审核</div></div>
    <div class="stat"><div class="stat-lbl">合计金额</div><div class="stat-val c-green">¥${fmtN(stats.revenue)}</div><div class="stat-sub">平均 ¥${fmtN(stats.avgOrder)}/单</div></div>
    <div class="stat"><div class="stat-lbl">未审核</div><div class="stat-val c-blue">${stats.pending}</div><div class="stat-sub">需跟进</div></div>
    <div class="stat"><div class="stat-lbl">已取消</div><div class="stat-val c-purple">${stats.cancelled}</div><div class="stat-sub">已作废</div></div>
  `;
  
  if (customerStats.length) {
    document.getElementById('recCustomerStats').innerHTML = `
      <div class="panel">
        <div class="panel-head"><h3>客户统计 TOP 10</h3></div>
        <div class="panel-body" style="padding:0">
          <table class="tbl" style="margin:0">
            <thead><tr><th>客户名称</th><th class="num">单据数</th><th class="num">收入</th><th class="num">未审核</th></tr></thead>
            <tbody>${customerStats.map(c => `<tr>
              <td>${c.name}</td>
              <td class="num">${c.count}</td>
              <td class="num amt">${c.revenue > 0 ? '¥' + fmtN(c.revenue) : '-'}</td>
              <td class="num">${c.pending || '-'}</td>
            </tr>`).join('')}</tbody>
          </table>
        </div>
      </div>
    `;
  } else {
    document.getElementById('recCustomerStats').innerHTML = '';
  }
  
  const wrap = document.getElementById('recTable');
  document.getElementById('recTitle').textContent = `${currentFilter === 'all' ? '全部' : statusTxt(currentFilter)}单据 (${docs.length})`;

  if (!docs.length) {
    wrap.innerHTML = '<div class="empty"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><p>暂无单据</p></div>';
    return;
  }
  wrap.innerHTML = `<table class="tbl anim">
    <thead><tr><th>单据编号</th><th>客户名称</th><th>商品</th><th>金额</th><th>状态</th><th>来源</th><th>日期</th><th>操作</th></tr></thead>
    <tbody>${docs.map(d => `<tr>
      <td><span class="doc-num">${d.doc_number}</span></td>
      <td>${d.customer_name}</td>
      <td>${d.items.length} 项</td>
      <td class="amt">¥${fmtN(d.total)}</td>
      <td><span class="badge badge-${d.status}">${statusTxt(d.status)}</span></td>
      <td style="font-size:11px;color:var(--txt4)">${d.source_file ? '📷 OCR' : '✏️ 手动'}</td>
      <td style="color:var(--txt4);font-size:11px">${d.doc_date || fmtDate(d.created_at)}</td>
      <td><div class="act-grp">
        <button class="btn btn-g btn-s" onclick="viewDoc('${d.id}')">查看</button>
        <button class="btn btn-g btn-s" onclick="editDoc('${d.id}')">编辑</button>
        <button class="btn btn-d btn-s" onclick="delDoc('${d.id}')">删除</button>
      </div></td>
    </tr>`).join('')}</tbody></table>`;
}

function setFilter(status, el) {
  currentFilter = status;
  document.querySelectorAll('.fbtn').forEach(b => b.classList.remove('active'));
  el.classList.add('on');
  document.querySelectorAll('.fbtn').forEach(b => b.classList.remove('on'));
  el.classList.add('on');
  loadRecords();
}

function handleSearch(q) { searchQuery = q.trim(); loadRecords(); }

// ═══════════════════════════════════════════════════════════
// Export & Print
// ═══════════════════════════════════════════════════════════

function exportCSV() {
  if (!cachedDocs.length) { toast('没有数据可导出', 'err'); return; }
  const headers = ['单据编号', '客户名称', '联系方式', '单据日期', '商品名称', '数量', '单价', '小计', '折扣率', '税率', '单据合计', '状态', '来源', '备注'];
  const rows = [];
  cachedDocs.forEach(d => {
    const items = d.items || [];
    if (items.length === 0) {
      rows.push([d.doc_number, d.customer_name, d.customer_contact || '', d.doc_date || '', '', '', '', '', d.discount || 0, d.tax_rate || 0, d.total || 0, statusTxt(d.status), d.source_file ? 'OCR' : '手动', (d.notes || '').replace(/[\n\r]/g, ' ')]);
    } else {
      items.forEach((it, idx) => {
        rows.push([
          idx === 0 ? d.doc_number : '',
          idx === 0 ? d.customer_name : '',
          idx === 0 ? (d.customer_contact || '') : '',
          idx === 0 ? (d.doc_date || '') : '',
          it.product_name || '',
          it.quantity || 0,
          it.unit_price || 0,
          it.subtotal || (it.quantity * it.unit_price) || 0,
          idx === 0 ? (d.discount || 0) : '',
          idx === 0 ? (d.tax_rate || 0) : '',
          idx === 0 ? (d.total || 0) : '',
          idx === 0 ? statusTxt(d.status) : '',
          idx === 0 ? (d.source_file ? 'OCR' : '手动') : '',
          idx === 0 ? (d.notes || '').replace(/[\n\r]/g, ' ') : ''
        ]);
      });
    }
  });
  const csvContent = '\uFEFF' + [headers, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
  const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = `单据明细导出_${new Date().toISOString().split('T')[0]}.csv`;
  a.click();
  URL.revokeObjectURL(a.href);
  toast('CSV 已导出', 'ok');
}

function exportPDF() {
  if (!cachedDocs.length) { toast('没有数据可导出', 'err'); return; }
  const stats = calcRecordsStats(cachedDocs);
  const printWindow = window.open('', '_blank');
  const docDetails = cachedDocs.map(d => `
<div class="doc-card">
  <div class="doc-hd">
    <span class="doc-num">${d.doc_number}</span>
    <span class="badge badge-${d.status}">${statusTxt(d.status)}</span>
  </div>
  <div class="doc-info">
    <div><b>客户:</b> ${d.customer_name}</div>
    <div><b>日期:</b> ${d.doc_date || '-'}</div>
    ${d.customer_contact ? `<div><b>联系:</b> ${d.customer_contact}</div>` : ''}
  </div>
  <table class="item-tbl">
    <thead><tr><th>商品名称</th><th class="amt">数量</th><th class="amt">单价</th><th class="amt">小计</th></tr></thead>
    <tbody>${(d.items || []).map(it => `<tr>
      <td>${it.product_name}</td>
      <td class="amt">${it.quantity}</td>
      <td class="amt">¥${fmtN(it.unit_price)}</td>
      <td class="amt">¥${fmtN(it.subtotal || it.quantity * it.unit_price)}</td>
    </tr>`).join('')}</tbody>
  </table>
  <div class="doc-totals">
    <div class="trow"><span>小计</span><span>¥${fmtN(d.subtotal || 0)}</span></div>
    ${d.discount ? `<div class="trow"><span>折扣 (${(d.discount * 100).toFixed(0)}%)</span><span>-¥${fmtN(d.discount_amount || 0)}</span></div>` : ''}
    <div class="trow"><span>税额 (${((d.tax_rate || 0) * 100).toFixed(0)}%)</span><span>¥${fmtN(d.tax_amount || 0)}</span></div>
    <div class="trow final"><span>合计</span><span>¥${fmtN(d.total || 0)}</span></div>
  </div>
  ${d.notes ? `<div class="doc-notes"><b>备注:</b> ${d.notes}</div>` : ''}
</div>
`).join('');
  const html = `<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>单据明细导出</title>
<style>
body{font-family:"Microsoft YaHei",sans-serif;padding:20px;color:#333;max-width:900px;margin:0 auto}
h1{font-size:18px;margin-bottom:10px;text-align:center}
.stats{display:flex;gap:20px;margin-bottom:20px;padding:15px;background:#f5f5f5;border-radius:6px;justify-content:center}
.stat{text-align:center;min-width:80px}
.stat-val{font-size:20px;font-weight:bold;color:#d97706}
.stat-lbl{font-size:12px;color:#666;margin-top:4px}
.doc-card{border:1px solid #ddd;border-radius:8px;margin-bottom:16px;padding:16px;page-break-inside:avoid}
.doc-hd{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;padding-bottom:10px;border-bottom:1px solid #eee}
.doc-num{font-size:16px;font-weight:bold;color:#d97706}
.doc-info{display:flex;gap:20px;font-size:12px;color:#666;margin-bottom:12px}
.doc-info div{white-space:nowrap}
.item-tbl{width:100%;border-collapse:collapse;font-size:12px;margin-bottom:12px}
.item-tbl th,.item-tbl td{border:1px solid #eee;padding:6px 8px}
.item-tbl th{background:#f9f9f9;font-weight:600}
.amt{text-align:right}
.doc-totals{margin-left:auto;width:200px;font-size:12px}
.trow{display:flex;justify-content:space-between;padding:3px 0}
.trow.final{font-weight:bold;border-top:1px solid #ddd;padding-top:6px;margin-top:3px}
.doc-notes{font-size:11px;color:#666;margin-top:10px;padding-top:10px;border-top:1px solid #eee}
.badge{padding:2px 8px;border-radius:4px;font-size:11px}
.badge-pending{background:#fef3c7;color:#92400e}
.badge-completed{background:#d1fae5;color:#065f46}
.badge-cancelled{background:#fee2e2;color:#991b1b}
.footer{margin-top:20px;font-size:11px;color:#999;text-align:center}
@media print{.doc-card{page-break-inside:avoid}}
</style></head><body>
<h1>销售单据明细报表</h1>
<div class="stats">
  <div class="stat"><div class="stat-val">${stats.total}</div><div class="stat-lbl">单据总数</div></div>
  <div class="stat"><div class="stat-val">¥${fmtN(stats.revenue)}</div><div class="stat-lbl">合计金额</div></div>
  <div class="stat"><div class="stat-val">${stats.completed}</div><div class="stat-lbl">已审核</div></div>
  <div class="stat"><div class="stat-val">${stats.pending}</div><div class="stat-lbl">未审核</div></div>
</div>
${docDetails}
<div class="footer">导出时间: ${new Date().toLocaleString('zh-CN')}</div>
</body></html>`;
  printWindow.document.write(html);
  printWindow.document.close();
  printWindow.onload = () => { printWindow.print(); };
  toast('PDF 窗口已打开', 'ok');
}

function printRecords() {
  if (!cachedDocs.length) { toast('没有数据可打印', 'err'); return; }
  const stats = calcRecordsStats(cachedDocs);
  const tableRows = [];
  cachedDocs.forEach(d => {
    const items = d.items || [];
    if (items.length === 0) {
      tableRows.push(`<tr>
        <td>${d.doc_number}</td>
        <td>${d.customer_name}</td>
        <td>${d.doc_date || '-'}</td>
        <td>-</td>
        <td class="amt">-</td>
        <td class="amt">-</td>
        <td class="amt">-</td>
        <td class="amt">¥${fmtN(d.total || 0)}</td>
        <td><span class="badge badge-${d.status}">${statusTxt(d.status)}</span></td>
      </tr>`);
    } else {
      items.forEach((it, idx) => {
        tableRows.push(`<tr>
          <td>${idx === 0 ? d.doc_number : ''}</td>
          <td>${idx === 0 ? d.customer_name : ''}</td>
          <td>${idx === 0 ? (d.doc_date || '-') : ''}</td>
          <td>${it.product_name || '-'}</td>
          <td class="amt">${it.quantity || 0}</td>
          <td class="amt">¥${fmtN(it.unit_price || 0)}</td>
          <td class="amt">¥${fmtN(it.subtotal || (it.quantity * it.unit_price) || 0)}</td>
          <td class="amt">${idx === 0 ? '¥' + fmtN(d.total || 0) : ''}</td>
          <td>${idx === 0 ? `<span class="badge badge-${d.status}">${statusTxt(d.status)}</span>` : ''}</td>
        </tr>`);
      });
    }
  });
  const imagesHtml = cachedDocs.filter(d => d.source_file).map(d => `
    <div class="img-card">
      <div class="img-hd">${d.doc_number} - ${d.customer_name}</div>
      <img src="/api/files/image/completed/${d.source_file}" alt="单据图片">
    </div>
  `).join('');
  const printWindow = window.open('', '_blank');
  const html = `<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>单据打印</title>
<style>
body{font-family:"Microsoft YaHei",sans-serif;padding:20px;color:#333;max-width:1000px;margin:0 auto}
h1{font-size:18px;margin-bottom:10px;text-align:center}
.stats{display:flex;gap:20px;margin-bottom:20px;padding:15px;background:#f5f5f5;border-radius:6px;justify-content:center}
.stat{text-align:center;min-width:80px}
.stat-val{font-size:20px;font-weight:bold;color:#d97706}
.stat-lbl{font-size:12px;color:#666;margin-top:4px}
.tbl{width:100%;border-collapse:collapse;font-size:11px;margin-bottom:20px}
.tbl th,.tbl td{border:1px solid #ddd;padding:6px 8px}
.tbl th{background:#f9f9f9;font-weight:600;position:sticky;top:0}
.amt{text-align:right}
.badge{padding:2px 6px;border-radius:4px;font-size:10px}
.badge-pending{background:#fef3c7;color:#92400e}
.badge-completed{background:#d1fae5;color:#065f46}
.badge-cancelled{background:#fee2e2;color:#991b1b}
.section-title{font-size:14px;font-weight:bold;margin:20px 0 10px;padding-bottom:6px;border-bottom:2px solid #d97706}
.img-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:16px}
.img-card{border:1px solid #ddd;border-radius:6px;overflow:hidden;page-break-inside:avoid}
.img-hd{padding:8px 12px;background:#f5f5f5;font-size:12px;font-weight:600;border-bottom:1px solid #ddd}
.img-card img{width:100%;max-height:300px;object-fit:contain;display:block}
.footer{margin-top:20px;font-size:11px;color:#999;text-align:center}
@media print{
  .img-card{page-break-inside:avoid}
  .tbl th{position:static}
}
</style></head><body>
<h1>销售单据明细报表</h1>
<div class="stats">
  <div class="stat"><div class="stat-val">${stats.total}</div><div class="stat-lbl">单据总数</div></div>
  <div class="stat"><div class="stat-val">¥${fmtN(stats.revenue)}</div><div class="stat-lbl">合计金额</div></div>
  <div class="stat"><div class="stat-val">${stats.completed}</div><div class="stat-lbl">已审核</div></div>
  <div class="stat"><div class="stat-val">${stats.pending}</div><div class="stat-lbl">未审核</div></div>
</div>
<table class="tbl">
<thead><tr><th>单据编号</th><th>客户名称</th><th>日期</th><th>商品名称</th><th class="amt">数量</th><th class="amt">单价</th><th class="amt">小计</th><th class="amt">合计</th><th>状态</th></tr></thead>
<tbody>${tableRows.join('')}</tbody>
</table>
${imagesHtml ? `<div class="section-title">单据图片</div><div class="img-grid">${imagesHtml}</div>` : ''}
<div class="footer">打印时间: ${new Date().toLocaleString('zh-CN')}</div>
</body></html>`;
  printWindow.document.write(html);
  printWindow.document.close();
  printWindow.onload = () => { printWindow.print(); };
}

// ═══════════════════════════════════════════════════════════
// Completed Files
// ═══════════════════════════════════════════════════════════
async function loadCompletedFiles() {
  const files = await api('/files/completed');
  const wrap = document.getElementById('completedGrid');
  if (!files.length) {
    wrap.innerHTML = '<div class="empty"><p>暂无已处理文件</p></div>';
    return;
  }
  wrap.innerHTML = `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px">
    ${files.map(f => `<div class="panel" style="cursor:pointer" onclick="${f.record ? `viewDoc('${f.record.id}')` : ''}">
      <img src="/api/files/image/completed/${f.filename}" style="width:100%;height:160px;object-fit:cover;display:block" onerror="this.style.display='none'">
      <div style="padding:12px">
        <div style="font-size:12px;font-family:var(--fm);color:var(--txt3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${f.filename}</div>
        <div style="font-size:11px;color:var(--txt4);margin-top:4px">${f.size_text}</div>
        ${f.record ? `<div style="margin-top:6px"><span class="doc-num">${f.record.doc_number}</span> <span style="font-size:11px;color:var(--txt3)">${f.record.customer_name}</span></div>` : '<div style="font-size:11px;color:var(--txt4);margin-top:6px">未关联单据</div>'}
      </div>
    </div>`).join('')}
  </div>`;
}

// ═══════════════════════════════════════════════════════════
// CRUD
// ═══════════════════════════════════════════════════════════
async function viewDoc(id) {
  const d = await api(`/documents/${id}`);
  document.getElementById('detailBody').innerHTML = `<div class="dgrid">
    <div class="dsec"><h4>基本信息</h4>
      <div class="drow"><span class="dl">单号</span><span class="dv" style="font-family:var(--fm);color:var(--amber-lt)">${d.doc_number}</span></div>
      <div class="drow"><span class="dl">状态</span><span class="dv"><span class="badge badge-${d.status}">${statusTxt(d.status)}</span></span></div>
      <div class="drow"><span class="dl">来源</span><span class="dv">${d.source_file ? '📷 OCR 识别' : '✏️ 手动录入'}</span></div>
      <div class="drow"><span class="dl">单据日期</span><span class="dv">${d.doc_date || '-'}</span></div>
      <div class="drow"><span class="dl">创建时间</span><span class="dv">${fmtDate(d.created_at)}</span></div>
    </div>
    <div class="dsec"><h4>客户信息</h4>
      <div class="drow"><span class="dl">客户名称</span><span class="dv">${d.customer_name}</span></div>
      <div class="drow"><span class="dl">联系方式</span><span class="dv">${d.customer_contact || '-'}</span></div>
      <div class="drow"><span class="dl">备注</span><span class="dv">${d.notes || '-'}</span></div>
    </div>
    ${d.source_file ? `<div class="dsec" style="grid-column:1/-1"><h4>原始单据</h4>
      <img src="/api/files/image/completed/${d.source_file}" style="max-width:100%;max-height:300px;border-radius:var(--r);object-fit:contain;cursor:pointer" onclick="document.getElementById('zoomImg').src=this.src;openModal('imgModal')">
    </div>` : ''}
    <div class="ditems"><h4 style="font-size:11px;font-weight:600;color:var(--txt4);margin-bottom:10px;text-transform:uppercase;letter-spacing:.05em">商品明细</h4>
      <table><thead><tr><th>商品名称</th><th style="text-align:right">数量</th><th style="text-align:right">单价</th><th style="text-align:right">小计</th></tr></thead>
      <tbody>${d.items.map(i => `<tr><td>${i.product_name}</td><td style="text-align:right" class="mono">${i.quantity}</td><td style="text-align:right" class="mono">¥${fmtN(i.unit_price)}</td><td style="text-align:right" class="mono">¥${fmtN(i.subtotal)}</td></tr>`).join('')}</tbody></table>
    </div>
    <div class="dtotals"><div class="tbox">
      <div class="trow"><span class="dl">小计</span><span class="dv">¥${fmtN(d.subtotal)}</span></div>
      ${d.discount ? `<div class="trow"><span class="dl">折扣 (${(d.discount*100).toFixed(0)}%)</span><span class="dv" style="color:var(--red)">-¥${fmtN(d.discount_amount)}</span></div>` : ''}
      <div class="trow"><span class="dl">税额 (${(d.tax_rate*100).toFixed(0)}%)</span><span class="dv">¥${fmtN(d.tax_amount)}</span></div>
      <div class="trow final"><span class="dl">合计</span><span class="dv">¥${fmtN(d.total)}</span></div>
    </div></div>
  </div>`;
  document.getElementById('detailEditBtn').onclick = () => { closeModal('detailModal'); editDoc(id); };
  openModal('detailModal');
}

function showCreateModal() {
  closeSidebar();
  document.getElementById('formTitle').textContent = '新建单据';
  document.getElementById('editId').value = '';
  document.getElementById('fName').value = '';
  document.getElementById('fContact').value = '';
  document.getElementById('fDocDate').value = new Date().toISOString().split('T')[0];
  document.getElementById('fDiscount').value = '0';
  document.getElementById('fTax').value = '0';
  document.getElementById('fStatus').value = 'pending';
  document.getElementById('fNotes').value = '';
  document.getElementById('formItems').innerHTML = '';
  addFormRow(); addFormRow();
  openModal('formModal');
}

async function editDoc(id) {
  const d = await api(`/documents/${id}`);
  document.getElementById('formTitle').textContent = '编辑单据';
  document.getElementById('editId').value = id;
  document.getElementById('fName').value = d.customer_name;
  document.getElementById('fContact').value = d.customer_contact || '';
  document.getElementById('fDocDate').value = d.doc_date || '';
  document.getElementById('fDiscount').value = d.discount;
  document.getElementById('fTax').value = d.tax_rate;
  document.getElementById('fStatus').value = d.status || 'pending';
  document.getElementById('fNotes').value = d.notes || '';
  const tbody = document.getElementById('formItems');
  tbody.innerHTML = '';
  d.items.forEach(it => addFormRow(it));
  openModal('formModal');
}

async function saveForm() {
  const name = document.getElementById('fName').value.trim();
  if (!name) { toast('请填写客户名称', 'err'); return; }
  const items = [];
  document.querySelectorAll('#formItems tr').forEach(tr => {
    const pn = tr.querySelector('.item-name')?.value.trim();
    const q = parseInt(tr.querySelector('.item-qty')?.value);
    const p = parseFloat(tr.querySelector('.item-price')?.value);
    if (pn && q > 0 && p >= 0) items.push({ product_name: pn, quantity: q, unit_price: p });
  });
  if (!items.length) { toast('请至少添加一个商品', 'err'); return; }
  const payload = {
    customer_name: name,
    customer_contact: document.getElementById('fContact').value.trim(),
    doc_date: document.getElementById('fDocDate').value || null,
    items, discount: parseFloat(document.getElementById('fDiscount').value) || 0,
    tax_rate: parseFloat(document.getElementById('fTax').value) || 0,
    status: document.getElementById('fStatus').value,
    notes: document.getElementById('fNotes').value.trim(),
  };
  const eid = document.getElementById('editId').value;
  try {
    if (eid) { await api(`/documents/${eid}`, { method: 'PUT', body: payload }); toast('已更新', 'ok'); }
    else { await api('/documents', { method: 'POST', body: payload }); toast('已创建', 'ok'); }
    closeModal('formModal'); loadStats(); loadRecords();
  } catch (e) { toast(e.message, 'err'); }
}

async function delDoc(id) {
  if (!confirm('确定删除此单据?')) return;
  await api(`/documents/${id}`, { method: 'DELETE' });
  toast('已删除'); loadStats(); loadRecords();
}

// Form Items
function addFormRow(item = null) {
  const tr = document.createElement('tr');
  tr.innerHTML = `<td><input class="fi item-name" placeholder="商品名称" value="${item?.product_name||''}"></td>
    <td><input class="fi num item-qty" type="number" min="1" value="${item?.quantity||1}" oninput="updRowSub(this)"></td>
    <td><input class="fi num item-price" type="number" min="0" step="0.01" value="${item?.unit_price??''}" placeholder="0.00" oninput="updRowSub(this)"></td>
    <td class="sub">¥0.00</td>
    <td><button class="btn btn-d btn-s" onclick="this.closest('tr').remove()" style="padding:3px 7px">&times;</button></td>`;
  document.getElementById('formItems').appendChild(tr);
  if (item) updRowSub(tr.querySelector('.item-qty'));
}

function updRowSub(el) {
  const tr = el.closest('tr');
  const q = parseInt(tr.querySelector('.item-qty').value) || 0;
  const p = parseFloat(tr.querySelector('.item-price').value) || 0;
  tr.querySelector('.sub').textContent = '¥' + fmtN(q * p);
}

// ═══════════════════════════════════════════════════════════
// Utilities
// ═══════════════════════════════════════════════════════════
function openModal(id) { document.getElementById(id).classList.add('show'); document.body.style.overflow = 'hidden'; }
function closeModal(id) { document.getElementById(id).classList.remove('show'); document.body.style.overflow = ''; }

function toast(msg, type = '') {
  const el = document.createElement('div');
  el.className = 'toast ' + type;
  el.textContent = msg;
  document.getElementById('toasts').appendChild(el);
  setTimeout(() => el.remove(), 3500);
}

function fmtN(n) { return (n||0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
function statusTxt(s) { return { pending: '未审核', completed: '已审核', cancelled: '已取消' }[s] || s; }
function fmtDate(iso) {
  if (!iso) return '-';
  const d = new Date(iso);
  return `${d.getFullYear()}-${S(d.getMonth()+1)}-${S(d.getDate())} ${S(d.getHours())}:${S(d.getMinutes())}`;
}
function S(n) { return String(n).padStart(2, '0'); }
function debounce(fn, ms) { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }

// Keyboard shortcut
document.addEventListener('keydown', (e) => {
  if (e.ctrlKey && e.key === 'Enter' && currentView === 'processing') importJson();
  if (e.key === 'Escape') { document.querySelectorAll('.modal-bg.show').forEach(m => m.classList.remove('show')); document.body.style.overflow = ''; }
});
</script>
</body>
</html>"""

# ── 路由 ──────────────────────────────────────────────────

@app.get("/", response_class=HTMLResponse)
async def index():
    return HTML_CONTENT

if __name__ == "__main__":
    print("=" * 50)
    print("  销售单据 OCR 处理中心")
    print("=" * 50)
    print(f"  待处理目录: {PENDING_DIR}")
    print(f"  已完成目录: {COMPLETED_DIR}")
    print(f"  数据目录:   {DATA_DIR}")
    print("-" * 50)
    print("  将扫描的单据 JPG 放入 pending 目录")
    print("  访问 http://localhost:8000")
    print("=" * 50)
    uvicorn.run(app, host="0.0.0.0", port=8000)
相关推荐
农村小镇哥2 小时前
Html的字体+字符编码+图片标签
chrome·笔记·html
摇滚侠2 小时前
HTML CSS 演示小米 logo 的变化 border-radius 属性设置圆角
前端·css·html
❆VE❆2 小时前
虚拟列表原理与实战运用场景详解
前端·javascript·css·vue.js·html·虚拟列表
weixin_408099672 小时前
【实战教程】EasyClick 调用 OCR 文字识别 API(自动识别屏幕文字 + 完整示例代码)
前端·人工智能·后端·ocr·api·安卓·easyclick
周末也要写八哥12 小时前
html网页设计适合新手的学习路线总结
html
ZC跨境爬虫13 小时前
【爬虫实战对比】Requests vs Scrapy 笔趣阁小说爬虫,从单线程到高效并发的全方位升级
前端·爬虫·scrapy·html
爱上好庆祝13 小时前
svg图片
前端·css·学习·html·css3
weixin_66817 小时前
OCR 模型深度对比分析报告 - AI分析
人工智能·ocr
weixin_4080996717 小时前
【完整教程】天诺脚本如何调用 OCR 文字识别 API?自动识别屏幕文字实战(附代码)
前端·人工智能·后端·ocr·api·天诺脚本·自动识别文字脚本