一个为 AI 助手设计的进销存管理系统,内置完整的 CLI 命令接口,让 AI 可以通过自然语言或命令行直接操作库存。技术栈 FastAPI+Html

进销存管理系统

一个为 AI 助手设计的进销存管理系统,内置完整的 CLI 命令接口,让 AI 可以通过自然语言或命令行直接操作库存。

核心特性

AI 友好的 CLI 接口

系统提供 POST /api/cli 接口,AI 助手可直接执行命令操作:

命令 说明
ls products 列出所有商品
ls categories 列出所有分类
ls stock [in|out] 查看出入库记录
ls sales 查看销售订单
ls low 查看低库存预警
add product <编码> <名称> <分类ID> <进价> <售价> 新增商品
stock in <商品ID> <数量> [进价] 入库操作
stock out <商品ID> <数量> 出库操作
search <关键词> 搜索商品
report stock 库存报表
report profit 利润报表
help 显示帮助
clear 清屏

其他功能

  • 仪表盘 - 实时统计商品数量、库存价值、今日出入库、销售额

  • Web 界面 - CRT 终端风格的管理界面

  • 销售订单 - 创建销售订单,自动扣减库存

  • 报表分析 - 库存报表、利润分析

技术栈

  • 后端: FastAPI

  • 数据库: SQLite (WAL 模式)

  • 前端: 原生 HTML/CSS/JavaScript

  • UI 风格: CRT 终端风格

快速开始

安装依赖

复制代码
pip install fastapi uvicorn

启动服务

复制代码
uvicorn main:app --reload --host 0.0.0.0 --port 8000

访问系统

AI 调用示例

AI 助手可通过 CLI 接口直接操作:

复制代码
POST /api/cli
Content-Type: application/json
​
{"cmd": "ls products"}

返回:

复制代码
ID    编码       名称            分类        单位    进价        售价        库存
──────────────────────────────────────────────────────────────────────────
1     P001      机械键盘        电子产品    个      150.00     299.00     45
2     P002      无线鼠标        电子产品    个      45.00      89.00      110
...
​
共 8 件商品

入库操作:

复制代码
{"cmd": "stock in 1 50 145.00"}

返回:

复制代码
✓ 入库成功: 商品ID=1, 数量=50

项目结构

复制代码
inventory_system/
├── main.py           # FastAPI 后端 (含 CLI 接口)
├── inventory.db      # SQLite 数据库
├── templates/
│   └── index.html    # Web 界面
└── README.md

数据库表结构

表名 说明
categories 商品分类
products 商品信息
stock_records 库存出入库记录
sales_orders 销售订单
sale_items 销售订单明细

REST API

CLI 接口

  • POST /api/cli - 执行 CLI 命令

仪表盘

  • GET /api/dashboard - 获取统计数据

分类管理

  • GET /api/categories - 分类列表

  • POST /api/categories - 创建分类

  • DELETE /api/categories/{id} - 删除分类

商品管理

  • GET /api/products - 商品列表

  • POST /api/products - 创建商品

  • PUT /api/products/{id} - 更新商品

  • DELETE /api/products/{id} - 删除商品

库存操作

  • POST /api/stock/in - 入库

  • POST /api/stock/out - 出库

  • GET /api/stock/records - 出入库记录

销售订单

  • GET /api/sales - 订单列表

  • GET /api/sales/{id} - 订单详情

  • POST /api/sales - 创建订单

报表

  • GET /api/reports/stock - 库存报表

  • GET /api/reports/profit - 利润报表

示例数据

系统首次启动时自动创建:

  • 4 个商品分类

  • 8 个示例商品

  • 若干出入库记录

  • 2 个示例销售订单

代码main.py

python 复制代码
"""
进销存管理系统 --- FastAPI 后端
启动: uvicorn main:app --reload --host 0.0.0.0 --port 8000
"""

import sqlite3
from datetime import datetime
from contextlib import contextmanager
from pathlib import Path

from fastapi import FastAPI, Request, Form, HTTPException
from fastapi.responses import HTMLResponse

app = FastAPI(title="进销存管理系统", version="1.0.0")

DB_PATH = "inventory.db"

# ── 读取 HTML 模板内容(启动时读一次) ──
TEMPLATES_DIR = Path(__file__).parent / "templates"
INDEX_HTML = (TEMPLATES_DIR / "index.html").read_text(encoding="utf-8")


# ──────────────────────────────────────────────
# 数据库初始化
# ──────────────────────────────────────────────
def get_db():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA foreign_keys=ON")
    return conn


@contextmanager
def db_session():
    conn = get_db()
    try:
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()


def init_db():
    with db_session() as conn:
        conn.executescript("""
        CREATE TABLE IF NOT EXISTS categories (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL UNIQUE,
            created_at TEXT DEFAULT (datetime('now','localtime'))
        );

        CREATE TABLE IF NOT EXISTS products (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            code TEXT NOT NULL UNIQUE,
            name TEXT NOT NULL,
            category_id INTEGER,
            unit TEXT DEFAULT '件',
            purchase_price REAL DEFAULT 0,
            sale_price REAL DEFAULT 0,
            stock_qty REAL DEFAULT 0,
            min_stock REAL DEFAULT 0,
            created_at TEXT DEFAULT (datetime('now','localtime')),
            FOREIGN KEY (category_id) REFERENCES categories(id)
        );

        CREATE TABLE IF NOT EXISTS stock_records (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            product_id INTEGER NOT NULL,
            type TEXT NOT NULL CHECK(type IN ('in','out')),
            quantity REAL NOT NULL,
            price REAL DEFAULT 0,
            supplier TEXT DEFAULT '',
            remark TEXT DEFAULT '',
            created_at TEXT DEFAULT (datetime('now','localtime')),
            FOREIGN KEY (product_id) REFERENCES products(id)
        );

        CREATE TABLE IF NOT EXISTS sales_orders (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            order_no TEXT NOT NULL UNIQUE,
            customer TEXT DEFAULT '',
            total_amount REAL DEFAULT 0,
            remark TEXT DEFAULT '',
            created_at TEXT DEFAULT (datetime('now','localtime'))
        );

        CREATE TABLE IF NOT EXISTS sale_items (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            order_id INTEGER NOT NULL,
            product_id INTEGER NOT NULL,
            quantity REAL NOT NULL,
            unit_price REAL NOT NULL,
            subtotal REAL NOT NULL,
            FOREIGN KEY (order_id) REFERENCES sales_orders(id),
            FOREIGN KEY (product_id) REFERENCES products(id)
        );
        """)
        # 插入示例数据(如果表为空)
        cur = conn.execute("SELECT COUNT(*) FROM categories")
        if cur.fetchone()[0] == 0:
            conn.executescript("""
            INSERT INTO categories (name) VALUES ('电子产品'), ('办公用品'), ('食品饮料'), ('日用百货');

            INSERT INTO products (code, name, category_id, unit, purchase_price, sale_price, stock_qty, min_stock)
            VALUES
                ('P001', '机械键盘', 1, '个', 150.00, 299.00, 50, 10),
                ('P002', '无线鼠标', 1, '个', 45.00, 89.00, 120, 20),
                ('P003', 'USB-C扩展坞', 1, '个', 80.00, 168.00, 35, 5),
                ('P004', 'A4打印纸', 2, '箱', 25.00, 45.00, 200, 30),
                ('P005', '签字笔(盒)', 2, '盒', 8.00, 15.00, 80, 10),
                ('P006', '矿泉水(箱)', 3, '箱', 18.00, 32.00, 60, 20),
                ('P007', '咖啡豆(袋)', 3, '袋', 35.00, 68.00, 25, 5),
                ('P008', '垃圾袋(卷)', 4, '卷', 3.00, 8.00, 300, 50);

            INSERT INTO stock_records (product_id, type, quantity, price, supplier, remark, created_at)
            VALUES
                (1, 'in', 30, 150.00, '深圳供应商', '首次入库', '2025-04-01 09:00:00'),
                (1, 'in', 20, 148.00, '深圳供应商', '补货', '2025-04-10 14:30:00'),
                (2, 'in', 120, 45.00, '东莞工厂', '批量采购', '2025-04-01 09:15:00'),
                (4, 'in', 200, 25.00, '本地文具批发', '月度采购', '2025-04-05 10:00:00'),
                (6, 'in', 60, 18.00, '饮用水配送', '月度配送', '2025-04-02 08:00:00'),
                (1, 'out', 5, 0, '', '内部领用', '2025-04-12 11:00:00'),
                (2, 'out', 10, 0, '', '部门领用', '2025-04-12 11:05:00');

            INSERT INTO sales_orders (order_no, customer, total_amount, remark, created_at)
            VALUES
                ('SO2025040001', '张三', 598.00, '', '2025-04-15 14:00:00'),
                ('SO2025040002', '李四科技', 2457.00, '公司采购', '2025-04-18 10:30:00');

            INSERT INTO sale_items (order_id, product_id, quantity, unit_price, subtotal)
            VALUES
                (1, 1, 2, 299.00, 598.00),
                (2, 2, 5, 89.00, 445.00),
                (2, 3, 3, 168.00, 504.00),
                (2, 1, 5, 299.00, 1495.00),
                (2, 7, 1, 68.00, 68.00);
            """)


init_db()


# ──────────────────────────────────────────────
# 页面路由
# ──────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def index():
    return HTMLResponse(content=INDEX_HTML)


# ──────────────────────────────────────────────
# 仪表盘统计
# ──────────────────────────────────────────────
@app.get("/api/dashboard")
async def dashboard():
    with db_session() as conn:
        products_count = conn.execute("SELECT COUNT(*) FROM products").fetchone()[0]
        total_stock_value = conn.execute(
            "SELECT COALESCE(SUM(stock_qty * purchase_price), 0) FROM products"
        ).fetchone()[0]
        today = datetime.now().strftime("%Y-%m-%d")
        today_in = conn.execute(
            "SELECT COALESCE(SUM(quantity),0) FROM stock_records WHERE type='in' AND date(created_at)=?",
            (today,)
        ).fetchone()[0]
        today_out = conn.execute(
            "SELECT COALESCE(SUM(quantity),0) FROM stock_records WHERE type='out' AND date(created_at)=?",
            (today,)
        ).fetchone()[0]
        low_stock = conn.execute(
            "SELECT COUNT(*) FROM products WHERE stock_qty <= min_stock"
        ).fetchone()[0]
        today_sales = conn.execute(
            "SELECT COALESCE(SUM(total_amount),0) FROM sales_orders WHERE date(created_at)=?",
            (today,)
        ).fetchone()[0]
        month_sales = conn.execute(
            "SELECT COALESCE(SUM(total_amount),0) FROM sales_orders WHERE strftime('%Y-%m',created_at)=?",
            (datetime.now().strftime("%Y-%m"),)
        ).fetchone()[0]

        sales_trend = conn.execute("""
            SELECT date(created_at) as dt, COALESCE(SUM(total_amount),0) as amt
            FROM sales_orders
            WHERE created_at >= date('now','-7 days')
            GROUP BY date(created_at) ORDER BY dt
        """).fetchall()

        stock_dist = conn.execute("""
            SELECT c.name, COALESCE(SUM(p.stock_qty),0) as qty
            FROM categories c LEFT JOIN products p ON p.category_id = c.id
            GROUP BY c.id ORDER BY qty DESC
        """).fetchall()

    return {
        "products_count": products_count,
        "total_stock_value": round(total_stock_value, 2),
        "today_in": today_in,
        "today_out": today_out,
        "low_stock_count": low_stock,
        "today_sales": round(today_sales, 2),
        "month_sales": round(month_sales, 2),
        "sales_trend": [{"date": r["dt"], "amount": r["amt"]} for r in sales_trend],
        "stock_dist": [{"name": r["name"], "qty": r["qty"]} for r in stock_dist],
    }


# ──────────────────────────────────────────────
# 分类 CRUD
# ──────────────────────────────────────────────
@app.get("/api/categories")
async def list_categories():
    with db_session() as conn:
        rows = conn.execute("""
            SELECT c.*, COUNT(p.id) as product_count
            FROM categories c LEFT JOIN products p ON p.category_id = c.id
            GROUP BY c.id ORDER BY c.name
        """).fetchall()
    return [dict(r) for r in rows]


@app.post("/api/categories")
async def create_category(name: str = Form(...)):
    with db_session() as conn:
        try:
            conn.execute("INSERT INTO categories (name) VALUES (?)", (name,))
        except sqlite3.IntegrityError:
            raise HTTPException(400, "分类已存在")
    return {"ok": True, "message": f"分类 '{name}' 已创建"}


@app.delete("/api/categories/{cid}")
async def delete_category(cid: int):
    with db_session() as conn:
        conn.execute("DELETE FROM categories WHERE id=?", (cid,))
    return {"ok": True}


# ──────────────────────────────────────────────
# 商品 CRUD
# ──────────────────────────────────────────────
@app.get("/api/products")
async def list_products(keyword: str = "", category_id: int = 0):
    with db_session() as conn:
        sql = """
            SELECT p.*, c.name as category_name
            FROM products p LEFT JOIN categories c ON p.category_id = c.id
            WHERE 1=1
        """
        params = []
        if keyword:
            sql += " AND (p.code LIKE ? OR p.name LIKE ?)"
            params += [f"%{keyword}%", f"%{keyword}%"]
        if category_id:
            sql += " AND p.category_id = ?"
            params.append(category_id)
        sql += " ORDER BY p.id DESC"
        rows = conn.execute(sql, params).fetchall()
    return [dict(r) for r in rows]


@app.post("/api/products")
async def create_product(
    code: str = Form(...),
    name: str = Form(...),
    category_id: int = Form(0),
    unit: str = Form("件"),
    purchase_price: float = Form(0),
    sale_price: float = Form(0),
    stock_qty: float = Form(0),
    min_stock: float = Form(0),
):
    with db_session() as conn:
        try:
            conn.execute(
                """INSERT INTO products (code,name,category_id,unit,purchase_price,sale_price,stock_qty,min_stock)
                   VALUES (?,?,?,?,?,?,?,?)""",
                (code, name, category_id or None, unit, purchase_price, sale_price, stock_qty, min_stock),
            )
        except sqlite3.IntegrityError:
            raise HTTPException(400, f"商品编码 '{code}' 已存在")
    return {"ok": True, "message": f"商品 '{name}' 已创建"}


@app.put("/api/products/{pid}")
async def update_product(pid: int, request: Request):
    data = await request.json()
    fields = []
    params = []
    allowed = ["code", "name", "category_id", "unit", "purchase_price", "sale_price", "stock_qty", "min_stock"]
    for k in allowed:
        if k in data:
            fields.append(f"{k}=?")
            params.append(data[k])
    if not fields:
        raise HTTPException(400, "无更新字段")
    params.append(pid)
    with db_session() as conn:
        conn.execute(f"UPDATE products SET {','.join(fields)} WHERE id=?", params)
    return {"ok": True}


@app.delete("/api/products/{pid}")
async def delete_product(pid: int):
    with db_session() as conn:
        conn.execute("DELETE FROM products WHERE id=?", (pid,))
    return {"ok": True}


# ──────────────────────────────────────────────
# 入库 / 出库
# ──────────────────────────────────────────────
@app.post("/api/stock/in")
async def stock_in(
    product_id: int = Form(...),
    quantity: float = Form(...),
    price: float = Form(0),
    supplier: str = Form(""),
    remark: str = Form(""),
):
    with db_session() as conn:
        conn.execute(
            "INSERT INTO stock_records (product_id,type,quantity,price,supplier,remark) VALUES (?,?,?,?,?,?)",
            (product_id, "in", quantity, price, supplier, remark),
        )
        conn.execute(
            "UPDATE products SET stock_qty = stock_qty + ? WHERE id=?",
            (quantity, product_id),
        )
    return {"ok": True, "message": f"入库 {quantity} 件成功"}


@app.post("/api/stock/out")
async def stock_out(
    product_id: int = Form(...),
    quantity: float = Form(...),
    remark: str = Form(""),
):
    with db_session() as conn:
        row = conn.execute("SELECT stock_qty FROM products WHERE id=?", (product_id,)).fetchone()
        if not row:
            raise HTTPException(404, "商品不存在")
        if row["stock_qty"] < quantity:
            raise HTTPException(400, f"库存不足! 当前库存: {row['stock_qty']}")
        conn.execute(
            "INSERT INTO stock_records (product_id,type,quantity,remark) VALUES (?,?,?,?)",
            (product_id, "out", quantity, remark),
        )
        conn.execute(
            "UPDATE products SET stock_qty = stock_qty - ? WHERE id=?",
            (quantity, product_id),
        )
    return {"ok": True, "message": f"出库 {quantity} 件成功"}


@app.get("/api/stock/records")
async def stock_records(product_id: int = 0, type_filter: str = "", limit: int = 100):
    with db_session() as conn:
        sql = """
            SELECT sr.*, p.code as product_code, p.name as product_name
            FROM stock_records sr JOIN products p ON sr.product_id = p.id
            WHERE 1=1
        """
        params = []
        if product_id:
            sql += " AND sr.product_id=?"
            params.append(product_id)
        if type_filter in ("in", "out"):
            sql += " AND sr.type=?"
            params.append(type_filter)
        sql += f" ORDER BY sr.id DESC LIMIT {limit}"
        rows = conn.execute(sql, params).fetchall()
    return [dict(r) for r in rows]


# ──────────────────────────────────────────────
# 销售订单
# ──────────────────────────────────────────────
@app.get("/api/sales")
async def list_sales(limit: int = 50):
    with db_session() as conn:
        rows = conn.execute(
            "SELECT * FROM sales_orders ORDER BY id DESC LIMIT ?", (limit,)
        ).fetchall()
    return [dict(r) for r in rows]


@app.get("/api/sales/{order_id}")
async def get_sale(order_id: int):
    with db_session() as conn:
        order = conn.execute("SELECT * FROM sales_orders WHERE id=?", (order_id,)).fetchone()
        if not order:
            raise HTTPException(404, "订单不存在")
        items = conn.execute("""
            SELECT si.*, p.code as product_code, p.name as product_name
            FROM sale_items si JOIN products p ON si.product_id = p.id
            WHERE si.order_id=?
        """, (order_id,)).fetchall()
    return {"order": dict(order), "items": [dict(i) for i in items]}


@app.post("/api/sales")
async def create_sale(request: Request):
    data = await request.json()
    items = data.get("items", [])
    if not items:
        raise HTTPException(400, "订单项不能为空")

    with db_session() as conn:
        today_str = datetime.now().strftime("%Y%m%d")
        row = conn.execute(
            "SELECT COUNT(*) as cnt FROM sales_orders WHERE order_no LIKE ?",
            (f"SO{today_str}%",)
        ).fetchone()
        order_no = f"SO{today_str}{row['cnt'] + 1:04d}"

        total = 0
        for item in items:
            subtotal = item["quantity"] * item["unit_price"]
            total += subtotal
            prod = conn.execute("SELECT stock_qty FROM products WHERE id=?", (item["product_id"],)).fetchone()
            if not prod:
                raise HTTPException(404, f"商品ID {item['product_id']} 不存在")
            if prod["stock_qty"] < item["quantity"]:
                raise HTTPException(400, f"商品ID {item['product_id']} 库存不足")

        conn.execute(
            "INSERT INTO sales_orders (order_no, customer, total_amount, remark) VALUES (?,?,?,?)",
            (order_no, data.get("customer", ""), round(total, 2), data.get("remark", "")),
        )
        order_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]

        for item in items:
            subtotal = round(item["quantity"] * item["unit_price"], 2)
            conn.execute(
                "INSERT INTO sale_items (order_id, product_id, quantity, unit_price, subtotal) VALUES (?,?,?,?,?)",
                (order_id, item["product_id"], item["quantity"], item["unit_price"], subtotal),
            )
            conn.execute(
                "UPDATE products SET stock_qty = stock_qty - ? WHERE id=?",
                (item["quantity"], item["product_id"]),
            )
            conn.execute(
                "INSERT INTO stock_records (product_id,type,quantity,price,remark) VALUES (?,'out',?,?,?)",
                (item["product_id"], item["quantity"], item["unit_price"], f"销售订单{order_no}"),
            )

    return {"ok": True, "order_no": order_no, "message": f"订单 {order_no} 创建成功, 金额: {total:.2f}"}


# ──────────────────────────────────────────────
# 报表 --- 库存报表
# ──────────────────────────────────────────────
@app.get("/api/reports/stock")
async def stock_report():
    with db_session() as conn:
        rows = conn.execute("""
            SELECT p.id, p.code, p.name, c.name as category, p.unit,
                   p.purchase_price, p.sale_price, p.stock_qty, p.min_stock,
                   ROUND(p.stock_qty * p.purchase_price, 2) as stock_value,
                   CASE WHEN p.stock_qty <= p.min_stock THEN 1 ELSE 0 END as is_low
            FROM products p LEFT JOIN categories c ON p.category_id = c.id
            ORDER BY is_low DESC, p.stock_qty ASC
        """).fetchall()
    return [dict(r) for r in rows]


@app.get("/api/reports/profit")
async def profit_report():
    with db_session() as conn:
        rows = conn.execute("""
            SELECT p.code, p.name, p.purchase_price,
                   COALESCE(SUM(si.quantity), 0) as total_sold,
                   COALESCE(SUM(si.subtotal), 0) as total_revenue,
                   COALESCE(SUM(si.quantity * p.purchase_price), 0) as total_cost,
                   ROUND(COALESCE(SUM(si.subtotal) - SUM(si.quantity * p.purchase_price), 0), 2) as gross_profit
            FROM products p
            LEFT JOIN sale_items si ON si.product_id = p.id
            GROUP BY p.id
            HAVING total_sold > 0
            ORDER BY gross_profit DESC
        """).fetchall()
    return [dict(r) for r in rows]


# ──────────────────────────────────────────────
# CLI 命令接口
# ──────────────────────────────────────────────
@app.post("/api/cli")
async def cli_command(request: Request):
    data = await request.json()
    cmd = data.get("cmd", "").strip()
    if not cmd:
        return {"output": "请输入命令。输入 help 查看帮助。"}

    parts = cmd.split()
    action = parts[0].lower()

    if action == "help":
        return {"output": """
╔══════════════════════════════════════════════════════╗
║                   命 令 帮 助                        ║
╠══════════════════════════════════════════════════════╣
║  ls products          --- 列出所有商品                 ║
║  ls categories        --- 列出所有分类                 ║
║  ls stock [in|out]    --- 查看出入库记录               ║
║  ls sales             --- 查看销售订单                 ║
║  ls low               --- 查看低库存预警               ║
║  add product <编码> <名称> <分类ID> <进价> <售价>     ║
║  stock in <商品ID> <数量> [进价]                     ║
║  stock out <商品ID> <数量>                           ║
║  search <关键词>      --- 搜索商品                     ║
║  report stock         --- 库存报表                     ║
║  report profit        --- 利润报表                     ║
║  clear                --- 清屏                         ║
║  help                 --- 显示此帮助                   ║
╚══════════════════════════════════════════════════════╝
"""}

    if action == "clear":
        return {"output": "__CLEAR__"}

    if action == "ls" and len(parts) >= 2:
        target = parts[1].lower()
        if target == "products":
            with db_session() as conn:
                rows = conn.execute("""
                    SELECT p.id, p.code, p.name, c.name as cat, p.unit,
                           p.purchase_price, p.sale_price, p.stock_qty
                    FROM products p LEFT JOIN categories c ON p.category_id=c.id ORDER BY p.id
                """).fetchall()
            if not rows:
                return {"output": "暂无商品。"}
            lines = [f"{'ID':<5}{'编码':<10}{'名称':<16}{'分类':<10}{'单位':<5}{'进价':<10}{'售价':<10}{'库存':<8}"]
            lines.append("─" * 74)
            for r in rows:
                lines.append(f"{r['id']:<5}{r['code']:<10}{r['name']:<16}{(r['cat'] or '-'):<10}{r['unit']:<5}{r['purchase_price']:<10.2f}{r['sale_price']:<10.2f}{r['stock_qty']:<8.0f}")
            lines.append(f"\n共 {len(rows)} 件商品")
            return {"output": "\n".join(lines)}

        if target == "categories":
            with db_session() as conn:
                rows = conn.execute("SELECT * FROM categories ORDER BY id").fetchall()
            lines = [f"{r['id']}. {r['name']}" for r in rows]
            return {"output": "\n".join(lines) if lines else "暂无分类。"}

        if target == "stock":
            tf = parts[2] if len(parts) >= 3 else ""
            with db_session() as conn:
                sql = """SELECT sr.*, p.code, p.name FROM stock_records sr
                         JOIN products p ON sr.product_id=p.id WHERE 1=1"""
                params = []
                if tf in ("in", "out"):
                    sql += " AND sr.type=?"
                    params.append(tf)
                sql += " ORDER BY sr.id DESC LIMIT 30"
                rows = conn.execute(sql, params).fetchall()
            if not rows:
                return {"output": "暂无记录。"}
            lines = [f"{'ID':<5}{'类型':<6}{'编码':<10}{'名称':<16}{'数量':<8}{'单价':<10}{'供应商':<12}{'时间':<20}{'备注'}"]
            lines.append("─" * 100)
            for r in rows:
                t = "入库" if r["type"] == "in" else "出库"
                lines.append(f"{r['id']:<5}{t:<6}{r['code']:<10}{r['name']:<16}{r['quantity']:<8.0f}{r['price']:<10.2f}{(r['supplier'] or '-'):<12}{r['created_at']:<20}{r['remark']}")
            return {"output": "\n".join(lines)}

        if target == "sales":
            with db_session() as conn:
                rows = conn.execute("SELECT * FROM sales_orders ORDER BY id DESC LIMIT 20").fetchall()
            if not rows:
                return {"output": "暂无销售订单。"}
            lines = [f"{'订单号':<20}{'客户':<12}{'金额':<12}{'时间':<20}{'备注'}"]
            lines.append("─" * 70)
            for r in rows:
                lines.append(f"{r['order_no']:<20}{(r['customer'] or '-'):<12}{r['total_amount']:<12.2f}{r['created_at']:<20}{r['remark']}")
            return {"output": "\n".join(lines)}

        if target == "low":
            with db_session() as conn:
                rows = conn.execute(
                    "SELECT id, code, name, stock_qty, min_stock FROM products WHERE stock_qty <= min_stock ORDER BY stock_qty"
                ).fetchall()
            if not rows:
                return {"output": "✓ 所有商品库存正常,无预警。"}
            lines = ["⚠ 低库存预警:"]
            lines.append(f"{'ID':<5}{'编码':<10}{'名称':<16}{'当前库存':<10}{'最低库存'}")
            lines.append("─" * 50)
            for r in rows:
                lines.append(f"{r['id']:<5}{r['code']:<10}{r['name']:<16}{r['stock_qty']:<10.0f}{r['min_stock']:.0f}")
            return {"output": "\n".join(lines)}

    if action == "search" and len(parts) >= 2:
        keyword = " ".join(parts[1:])
        with db_session() as conn:
            rows = conn.execute(
                "SELECT id, code, name, stock_qty, sale_price FROM products WHERE code LIKE ? OR name LIKE ?",
                (f"%{keyword}%", f"%{keyword}%")
            ).fetchall()
        if not rows:
            return {"output": f"未找到包含 '{keyword}' 的商品。"}
        lines = [f"{'ID':<5}{'编码':<10}{'名称':<20}{'库存':<8}{'售价'}"]
        lines.append("─" * 55)
        for r in rows:
            lines.append(f"{r['id']:<5}{r['code']:<10}{r['name']:<20}{r['stock_qty']:<8.0f}{r['sale_price']:.2f}")
        return {"output": "\n".join(lines)}

    if action == "add" and len(parts) >= 3 and parts[1] == "product":
        if len(parts) < 7:
            return {"output": "用法: add product <编码> <名称> <分类ID> <进价> <售价>"}
        code, name, cid, pp, sp = parts[2], parts[3], int(parts[4]), float(parts[5]), float(parts[6])
        with db_session() as conn:
            try:
                conn.execute(
                    "INSERT INTO products (code,name,category_id,purchase_price,sale_price) VALUES (?,?,?,?,?)",
                    (code, name, cid, pp, sp),
                )
            except sqlite3.IntegrityError:
                return {"output": f"错误: 编码 '{code}' 已存在"}
        return {"output": f"✓ 商品 '{name}' ({code}) 已添加"}

    if action == "stock" and len(parts) >= 4:
        stype = parts[1].lower()
        pid, qty = int(parts[2]), float(parts[3])
        if stype == "in":
            price = float(parts[4]) if len(parts) >= 5 else 0
            with db_session() as conn:
                conn.execute(
                    "INSERT INTO stock_records (product_id,type,quantity,price) VALUES (?,'in',?,?)",
                    (pid, qty, price),
                )
                conn.execute("UPDATE products SET stock_qty=stock_qty+? WHERE id=?", (qty, pid))
            return {"output": f"✓ 入库成功: 商品ID={pid}, 数量={qty}"}
        elif stype == "out":
            with db_session() as conn:
                row = conn.execute("SELECT stock_qty FROM products WHERE id=?", (pid,)).fetchone()
                if not row:
                    return {"output": "错误: 商品不存在"}
                if row["stock_qty"] < qty:
                    return {"output": f"错误: 库存不足 (当前: {row['stock_qty']})"}
                conn.execute(
                    "INSERT INTO stock_records (product_id,type,quantity) VALUES (?,'out',?)",
                    (pid, qty),
                )
                conn.execute("UPDATE products SET stock_qty=stock_qty-? WHERE id=?", (qty, pid))
            return {"output": f"✓ 出库成功: 商品ID={pid}, 数量={qty}"}

    if action == "report" and len(parts) >= 2:
        target = parts[1].lower()
        if target == "stock":
            with db_session() as conn:
                rows = conn.execute("""
                    SELECT p.code, p.name, c.name as cat, p.stock_qty, p.purchase_price,
                           ROUND(p.stock_qty * p.purchase_price, 2) as val
                    FROM products p LEFT JOIN categories c ON p.category_id=c.id
                    ORDER BY val DESC
                """).fetchall()
            total_val = sum(r["val"] for r in rows)
            lines = [f"{'编码':<10}{'名称':<16}{'分类':<10}{'库存':<8}{'进价':<10}{'库存金额'}"]
            lines.append("─" * 65)
            for r in rows:
                lines.append(f"{r['code']:<10}{r['name']:<16}{(r['cat'] or '-'):<10}{r['stock_qty']:<8.0f}{r['purchase_price']:<10.2f}{r['val']:.2f}")
            lines.append("─" * 65)
            lines.append(f"库存总金额: ¥{total_val:,.2f}")
            return {"output": "\n".join(lines)}

        if target == "profit":
            with db_session() as conn:
                rows = conn.execute("""
                    SELECT p.code, p.name, p.purchase_price,
                           COALESCE(SUM(si.quantity),0) as sold,
                           COALESCE(SUM(si.subtotal),0) as revenue,
                           ROUND(COALESCE(SUM(si.subtotal)-SUM(si.quantity*p.purchase_price),0),2) as profit
                    FROM products p LEFT JOIN sale_items si ON si.product_id=p.id
                    GROUP BY p.id HAVING sold>0 ORDER BY profit DESC
                """).fetchall()
            if not rows:
                return {"output": "暂无销售数据。"}
            total_rev = sum(r["revenue"] for r in rows)
            total_profit = sum(r["profit"] for r in rows)
            lines = [f"{'编码':<10}{'名称':<16}{'进价':<10}{'已售':<8}{'销售额':<12}{'毛利'}"]
            lines.append("─" * 65)
            for r in rows:
                lines.append(f"{r['code']:<10}{r['name']:<16}{r['purchase_price']:<10.2f}{r['sold']:<8.0f}{r['revenue']:<12.2f}{r['profit']:.2f}")
            lines.append("─" * 65)
            lines.append(f"合计  {'':26}{'':8}{total_rev:<12.2f}{total_profit:.2f}")
            rate = (total_profit / total_rev * 100) if total_rev else 0
            lines.append(f"综合毛利率: {rate:.1f}%")
            return {"output": "\n".join(lines)}

    return {"output": f"未知命令: {cmd}\n输入 help 查看帮助。"}


if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

代码index.html

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>进销存管理系统 --- TERMINAL v2.5</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
/* ═══════════════════════════════════════════
   ROOT VARIABLES & RESET
   ═══════════════════════════════════════════ */
:root {
  --bg-deep: #050a05;
  --bg: #0a120a;
  --bg-panel: #0d1a0d;
  --bg-input: #081008;
  --border: #1a3a1a;
  --border-bright: #2a5a2a;
  --green: #00ff41;
  --green-dim: #00cc33;
  --green-dark: #008020;
  --green-muted: #1a5c1a;
  --amber: #ffb000;
  --amber-dim: #cc8800;
  --red: #ff3333;
  --red-dim: #cc2222;
  --cyan: #00e5ff;
  --cyan-dim: #0099aa;
  --text: #b0d4b0;
  --text-dim: #5a8a5a;
  --text-bright: #d0ffd0;
  --font-mono: 'Fira Code', 'Courier New', monospace;
  --font-cn: 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
  --scanline: rgba(0, 255, 65, 0.03);
  --glow: 0 0 10px rgba(0, 255, 65, 0.15), 0 0 40px rgba(0, 255, 65, 0.05);
}

* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  background: var(--bg-deep);
  color: var(--text);
  font-family: var(--font-cn);
  font-size: 14px;
  min-height: 100vh;
  overflow: hidden;
}

/* CRT Scanline overlay */
body::before {
  content: '';
  position: fixed;
  inset: 0;
  background: repeating-linear-gradient(
    0deg,
    transparent,
    transparent 2px,
    var(--scanline) 2px,
    var(--scanline) 4px
  );
  pointer-events: none;
  z-index: 9999;
}

/* CRT vignette */
body::after {
  content: '';
  position: fixed;
  inset: 0;
  background: radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,0.4) 100%);
  pointer-events: none;
  z-index: 9998;
}

/* ═══════════════════════════════════════════
   LAYOUT --- 改为两行布局,去掉终端面板行
   ═══════════════════════════════════════════ */
.app {
  display: grid;
  grid-template-columns: 220px 1fr;
  grid-template-rows: 48px 1fr;
  height: 100vh;
  gap: 1px;
  background: var(--border);
}

/* ─── Header ─── */
.header {
  grid-column: 1 / -1;
  background: var(--bg);
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 20px;
  border-bottom: 1px solid var(--border-bright);
  box-shadow: 0 2px 20px rgba(0, 255, 65, 0.05);
}

.header-title {
  font-family: var(--font-mono);
  font-size: 15px;
  font-weight: 600;
  color: var(--green);
  text-shadow: var(--glow);
  letter-spacing: 2px;
}

.header-title .blink {
  animation: blink 1s step-end infinite;
}

@keyframes blink {
  50% { opacity: 0; }
}

.header-status {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--text-dim);
  display: flex;
  gap: 20px;
  align-items: center;
}

.status-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--green);
  box-shadow: 0 0 6px var(--green);
  display: inline-block;
  margin-right: 4px;
  animation: pulse-dot 2s ease-in-out infinite;
}

@keyframes pulse-dot {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}

/* ─── Sidebar ─── */
.sidebar {
  background: var(--bg);
  padding: 12px 0;
  overflow-y: auto;
  border-right: 1px solid var(--border);
}

.nav-section {
  margin-bottom: 8px;
}

.nav-label {
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: 2px;
  padding: 8px 16px 4px;
}

.nav-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 16px;
  cursor: pointer;
  font-family: var(--font-cn);
  font-size: 13px;
  color: var(--text-dim);
  transition: all 0.15s;
  border-left: 2px solid transparent;
}

.nav-item:hover {
  background: rgba(0, 255, 65, 0.03);
  color: var(--text);
}

.nav-item.active {
  background: rgba(0, 255, 65, 0.06);
  color: var(--green);
  border-left-color: var(--green);
}

.nav-icon {
  font-family: var(--font-mono);
  font-size: 14px;
  width: 20px;
  text-align: center;
}

/* ─── Main Content ─── */
.main {
  background: var(--bg-deep);
  overflow-y: auto;
  padding: 20px;
}

.main::-webkit-scrollbar { width: 6px; }
.main::-webkit-scrollbar-track { background: var(--bg); }
.main::-webkit-scrollbar-thumb { background: var(--border-bright); border-radius: 3px; }

/* ═══════════════════════════════════════════
   FLOATING TERMINAL --- 弹出式终端窗口
   ═══════════════════════════════════════════ */

/* 浮动按钮 */
.terminal-fab {
  position: fixed;
  bottom: 24px;
  right: 24px;
  width: 52px;
  height: 52px;
  border-radius: 50%;
  background: var(--bg-panel);
  border: 2px solid var(--green-dark);
  color: var(--green);
  font-family: var(--font-mono);
  font-size: 20px;
  cursor: pointer;
  z-index: 5000;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 0 20px rgba(0, 255, 65, 0.15), 0 4px 20px rgba(0, 0, 0, 0.5);
  transition: all 0.2s;
}

.terminal-fab:hover {
  background: rgba(0, 255, 65, 0.1);
  border-color: var(--green);
  box-shadow: 0 0 30px rgba(0, 255, 65, 0.25), 0 4px 20px rgba(0, 0, 0, 0.5);
  transform: scale(1.05);
}

.terminal-fab.active {
  background: rgba(0, 255, 65, 0.15);
  border-color: var(--green);
}

.terminal-fab .badge {
  position: absolute;
  top: -4px;
  right: -4px;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--amber);
  font-size: 9px;
  display: none;
  align-items: center;
  justify-content: center;
  color: #000;
  font-weight: 700;
}

/* 弹出窗口容器 */
.terminal-popup {
  position: fixed;
  bottom: 88px;
  right: 24px;
  width: 680px;
  height: 420px;
  min-width: 400px;
  min-height: 250px;
  background: var(--bg);
  border: 1px solid var(--border-bright);
  border-radius: 6px;
  z-index: 5001;
  display: none;
  flex-direction: column;
  box-shadow:
    0 0 40px rgba(0, 255, 65, 0.08),
    0 20px 60px rgba(0, 0, 0, 0.6),
    inset 0 0 80px rgba(0, 255, 65, 0.02);
  overflow: hidden;
  animation: terminalOpen 0.25s ease;
}

.terminal-popup.open {
  display: flex;
}

@keyframes terminalOpen {
  from {
    opacity: 0;
    transform: translateY(20px) scale(0.95);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

/* 标题栏 --- 可拖拽 */
.terminal-titlebar {
  height: 36px;
  background: var(--bg-panel);
  border-bottom: 1px solid var(--border);
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 12px;
  cursor: move;
  user-select: none;
  flex-shrink: 0;
}

.terminal-titlebar-left {
  display: flex;
  align-items: center;
  gap: 10px;
}

.terminal-titlebar-dots {
  display: flex;
  gap: 6px;
}

.terminal-titlebar-dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  border: 1px solid;
}

.dot-close {
  border-color: var(--red-dim);
  background: rgba(255, 51, 51, 0.2);
}

.dot-minimize {
  border-color: var(--amber-dim);
  background: rgba(255, 176, 0, 0.2);
}

.dot-maximize {
  border-color: var(--green-dark);
  background: rgba(0, 255, 65, 0.2);
}

.terminal-titlebar-text {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--text-dim);
  letter-spacing: 1px;
}

.terminal-titlebar-actions {
  display: flex;
  gap: 4px;
}

.terminal-titlebar-btn {
  background: none;
  border: 1px solid transparent;
  color: var(--text-dim);
  font-family: var(--font-mono);
  font-size: 14px;
  cursor: pointer;
  width: 26px;
  height: 26px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 3px;
  transition: all 0.15s;
}

.terminal-titlebar-btn:hover {
  color: var(--text);
  border-color: var(--border-bright);
  background: rgba(0, 255, 65, 0.05);
}

.terminal-titlebar-btn.close-btn:hover {
  color: var(--red);
  border-color: var(--red-dim);
  background: rgba(255, 51, 51, 0.08);
}

/* 输出区域 */
.terminal-output {
  flex: 1;
  overflow-y: auto;
  padding: 12px 16px;
  font-family: var(--font-mono);
  font-size: 13px;
  line-height: 1.6;
  white-space: pre-wrap;
  word-break: break-all;
  color: var(--green-dim);
}

.terminal-output::-webkit-scrollbar { width: 4px; }
.terminal-output::-webkit-scrollbar-thumb { background: var(--border-bright); }

.terminal-output .cmd-echo {
  color: var(--text-dim);
}

.terminal-output .cmd-result {
  color: var(--green);
}

.terminal-output .error {
  color: var(--red);
}

/* 输入行 */
.terminal-input-row {
  display: flex;
  align-items: center;
  padding: 8px 16px;
  border-top: 1px solid var(--border);
  background: var(--bg-input);
  flex-shrink: 0;
}

.prompt {
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--amber);
  margin-right: 8px;
  white-space: nowrap;
  text-shadow: 0 0 8px rgba(255, 176, 0, 0.3);
}

.terminal-input {
  flex: 1;
  background: transparent;
  border: none;
  outline: none;
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--green);
  caret-color: var(--green);
}

/* 状态栏 */
.terminal-statusbar {
  height: 22px;
  background: var(--bg-panel);
  border-top: 1px solid var(--border);
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 12px;
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--text-dim);
  flex-shrink: 0;
}

/* 调整大小手柄 */
.resize-handle {
  position: absolute;
  bottom: 0;
  right: 0;
  width: 16px;
  height: 16px;
  cursor: nwse-resize;
  z-index: 10;
}

.resize-handle::after {
  content: '';
  position: absolute;
  bottom: 3px;
  right: 3px;
  width: 8px;
  height: 8px;
  border-right: 2px solid var(--border-bright);
  border-bottom: 2px solid var(--border-bright);
}

/* 全屏模式 */
.terminal-popup.fullscreen {
  bottom: 0 !important;
  right: 0 !important;
  width: 100vw !important;
  height: 100vh !important;
  border-radius: 0;
}

/* ═══════════════════════════════════════════
   PAGE: DASHBOARD
   ═══════════════════════════════════════════ */
.page { display: none; }
.page.active { display: block; }

.page-title {
  font-family: var(--font-mono);
  font-size: 18px;
  font-weight: 600;
  color: var(--green);
  margin-bottom: 20px;
  text-shadow: var(--glow);
  display: flex;
  align-items: center;
  gap: 10px;
}

.page-title::before {
  content: '>';
  color: var(--amber);
}

.stats-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 12px;
  margin-bottom: 24px;
}

.stat-card {
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 16px;
  transition: border-color 0.2s;
}

.stat-card:hover {
  border-color: var(--border-bright);
}

.stat-label {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: 1px;
  margin-bottom: 6px;
}

.stat-value {
  font-family: var(--font-mono);
  font-size: 26px;
  font-weight: 700;
  color: var(--green);
  text-shadow: 0 0 15px rgba(0, 255, 65, 0.2);
}

.stat-value.amber { color: var(--amber); text-shadow: 0 0 15px rgba(255, 176, 0, 0.2); }
.stat-value.red { color: var(--red); text-shadow: 0 0 15px rgba(255, 51, 51, 0.2); }
.stat-value.cyan { color: var(--cyan); text-shadow: 0 0 15px rgba(0, 229, 255, 0.2); }

.stat-sub {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--text-dim);
  margin-top: 4px;
}

/* Charts area */
.charts-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
  margin-bottom: 20px;
}

.chart-box {
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 16px;
}

.chart-title {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--text-dim);
  margin-bottom: 12px;
  text-transform: uppercase;
  letter-spacing: 1px;
}

/* Simple bar chart */
.bar-chart {
  display: flex;
  align-items: flex-end;
  gap: 8px;
  height: 120px;
  padding-top: 10px;
}

.bar-col {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  height: 100%;
  justify-content: flex-end;
}

.bar {
  width: 100%;
  max-width: 40px;
  background: linear-gradient(to top, var(--green-dark), var(--green));
  border-radius: 2px 2px 0 0;
  min-height: 2px;
  transition: height 0.5s ease;
  box-shadow: 0 0 8px rgba(0, 255, 65, 0.2);
}

.bar-label {
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--text-dim);
  margin-top: 6px;
  white-space: nowrap;
}

.bar-value {
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--green);
  margin-bottom: 4px;
}

/* Donut-like list */
.dist-list { list-style: none; }
.dist-item {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 0;
  border-bottom: 1px solid rgba(26, 58, 26, 0.5);
}
.dist-item:last-child { border-bottom: none; }

.dist-bar-wrap {
  flex: 1;
  height: 6px;
  background: var(--bg);
  border-radius: 3px;
  overflow: hidden;
}

.dist-bar {
  height: 100%;
  background: linear-gradient(90deg, var(--cyan-dim), var(--cyan));
  border-radius: 3px;
  transition: width 0.5s ease;
}

.dist-name {
  font-family: var(--font-cn);
  font-size: 12px;
  color: var(--text);
  width: 80px;
}

.dist-qty {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--cyan);
  width: 50px;
  text-align: right;
}

/* ═══════════════════════════════════════════
   TABLE STYLES
   ═══════════════════════════════════════════ */
.data-table {
  width: 100%;
  border-collapse: collapse;
  font-family: var(--font-mono);
  font-size: 12px;
}

.data-table thead th {
  background: var(--bg-panel);
  color: var(--text-dim);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 1px;
  padding: 10px 12px;
  text-align: left;
  border-bottom: 1px solid var(--border-bright);
  position: sticky;
  top: 0;
  z-index: 1;
}

.data-table tbody tr {
  border-bottom: 1px solid var(--border);
  transition: background 0.1s;
}

.data-table tbody tr:hover {
  background: rgba(0, 255, 65, 0.03);
}

.data-table td {
  padding: 8px 12px;
  color: var(--text);
}

.data-table td.mono {
  font-family: var(--font-mono);
  color: var(--green-dim);
}

.data-table td.price {
  color: var(--amber);
}

.data-table td.qty {
  color: var(--cyan);
}

.low-stock-row td {
  color: var(--red) !important;
}

.tag {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 2px;
  font-size: 11px;
  font-family: var(--font-mono);
}

.tag-in {
  background: rgba(0, 255, 65, 0.1);
  color: var(--green);
  border: 1px solid rgba(0, 255, 65, 0.2);
}

.tag-out {
  background: rgba(255, 51, 51, 0.1);
  color: var(--red);
  border: 1px solid rgba(255, 51, 51, 0.2);
}

/* ═══════════════════════════════════════════
   FORMS & BUTTONS
   ═══════════════════════════════════════════ */
.toolbar {
  display: flex;
  gap: 10px;
  margin-bottom: 16px;
  flex-wrap: wrap;
  align-items: center;
}

.input-field {
  background: var(--bg-input);
  border: 1px solid var(--border);
  color: var(--green);
  font-family: var(--font-mono);
  font-size: 13px;
  padding: 7px 12px;
  border-radius: 2px;
  outline: none;
  transition: border-color 0.2s;
}

.input-field:focus {
  border-color: var(--green-dark);
  box-shadow: 0 0 8px rgba(0, 255, 65, 0.1);
}

.input-field::placeholder {
  color: var(--text-dim);
}

select.input-field {
  cursor: pointer;
}

.btn {
  font-family: var(--font-mono);
  font-size: 12px;
  padding: 7px 16px;
  border: 1px solid var(--border-bright);
  background: var(--bg-panel);
  color: var(--green);
  cursor: pointer;
  border-radius: 2px;
  transition: all 0.15s;
  text-transform: uppercase;
  letter-spacing: 1px;
}

.btn:hover {
  background: rgba(0, 255, 65, 0.08);
  border-color: var(--green-dark);
  box-shadow: 0 0 12px rgba(0, 255, 65, 0.1);
}

.btn-primary {
  background: rgba(0, 255, 65, 0.1);
  border-color: var(--green-dark);
}

.btn-danger {
  color: var(--red);
  border-color: rgba(255, 51, 51, 0.3);
}

.btn-danger:hover {
  background: rgba(255, 51, 51, 0.08);
  border-color: var(--red-dim);
}

.btn-amber {
  color: var(--amber);
  border-color: rgba(255, 176, 0, 0.3);
}

.btn-amber:hover {
  background: rgba(255, 176, 0, 0.08);
}

.btn-sm {
  padding: 4px 10px;
  font-size: 11px;
}

/* ═══════════════════════════════════════════
   MODAL
   ═══════════════════════════════════════════ */
.modal-overlay {
  display: none;
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.7);
  z-index: 1000;
  justify-content: center;
  align-items: center;
  backdrop-filter: blur(2px);
}

.modal-overlay.active {
  display: flex;
}

.modal {
  background: var(--bg);
  border: 1px solid var(--border-bright);
  border-radius: 4px;
  width: 500px;
  max-width: 90vw;
  max-height: 80vh;
  overflow-y: auto;
  box-shadow: 0 0 40px rgba(0, 255, 65, 0.1), 0 20px 60px rgba(0, 0, 0, 0.5);
}

.modal-header {
  padding: 16px 20px;
  border-bottom: 1px solid var(--border);
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.modal-title {
  font-family: var(--font-mono);
  font-size: 14px;
  color: var(--green);
  text-transform: uppercase;
  letter-spacing: 1px;
}

.modal-close {
  background: none;
  border: none;
  color: var(--text-dim);
  font-size: 18px;
  cursor: pointer;
  font-family: var(--font-mono);
}

.modal-close:hover { color: var(--red); }

.modal-body {
  padding: 20px;
}

.form-group {
  margin-bottom: 14px;
}

.form-label {
  display: block;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: 1px;
  margin-bottom: 4px;
}

.form-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}

.modal-footer {
  padding: 12px 20px;
  border-top: 1px solid var(--border);
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}

/* ═══════════════════════════════════════════
   TOAST
   ═══════════════════════════════════════════ */
.toast-container {
  position: fixed;
  top: 60px;
  right: 20px;
  z-index: 2000;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.toast {
  font-family: var(--font-mono);
  font-size: 12px;
  padding: 10px 16px;
  border-radius: 2px;
  border: 1px solid;
  animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards;
  max-width: 350px;
}

.toast-success {
  background: rgba(0, 255, 65, 0.1);
  border-color: var(--green-dark);
  color: var(--green);
}

.toast-error {
  background: rgba(255, 51, 51, 0.1);
  border-color: var(--red-dim);
  color: var(--red);
}

.toast-info {
  background: rgba(0, 229, 255, 0.1);
  border-color: var(--cyan-dim);
  color: var(--cyan);
}

@keyframes toastIn {
  from { opacity: 0; transform: translateX(20px); }
  to { opacity: 1; transform: translateX(0); }
}

@keyframes toastOut {
  from { opacity: 1; }
  to { opacity: 0; transform: translateY(-10px); }
}

/* ═══════════════════════════════════════════
   RESPONSIVE
   ═══════════════════════════════════════════ */
@media (max-width: 768px) {
  .app {
    grid-template-columns: 1fr;
    grid-template-rows: 48px auto 1fr;
  }
  .sidebar {
    display: flex;
    overflow-x: auto;
    padding: 4px 8px;
    border-right: none;
    border-bottom: 1px solid var(--border);
    gap: 4px;
  }
  .nav-section { display: flex; gap: 4px; margin-bottom: 0; }
  .nav-label { display: none; }
  .nav-item { white-space: nowrap; padding: 6px 10px; font-size: 12px; border-left: none; border-bottom: 2px solid transparent; }
  .nav-item.active { border-bottom-color: var(--green); border-left-color: transparent; }
  .charts-row { grid-template-columns: 1fr; }
  .stats-grid { grid-template-columns: repeat(2, 1fr); }

  .terminal-popup {
    left: 8px !important;
    right: 8px !important;
    bottom: 80px !important;
    width: auto !important;
    height: 50vh !important;
  }
}
</style>
</head>
<body>

<div class="app">
  <!-- ═══ HEADER ═══ -->
  <header class="header">
    <div class="header-title">
      <span style="color: var(--amber);">[</span>IMS<span style="color: var(--amber);">]</span>
      进销存管理系统 <span class="blink">_</span>
    </div>
    <div class="header-status">
      <span><span class="status-dot"></span> SYSTEM ONLINE</span>
      <span id="clock"></span>
    </div>
  </header>

  <!-- ═══ SIDEBAR ═══ -->
  <nav class="sidebar">
    <div class="nav-section">
      <div class="nav-label">概览</div>
      <div class="nav-item active" data-page="dashboard">
        <span class="nav-icon">◈</span> 仪表盘
      </div>
    </div>
    <div class="nav-section">
      <div class="nav-label">库存管理</div>
      <div class="nav-item" data-page="products">
        <span class="nav-icon">◫</span> 商品管理
      </div>
      <div class="nav-item" data-page="stock-in">
        <span class="nav-icon">▲</span> 入库管理
      </div>
      <div class="nav-item" data-page="stock-out">
        <span class="nav-icon">▼</span> 出库管理
      </div>
      <div class="nav-item" data-page="records">
        <span class="nav-icon">◸</span> 出入库记录
      </div>
    </div>
    <div class="nav-section">
      <div class="nav-label">销售</div>
      <div class="nav-item" data-page="sales">
        <span class="nav-icon">◆</span> 销售订单
      </div>
    </div>
    <div class="nav-section">
      <div class="nav-label">报表</div>
      <div class="nav-item" data-page="report-stock">
        <span class="nav-icon">◧</span> 库存报表
      </div>
      <div class="nav-item" data-page="report-profit">
        <span class="nav-icon">◩</span> 利润报表
      </div>
    </div>
    <div class="nav-section">
      <div class="nav-label">系统</div>
      <div class="nav-item" data-page="categories">
        <span class="nav-icon">◫</span> 分类管理
      </div>
    </div>
  </nav>

  <!-- ═══ MAIN CONTENT ═══ -->
  <main class="main" id="mainContent">
    <!-- Dashboard -->
    <div class="page active" id="page-dashboard">
      <div class="page-title">仪表盘</div>
      <div class="stats-grid" id="statsGrid"></div>
      <div class="charts-row">
        <div class="chart-box">
          <div class="chart-title">▸ 近7日销售趋势</div>
          <div class="bar-chart" id="salesChart"></div>
        </div>
        <div class="chart-box">
          <div class="chart-title">▸ 库存分布</div>
          <ul class="dist-list" id="stockDist"></ul>
        </div>
      </div>
    </div>

    <!-- Products -->
    <div class="page" id="page-products">
      <div class="page-title">商品管理</div>
      <div class="toolbar">
        <input class="input-field" id="productSearch" placeholder="搜索商品编码/名称..." style="width:220px">
        <select class="input-field" id="productCatFilter" style="width:140px">
          <option value="0">全部分类</option>
        </select>
        <button class="btn btn-primary" onclick="openModal('productModal')">+ 新增商品</button>
      </div>
      <div style="overflow-x:auto">
        <table class="data-table" id="productsTable">
          <thead>
            <tr>
              <th>ID</th><th>编码</th><th>名称</th><th>分类</th><th>单位</th>
              <th>进价</th><th>售价</th><th>库存</th><th>最低库存</th><th>操作</th>
            </tr>
          </thead>
          <tbody></tbody>
        </table>
      </div>
    </div>

    <!-- Stock In -->
    <div class="page" id="page-stock-in">
      <div class="page-title">入库管理</div>
      <div class="toolbar">
        <select class="input-field" id="stockInProduct" style="width:260px"></select>
        <input class="input-field" id="stockInQty" type="number" placeholder="数量" style="width:100px">
        <input class="input-field" id="stockInPrice" type="number" step="0.01" placeholder="单价" style="width:100px">
        <input class="input-field" id="stockInSupplier" placeholder="供应商" style="width:160px">
        <input class="input-field" id="stockInRemark" placeholder="备注" style="width:160px">
        <button class="btn btn-primary" onclick="doStockIn()">确认入库</button>
      </div>
      <div style="color:var(--text-dim);font-family:var(--font-mono);font-size:12px;margin-top:12px;">
        选择商品后输入数量和单价,点击确认入库。库存将自动增加。
      </div>
    </div>

    <!-- Stock Out -->
    <div class="page" id="page-stock-out">
      <div class="page-title">出库管理</div>
      <div class="toolbar">
        <select class="input-field" id="stockOutProduct" style="width:260px"></select>
        <input class="input-field" id="stockOutQty" type="number" placeholder="数量" style="width:100px">
        <input class="input-field" id="stockOutRemark" placeholder="备注" style="width:200px">
        <button class="btn btn-danger" onclick="doStockOut()">确认出库</button>
      </div>
      <div style="color:var(--text-dim);font-family:var(--font-mono);font-size:12px;margin-top:12px;">
        出库将自动扣减库存。如库存不足将提示错误。
      </div>
    </div>

    <!-- Records -->
    <div class="page" id="page-records">
      <div class="page-title">出入库记录</div>
      <div class="toolbar">
        <select class="input-field" id="recordTypeFilter" style="width:120px">
          <option value="">全部</option>
          <option value="in">仅入库</option>
          <option value="out">仅出库</option>
        </select>
        <button class="btn" onclick="loadRecords()">刷新</button>
      </div>
      <div style="overflow-x:auto">
        <table class="data-table" id="recordsTable">
          <thead>
            <tr><th>ID</th><th>类型</th><th>编码</th><th>名称</th><th>数量</th><th>单价</th><th>供应商</th><th>时间</th><th>备注</th></tr>
          </thead>
          <tbody></tbody>
        </table>
      </div>
    </div>

    <!-- Sales -->
    <div class="page" id="page-sales">
      <div class="page-title">销售订单</div>
      <div class="toolbar">
        <button class="btn btn-primary" onclick="openModal('saleModal')">+ 新建销售订单</button>
        <button class="btn" onclick="loadSales()">刷新</button>
      </div>
      <div style="overflow-x:auto">
        <table class="data-table" id="salesTable">
          <thead>
            <tr><th>订单号</th><th>客户</th><th>金额</th><th>时间</th><th>备注</th><th>操作</th></tr>
          </thead>
          <tbody></tbody>
        </table>
      </div>
    </div>

    <!-- Report Stock -->
    <div class="page" id="page-report-stock">
      <div class="page-title">库存报表</div>
      <div class="toolbar">
        <button class="btn" onclick="loadStockReport()">刷新</button>
      </div>
      <div style="overflow-x:auto">
        <table class="data-table" id="stockReportTable">
          <thead>
            <tr><th>编码</th><th>名称</th><th>分类</th><th>单位</th><th>进价</th><th>售价</th><th>库存</th><th>最低库存</th><th>库存金额</th><th>状态</th></tr>
          </thead>
          <tbody></tbody>
        </table>
      </div>
    </div>

    <!-- Report Profit -->
    <div class="page" id="page-report-profit">
      <div class="page-title">利润报表</div>
      <div class="toolbar">
        <button class="btn" onclick="loadProfitReport()">刷新</button>
      </div>
      <div style="overflow-x:auto">
        <table class="data-table" id="profitReportTable">
          <thead>
            <tr><th>编码</th><th>名称</th><th>进价</th><th>已售数量</th><th>销售额</th><th>毛利</th><th>毛利率</th></tr>
          </thead>
          <tbody></tbody>
        </table>
      </div>
    </div>

    <!-- Categories -->
    <div class="page" id="page-categories">
      <div class="page-title">分类管理</div>
      <div class="toolbar">
        <input class="input-field" id="newCatName" placeholder="新分类名称" style="width:200px">
        <button class="btn btn-primary" onclick="addCategory()">添加分类</button>
      </div>
      <div style="overflow-x:auto">
        <table class="data-table" id="catTable">
          <thead><tr><th>ID</th><th>名称</th><th>商品数</th><th>创建时间</th><th>操作</th></tr></thead>
          <tbody></tbody>
        </table>
      </div>
    </div>
  </main>
</div>

<!-- ═══════════════════════════════════════════
     浮动终端按钮 (FAB)
     ═══════════════════════════════════════════ -->
<button class="terminal-fab" id="terminalFab" title="CLI 终端 (Ctrl+`)">
  <span style="font-family:var(--font-mono);font-size:18px;">&gt;_</span>
</button>

<!-- ═══════════════════════════════════════════
     弹出式终端窗口
     ═══════════════════════════════════════════ -->
<div class="terminal-popup" id="terminalPopup">
  <!-- 标题栏(可拖拽) -->
  <div class="terminal-titlebar" id="terminalTitlebar">
    <div class="terminal-titlebar-left">
      <div class="terminal-titlebar-dots">
        <div class="terminal-titlebar-dot dot-close"></div>
        <div class="terminal-titlebar-dot dot-minimize"></div>
        <div class="terminal-titlebar-dot dot-maximize"></div>
      </div>
      <span class="terminal-titlebar-text">CLI TERMINAL --- Ctrl+` 切换 | 输入 help 查看命令</span>
    </div>
    <div class="terminal-titlebar-actions">
      <button class="terminal-titlebar-btn" id="terminalClearBtn" title="清屏">⌫</button>
      <button class="terminal-titlebar-btn" id="terminalFullscreenBtn" title="全屏">⛶</button>
      <button class="terminal-titlebar-btn close-btn" id="terminalCloseBtn" title="关闭 (Esc)">×</button>
    </div>
  </div>

  <!-- 输出区域 -->
  <div class="terminal-output" id="cliOutput"></div>

  <!-- 输入行 -->
  <div class="terminal-input-row">
    <span class="prompt">ims@local:~$</span>
    <input class="terminal-input" id="cliInput" placeholder="输入命令..." autocomplete="off" spellcheck="false">
  </div>

  <!-- 状态栏 -->
  <div class="terminal-statusbar">
    <span id="cliLineCount">0 lines</span>
    <span>UTF-8 | CLI v1.0</span>
  </div>

  <!-- 调整大小手柄 -->
  <div class="resize-handle" id="terminalResizeHandle"></div>
</div>

<!-- ═══ MODALS ═══ -->
<!-- Product Modal -->
<div class="modal-overlay" id="productModal">
  <div class="modal">
    <div class="modal-header">
      <span class="modal-title">新增商品</span>
      <button class="modal-close" onclick="closeModal('productModal')">×</button>
    </div>
    <div class="modal-body">
      <div class="form-row">
        <div class="form-group">
          <label class="form-label">商品编码 *</label>
          <input class="input-field" id="pm_code" style="width:100%" placeholder="如 P009">
        </div>
        <div class="form-group">
          <label class="form-label">商品名称 *</label>
          <input class="input-field" id="pm_name" style="width:100%" placeholder="商品名称">
        </div>
      </div>
      <div class="form-row">
        <div class="form-group">
          <label class="form-label">分类</label>
          <select class="input-field" id="pm_category" style="width:100%"></select>
        </div>
        <div class="form-group">
          <label class="form-label">单位</label>
          <input class="input-field" id="pm_unit" style="width:100%" value="件">
        </div>
      </div>
      <div class="form-row">
        <div class="form-group">
          <label class="form-label">进价</label>
          <input class="input-field" id="pm_pp" type="number" step="0.01" style="width:100%" value="0">
        </div>
        <div class="form-group">
          <label class="form-label">售价</label>
          <input class="input-field" id="pm_sp" type="number" step="0.01" style="width:100%" value="0">
        </div>
      </div>
      <div class="form-row">
        <div class="form-group">
          <label class="form-label">初始库存</label>
          <input class="input-field" id="pm_qty" type="number" style="width:100%" value="0">
        </div>
        <div class="form-group">
          <label class="form-label">最低库存预警</label>
          <input class="input-field" id="pm_min" type="number" style="width:100%" value="0">
        </div>
      </div>
    </div>
    <div class="modal-footer">
      <button class="btn" onclick="closeModal('productModal')">取消</button>
      <button class="btn btn-primary" onclick="saveProduct()">保存</button>
    </div>
  </div>
</div>

<!-- Sale Modal -->
<div class="modal-overlay" id="saleModal">
  <div class="modal" style="width:620px">
    <div class="modal-header">
      <span class="modal-title">新建销售订单</span>
      <button class="modal-close" onclick="closeModal('saleModal')">×</button>
    </div>
    <div class="modal-body">
      <div class="form-row">
        <div class="form-group">
          <label class="form-label">客户名称</label>
          <input class="input-field" id="sm_customer" style="width:100%" placeholder="客户名称">
        </div>
        <div class="form-group">
          <label class="form-label">备注</label>
          <input class="input-field" id="sm_remark" style="width:100%" placeholder="备注">
        </div>
      </div>
      <div style="margin-bottom:8px;">
        <label class="form-label">订单明细</label>
        <button class="btn btn-sm" onclick="addSaleItem()" style="margin-left:8px;">+ 添加行</button>
      </div>
      <div id="saleItems" style="max-height:240px;overflow-y:auto;"></div>
      <div style="text-align:right;margin-top:10px;font-family:var(--font-mono);color:var(--amber);font-size:14px;">
        合计: ¥<span id="saleTotal">0.00</span>
      </div>
    </div>
    <div class="modal-footer">
      <button class="btn" onclick="closeModal('saleModal')">取消</button>
      <button class="btn btn-primary" onclick="saveSale()">提交订单</button>
    </div>
  </div>
</div>

<!-- Sale Detail Modal -->
<div class="modal-overlay" id="saleDetailModal">
  <div class="modal" style="width:560px">
    <div class="modal-header">
      <span class="modal-title">订单详情</span>
      <button class="modal-close" onclick="closeModal('saleDetailModal')">×</button>
    </div>
    <div class="modal-body" id="saleDetailBody"></div>
  </div>
</div>

<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>

<script>
/* ═══════════════════════════════════════════════════
   GLOBALS
   ═══════════════════════════════════════════════════ */
let allProducts = [];
let allCategories = [];
let cliHistory = [];
let cliHistoryIndex = -1;
let saleItemCount = 0;

/* ═══════════════════════════════════════════════════
   CLOCK
   ═══════════════════════════════════════════════════ */
function updateClock() {
  const now = new Date();
  document.getElementById('clock').textContent = now.toLocaleString('zh-CN', {
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
  });
}
setInterval(updateClock, 1000);
updateClock();

/* ═══════════════════════════════════════════════════
   NAVIGATION
   ═══════════════════════════════════════════════════ */
document.querySelectorAll('.nav-item').forEach(item => {
  item.addEventListener('click', () => {
    document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
    item.classList.add('active');
    const page = item.dataset.page;
    document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
    const el = document.getElementById('page-' + page);
    if (el) el.classList.add('active');
    loadPageData(page);
  });
});

function loadPageData(page) {
  switch (page) {
    case 'dashboard': loadDashboard(); break;
    case 'products': loadProducts(); break;
    case 'stock-in': loadProductSelect('stockInProduct'); break;
    case 'stock-out': loadProductSelect('stockOutProduct'); break;
    case 'records': loadRecords(); break;
    case 'sales': loadSales(); break;
    case 'report-stock': loadStockReport(); break;
    case 'report-profit': loadProfitReport(); break;
    case 'categories': loadCategories(); break;
  }
}

/* ═══════════════════════════════════════════════════
   API HELPERS
   ═══════════════════════════════════════════════════ */
async function api(url, options = {}) {
  try {
    const res = await fetch(url, options);
    const data = await res.json();
    if (!res.ok) throw new Error(data.detail || '请求失败');
    return data;
  } catch (e) {
    toast(e.message, 'error');
    throw e;
  }
}

function toast(msg, type = 'success') {
  const container = document.getElementById('toastContainer');
  const el = document.createElement('div');
  el.className = `toast toast-${type}`;
  el.textContent = msg;
  container.appendChild(el);
  setTimeout(() => el.remove(), 3000);
}

function openModal(id) { document.getElementById(id).classList.add('active'); }
function closeModal(id) { document.getElementById(id).classList.remove('active'); }

/* ═══════════════════════════════════════════════════
   DASHBOARD
   ═══════════════════════════════════════════════════ */
async function loadDashboard() {
  const d = await api('/api/dashboard');
  document.getElementById('statsGrid').innerHTML = `
    <div class="stat-card">
      <div class="stat-label">商品总数</div>
      <div class="stat-value">${d.products_count}</div>
      <div class="stat-sub">种商品</div>
    </div>
    <div class="stat-card">
      <div class="stat-label">库存总金额</div>
      <div class="stat-value amber">¥${d.total_stock_value.toLocaleString()}</div>
      <div class="stat-sub">按进价计算</div>
    </div>
    <div class="stat-card">
      <div class="stat-label">今日入库</div>
      <div class="stat-value cyan">${d.today_in}</div>
      <div class="stat-sub">件</div>
    </div>
    <div class="stat-card">
      <div class="stat-label">今日出库</div>
      <div class="stat-value">${d.today_out}</div>
      <div class="stat-sub">件</div>
    </div>
    <div class="stat-card">
      <div class="stat-label">低库存预警</div>
      <div class="stat-value ${d.low_stock_count > 0 ? 'red' : ''}">${d.low_stock_count}</div>
      <div class="stat-sub">种商品</div>
    </div>
    <div class="stat-card">
      <div class="stat-label">今日销售额</div>
      <div class="stat-value amber">¥${d.today_sales.toLocaleString()}</div>
      <div class="stat-sub">元</div>
    </div>
    <div class="stat-card">
      <div class="stat-label">本月销售额</div>
      <div class="stat-value amber">¥${d.month_sales.toLocaleString()}</div>
      <div class="stat-sub">元</div>
    </div>
  `;

  const maxAmt = Math.max(...d.sales_trend.map(s => s.amount), 1);
  document.getElementById('salesChart').innerHTML = d.sales_trend.map(s => {
    const h = Math.max((s.amount / maxAmt) * 100, 2);
    const day = s.date.slice(5);
    return `<div class="bar-col">
      <div class="bar-value">¥${s.amount >= 1000 ? (s.amount/1000).toFixed(1)+'k' : s.amount}</div>
      <div class="bar" style="height:${h}%"></div>
      <div class="bar-label">${day}</div>
    </div>`;
  }).join('') || '<div style="color:var(--text-dim);font-size:12px;padding:20px;">暂无数据</div>';

  const maxQty = Math.max(...d.stock_dist.map(s => s.qty), 1);
  document.getElementById('stockDist').innerHTML = d.stock_dist.map(s => {
    const w = (s.qty / maxQty) * 100;
    return `<li class="dist-item">
      <span class="dist-name">${s.name}</span>
      <div class="dist-bar-wrap"><div class="dist-bar" style="width:${w}%"></div></div>
      <span class="dist-qty">${s.qty}</span>
    </li>`;
  }).join('');
}

/* ═══════════════════════════════════════════════════
   PRODUCTS
   ═══════════════════════════════════════════════════ */
async function loadProducts() {
  const keyword = document.getElementById('productSearch').value;
  const catId = document.getElementById('productCatFilter').value;
  const [products, cats] = await Promise.all([
    api(`/api/products?keyword=${encodeURIComponent(keyword)}&category_id=${catId}`),
    api('/api/categories')
  ]);
  allProducts = products;
  allCategories = cats;

  const sel = document.getElementById('productCatFilter');
  const curVal = sel.value;
  sel.innerHTML = '<option value="0">全部分类</option>' +
    cats.map(c => `<option value="${c.id}" ${c.id == curVal ? 'selected' : ''}>${c.name}</option>`).join('');

  const tbody = document.querySelector('#productsTable tbody');
  tbody.innerHTML = products.map(p => {
    const isLow = p.stock_qty <= p.min_stock;
    return `<tr class="${isLow ? 'low-stock-row' : ''}">
      <td>${p.id}</td>
      <td class="mono">${p.code}</td>
      <td>${p.name}</td>
      <td>${p.category_name || '-'}</td>
      <td>${p.unit}</td>
      <td class="price">¥${p.purchase_price.toFixed(2)}</td>
      <td class="price">¥${p.sale_price.toFixed(2)}</td>
      <td class="qty">${p.stock_qty}</td>
      <td>${p.min_stock}</td>
      <td>
        <button class="btn btn-sm btn-danger" onclick="deleteProduct(${p.id}, '${p.name}')">删除</button>
      </td>
    </tr>`;
  }).join('');
}

document.getElementById('productSearch').addEventListener('input', debounce(loadProducts, 300));
document.getElementById('productCatFilter').addEventListener('change', loadProducts);

async function saveProduct() {
  const fd = new FormData();
  fd.append('code', document.getElementById('pm_code').value);
  fd.append('name', document.getElementById('pm_name').value);
  fd.append('category_id', document.getElementById('pm_category').value);
  fd.append('unit', document.getElementById('pm_unit').value);
  fd.append('purchase_price', document.getElementById('pm_pp').value);
  fd.append('sale_price', document.getElementById('pm_sp').value);
  fd.append('stock_qty', document.getElementById('pm_qty').value);
  fd.append('min_stock', document.getElementById('pm_min').value);
  await api('/api/products', { method: 'POST', body: fd });
  toast('商品创建成功');
  closeModal('productModal');
  loadProducts();
}

async function deleteProduct(id, name) {
  if (!confirm(`确定删除商品 "${name}"?`)) return;
  await api(`/api/products/${id}`, { method: 'DELETE' });
  toast('已删除');
  loadProducts();
}

/* ═══════════════════════════════════════════════════
   STOCK IN / OUT
   ═══════════════════════════════════════════════════ */
async function loadProductSelect(selectId) {
  const products = await api('/api/products');
  allProducts = products;
  const sel = document.getElementById(selectId);
  sel.innerHTML = '<option value="">-- 选择商品 --</option>' +
    products.map(p => `<option value="${p.id}">${p.code} --- ${p.name} (库存: ${p.stock_qty})</option>`).join('');
}

async function doStockIn() {
  const fd = new FormData();
  fd.append('product_id', document.getElementById('stockInProduct').value);
  fd.append('quantity', document.getElementById('stockInQty').value);
  fd.append('price', document.getElementById('stockInPrice').value);
  fd.append('supplier', document.getElementById('stockInSupplier').value);
  fd.append('remark', document.getElementById('stockInRemark').value);
  const r = await api('/api/stock/in', { method: 'POST', body: fd });
  toast(r.message);
  document.getElementById('stockInQty').value = '';
  document.getElementById('stockInPrice').value = '';
  document.getElementById('stockInSupplier').value = '';
  document.getElementById('stockInRemark').value = '';
  loadProductSelect('stockInProduct');
}

async function doStockOut() {
  const fd = new FormData();
  fd.append('product_id', document.getElementById('stockOutProduct').value);
  fd.append('quantity', document.getElementById('stockOutQty').value);
  fd.append('remark', document.getElementById('stockOutRemark').value);
  const r = await api('/api/stock/out', { method: 'POST', body: fd });
  toast(r.message);
  document.getElementById('stockOutQty').value = '';
  document.getElementById('stockOutRemark').value = '';
  loadProductSelect('stockOutProduct');
}

/* ═══════════════════════════════════════════════════
   RECORDS
   ═══════════════════════════════════════════════════ */
async function loadRecords() {
  const tf = document.getElementById('recordTypeFilter').value;
  const records = await api(`/api/stock/records?type_filter=${tf}&limit=200`);
  const tbody = document.querySelector('#recordsTable tbody');
  tbody.innerHTML = records.map(r => `<tr>
    <td>${r.id}</td>
    <td><span class="tag ${r.type === 'in' ? 'tag-in' : 'tag-out'}">${r.type === 'in' ? '入库' : '出库'}</span></td>
    <td class="mono">${r.product_code}</td>
    <td>${r.product_name}</td>
    <td class="qty">${r.quantity}</td>
    <td class="price">${r.price ? '¥' + r.price.toFixed(2) : '-'}</td>
    <td>${r.supplier || '-'}</td>
    <td style="font-size:11px;color:var(--text-dim)">${r.created_at}</td>
    <td>${r.remark || '-'}</td>
  </tr>`).join('');
}

document.getElementById('recordTypeFilter').addEventListener('change', loadRecords);

/* ═══════════════════════════════════════════════════
   SALES
   ═══════════════════════════════════════════════════ */
async function loadSales() {
  const sales = await api('/api/sales');
  const tbody = document.querySelector('#salesTable tbody');
  tbody.innerHTML = sales.map(s => `<tr>
    <td class="mono">${s.order_no}</td>
    <td>${s.customer || '-'}</td>
    <td class="price">¥${s.total_amount.toFixed(2)}</td>
    <td style="font-size:11px;color:var(--text-dim)">${s.created_at}</td>
    <td>${s.remark || '-'}</td>
    <td><button class="btn btn-sm" onclick="viewSale(${s.id})">详情</button></td>
  </tr>`).join('');
}

async function viewSale(id) {
  const d = await api(`/api/sales/${id}`);
  const o = d.order;
  let html = `<div style="font-family:var(--font-mono);font-size:12px;margin-bottom:12px;">
    <div><span style="color:var(--text-dim)">订单号:</span> ${o.order_no}</div>
    <div><span style="color:var(--text-dim)">客户:</span> ${o.customer || '-'}</div>
    <div><span style="color:var(--text-dim)">时间:</span> ${o.created_at}</div>
    <div><span style="color:var(--text-dim)">备注:</span> ${o.remark || '-'}</div>
  </div>`;
  html += '<table class="data-table"><thead><tr><th>编码</th><th>名称</th><th>数量</th><th>单价</th><th>小计</th></tr></thead><tbody>';
  d.items.forEach(i => {
    html += `<tr>
      <td class="mono">${i.product_code}</td><td>${i.product_name}</td>
      <td class="qty">${i.quantity}</td><td class="price">¥${i.unit_price.toFixed(2)}</td>
      <td class="price">¥${i.subtotal.toFixed(2)}</td>
    </tr>`;
  });
  html += `</tbody></table>
    <div style="text-align:right;margin-top:10px;font-family:var(--font-mono);color:var(--amber);font-size:16px;">
      合计: ¥${o.total_amount.toFixed(2)}
    </div>`;
  document.getElementById('saleDetailBody').innerHTML = html;
  openModal('saleDetailModal');
}

async function addSaleItem() {
  if (!allProducts.length) {
    allProducts = await api('/api/products');
  }
  saleItemCount++;
  const div = document.createElement('div');
  div.className = 'form-row';
  div.style.marginBottom = '8px';
  div.style.alignItems = 'end';
  div.id = `saleItem${saleItemCount}`;
  div.innerHTML = `
    <div class="form-group" style="margin-bottom:0">
      <select class="input-field sale-product" style="width:100%" onchange="calcSaleTotal()">
        <option value="">选择商品</option>
        ${allProducts.map(p => `<option value="${p.id}" data-price="${p.sale_price}">${p.code} ${p.name} (¥${p.sale_price})</option>`).join('')}
      </select>
    </div>
    <div class="form-group" style="margin-bottom:0;display:flex;gap:8px;align-items:end">
      <input class="input-field sale-qty" type="number" placeholder="数量" style="width:70px" value="1" oninput="calcSaleTotal()">
      <input class="input-field sale-price" type="number" step="0.01" placeholder="单价" style="width:90px" oninput="calcSaleTotal()">
      <button class="btn btn-sm btn-danger" onclick="this.closest('.form-row').remove();calcSaleTotal()">×</button>
    </div>`;
  document.getElementById('saleItems').appendChild(div);
  const sel = div.querySelector('.sale-product');
  sel.addEventListener('change', () => {
    const opt = sel.options[sel.selectedIndex];
    if (opt.dataset.price) div.querySelector('.sale-price').value = opt.dataset.price;
    calcSaleTotal();
  });
}

function calcSaleTotal() {
  let total = 0;
  document.querySelectorAll('#saleItems .form-row').forEach(row => {
    const qty = parseFloat(row.querySelector('.sale-qty').value) || 0;
    const price = parseFloat(row.querySelector('.sale-price').value) || 0;
    total += qty * price;
  });
  document.getElementById('saleTotal').textContent = total.toFixed(2);
}

async function saveSale() {
  const items = [];
  document.querySelectorAll('#saleItems .form-row').forEach(row => {
    const pid = row.querySelector('.sale-product').value;
    const qty = parseFloat(row.querySelector('.sale-qty').value);
    const price = parseFloat(row.querySelector('.sale-price').value);
    if (pid && qty > 0) items.push({ product_id: parseInt(pid), quantity: qty, unit_price: price });
  });
  if (!items.length) { toast('请添加订单明细', 'error'); return; }
  const r = await api('/api/sales', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      customer: document.getElementById('sm_customer').value,
      remark: document.getElementById('sm_remark').value,
      items
    })
  });
  toast(r.message);
  closeModal('saleModal');
  document.getElementById('saleItems').innerHTML = '';
  document.getElementById('sm_customer').value = '';
  document.getElementById('sm_remark').value = '';
  document.getElementById('saleTotal').textContent = '0.00';
  loadSales();
}

/* ═══════════════════════════════════════════════════
   REPORTS
   ═══════════════════════════════════════════════════ */
async function loadStockReport() {
  const rows = await api('/api/reports/stock');
  const tbody = document.querySelector('#stockReportTable tbody');
  tbody.innerHTML = rows.map(r => `<tr class="${r.is_low ? 'low-stock-row' : ''}">
    <td class="mono">${r.code}</td><td>${r.name}</td><td>${r.category || '-'}</td>
    <td>${r.unit}</td><td class="price">¥${r.purchase_price.toFixed(2)}</td>
    <td class="price">¥${r.sale_price.toFixed(2)}</td><td class="qty">${r.stock_qty}</td>
    <td>${r.min_stock}</td><td class="price">¥${r.stock_value.toLocaleString()}</td>
    <td>${r.is_low ? '<span class="tag tag-out">低库存</span>' : '<span class="tag tag-in">正常</span>'}</td>
  </tr>`).join('');
}

async function loadProfitReport() {
  const rows = await api('/api/reports/profit');
  const tbody = document.querySelector('#profitReportTable tbody');
  tbody.innerHTML = rows.map(r => {
    const rate = r.total_revenue > 0 ? (r.gross_profit / r.total_revenue * 100) : 0;
    return `<tr>
      <td class="mono">${r.code}</td><td>${r.name}</td>
      <td class="price">¥${r.purchase_price.toFixed(2)}</td><td class="qty">${r.total_sold}</td>
      <td class="price">¥${r.total_revenue.toFixed(2)}</td>
      <td style="color:${r.gross_profit >= 0 ? 'var(--green)' : 'var(--red)'}">¥${r.gross_profit.toFixed(2)}</td>
      <td style="color:${rate >= 0 ? 'var(--green)' : 'var(--red)'}">${rate.toFixed(1)}%</td>
    </tr>`;
  }).join('');
}

/* ═══════════════════════════════════════════════════
   CATEGORIES
   ═══════════════════════════════════════════════════ */
async function loadCategories() {
  const cats = await api('/api/categories');
  allCategories = cats;
  const tbody = document.querySelector('#catTable tbody');
  tbody.innerHTML = cats.map(c => `<tr>
    <td>${c.id}</td><td>${c.name}</td><td class="qty">${c.product_count}</td>
    <td style="font-size:11px;color:var(--text-dim)">${c.created_at}</td>
    <td><button class="btn btn-sm btn-danger" onclick="deleteCategory(${c.id})">删除</button></td>
  </tr>`).join('');

  const pmCat = document.getElementById('pm_category');
  pmCat.innerHTML = '<option value="0">无分类</option>' +
    cats.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
}

async function addCategory() {
  const name = document.getElementById('newCatName').value.trim();
  if (!name) return;
  const fd = new FormData();
  fd.append('name', name);
  await api('/api/categories', { method: 'POST', body: fd });
  toast(`分类 "${name}" 已添加`);
  document.getElementById('newCatName').value = '';
  loadCategories();
}

async function deleteCategory(id) {
  if (!confirm('确定删除此分类?')) return;
  await api(`/api/categories/${id}`, { method: 'DELETE' });
  toast('已删除');
  loadCategories();
}

/* ═══════════════════════════════════════════════════
   POPUP TERMINAL --- 弹出式终端
   ═══════════════════════════════════════════════════ */
const terminalFab = document.getElementById('terminalFab');
const terminalPopup = document.getElementById('terminalPopup');
const terminalTitlebar = document.getElementById('terminalTitlebar');
const terminalCloseBtn = document.getElementById('terminalCloseBtn');
const terminalFullscreenBtn = document.getElementById('terminalFullscreenBtn');
const terminalClearBtn = document.getElementById('terminalClearBtn');
const terminalResizeHandle = document.getElementById('terminalResizeHandle');
const cliInput = document.getElementById('cliInput');
const cliOutput = document.getElementById('cliOutput');

let terminalOpen = false;
let terminalFullscreen = false;

// ── 打开 / 关闭 ──
function toggleTerminal() {
  terminalOpen = !terminalOpen;
  terminalPopup.classList.toggle('open', terminalOpen);
  terminalFab.classList.toggle('active', terminalOpen);
  if (terminalOpen) {
    setTimeout(() => cliInput.focus(), 100);
  }
}

function closeTerminal() {
  terminalOpen = false;
  terminalPopup.classList.remove('open');
  terminalFab.classList.remove('active');
  // 退出全屏
  if (terminalFullscreen) {
    terminalFullscreen = false;
    terminalPopup.classList.remove('fullscreen');
    terminalPopup.style.left = '';
    terminalPopup.style.top = '';
    terminalPopup.style.width = '';
    terminalPopup.style.height = '';
  }
}

terminalFab.addEventListener('click', toggleTerminal);
terminalCloseBtn.addEventListener('click', closeTerminal);

// ── 清屏 ──
terminalClearBtn.addEventListener('click', () => {
  cliOutput.innerHTML = '';
  document.getElementById('cliLineCount').textContent = '0 lines';
});

// ── 全屏切换 ──
terminalFullscreenBtn.addEventListener('click', () => {
  terminalFullscreen = !terminalFullscreen;
  terminalPopup.classList.toggle('fullscreen', terminalFullscreen);
  terminalFullscreenBtn.textContent = terminalFullscreen ? '⊡' : '⛶';
});

// ── 快捷键 Ctrl+` 切换终端 ──
document.addEventListener('keydown', (e) => {
  if ((e.ctrlKey || e.metaKey) && e.key === '`') {
    e.preventDefault();
    toggleTerminal();
  }
  // Esc 关闭终端
  if (e.key === 'Escape' && terminalOpen) {
    closeTerminal();
  }
});

// ── CLI 输入处理 ──
cliInput.addEventListener('keydown', async (e) => {
  if (e.key === 'Enter') {
    const cmd = cliInput.value.trim();
    if (!cmd) return;
    cliHistory.push(cmd);
    cliHistoryIndex = cliHistory.length;
    cliInput.value = '';

    appendCli(`<span class="cmd-echo">ims@local:~$ ${escapeHtml(cmd)}</span>`);

    try {
      const r = await api('/api/cli', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ cmd })
      });
      if (r.output === '__CLEAR__') {
        cliOutput.innerHTML = '';
      } else {
        appendCli(`<span class="cmd-result">${escapeHtml(r.output)}</span>`);
      }
    } catch (e) {
      appendCli(`<span class="error">错误: ${escapeHtml(e.message)}</span>`);
    }
  } else if (e.key === 'ArrowUp') {
    e.preventDefault();
    if (cliHistoryIndex > 0) {
      cliHistoryIndex--;
      cliInput.value = cliHistory[cliHistoryIndex];
    }
  } else if (e.key === 'ArrowDown') {
    e.preventDefault();
    if (cliHistoryIndex < cliHistory.length - 1) {
      cliHistoryIndex++;
      cliInput.value = cliHistory[cliHistoryIndex];
    } else {
      cliHistoryIndex = cliHistory.length;
      cliInput.value = '';
    }
  }
});

function appendCli(html) {
  cliOutput.innerHTML += html + '\n';
  cliOutput.scrollTop = cliOutput.scrollHeight;
  const lines = cliOutput.textContent.split('\n').length;
  document.getElementById('cliLineCount').textContent = `${lines} lines`;
}

function escapeHtml(s) {
  const d = document.createElement('div');
  d.textContent = s;
  return d.innerHTML;
}

// ── 拖拽移动 ──
(function() {
  let isDragging = false;
  let startX, startY, origX, origY;

  terminalTitlebar.addEventListener('mousedown', (e) => {
    // 不在按钮上拖拽
    if (e.target.closest('.terminal-titlebar-btn')) return;
    if (terminalFullscreen) return;

    isDragging = true;
    const rect = terminalPopup.getBoundingClientRect();
    startX = e.clientX;
    startY = e.clientY;
    origX = rect.left;
    origY = rect.top;

    // 切换定位方式以便拖拽
    terminalPopup.style.left = origX + 'px';
    terminalPopup.style.top = origY + 'px';
    terminalPopup.style.right = 'auto';
    terminalPopup.style.bottom = 'auto';

    document.body.style.userSelect = 'none';
    e.preventDefault();
  });

  document.addEventListener('mousemove', (e) => {
    if (!isDragging) return;
    const dx = e.clientX - startX;
    const dy = e.clientY - startY;
    terminalPopup.style.left = (origX + dx) + 'px';
    terminalPopup.style.top = (origY + dy) + 'px';
  });

  document.addEventListener('mouseup', () => {
    if (isDragging) {
      isDragging = false;
      document.body.style.userSelect = '';
    }
  });
})();

// ── 调整大小 ──
(function() {
  let isResizing = false;
  let startX, startY, startW, startH;

  terminalResizeHandle.addEventListener('mousedown', (e) => {
    if (terminalFullscreen) return;
    isResizing = true;
    startX = e.clientX;
    startY = e.clientY;
    startW = terminalPopup.offsetWidth;
    startH = terminalPopup.offsetHeight;
    document.body.style.userSelect = 'none';
    e.preventDefault();
  });

  document.addEventListener('mousemove', (e) => {
    if (!isResizing) return;
    const w = Math.max(400, startW + (e.clientX - startX));
    const h = Math.max(250, startH + (e.clientY - startY));
    terminalPopup.style.width = w + 'px';
    terminalPopup.style.height = h + 'px';
  });

  document.addEventListener('mouseup', () => {
    if (isResizing) {
      isResizing = false;
      document.body.style.userSelect = '';
    }
  });
})();

/* ═══════════════════════════════════════════════════
   UTILS
   ═══════════════════════════════════════════════════ */
function debounce(fn, ms) {
  let timer;
  return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
}

/* ═══════════════════════════════════════════════════
   INIT
   ═══════════════════════════════════════════════════ */
loadDashboard();
appendCli('<span class="cmd-result">╔══════════════════════════════════════════╗\n║   进销存管理系统 v2.5 --- 已就绪          ║\n║   Ctrl+` 打开/关闭终端                  ║\n║   输入 help 查看可用命令                 ║\n╚══════════════════════════════════════════╝</span>');
</script>
</body>
</html>

inventory_system/

├── main.py # FastAPI 后端(全部 API + 数据库逻辑)

├── inventory.db # SQLite 数据库(自动生成)

├── templates/

│ └── index.html # 前端页面(HTML + CSS + JS)

相关推荐
枫叶林FYL2 小时前
第三篇:认知架构与推理系统 第8章 世界模型学习
人工智能·机器学习
一休哥助手2 小时前
2026年4月5日人工智能早间新闻
人工智能
七夜zippoe2 小时前
OpenClaw 消息工具详解:多渠道消息发送实战指南
人工智能·microsoft·多渠道·互动·openclaw
星空2 小时前
前端--A_3--HTML区块_块元素与行内元素
前端·html
SuniaWang2 小时前
2026 AI Agent 爆发元年:OpenClaw v2026.4.2(The Lobster)Windows 深度部署与全路径避坑指南
人工智能·windows·openclaw·小龙虾
追光的蜗牛丿2 小时前
OpenCV Mat 中的图像数据是如何存储的
人工智能·opencv·计算机视觉
jinanwuhuaguo2 小时前
OpenClaw办公人员核心技能深度培训体系:从认知重塑到数字组织构建的全链路实战指南
java·大数据·开发语言·人工智能·openclaw
ai生成式引擎优化技术2 小时前
AI世界的多元化结构理论猜想
人工智能
Fairy要carry2 小时前
面试-LayerNorm和RMSNorm的区别
人工智能