commit fcbcdb7f958a40b0ba6dfa7fb46da38c58d98dcd Author: houhuan Date: Sun Dec 7 15:09:21 2025 +0800 一个手机快速搜索商品的网页 diff --git a/.trae/documents/优化前端查询与导入界面.md b/.trae/documents/优化前端查询与导入界面.md new file mode 100644 index 0000000..abcfa5e --- /dev/null +++ b/.trae/documents/优化前端查询与导入界面.md @@ -0,0 +1,70 @@ +## 目标 +- 顶部菜单切换“搜索/导入”,各模块彼此独立、互不干扰 +- 统一白色背景与高对比度主题,在暗黑系统模式下也能清晰可见 +- 强化搜索结果的可读性,突出名称、卖价、进价、条码等关键字段 + +## 导航与布局 +- 顶部加入切换菜单(Tabs):“搜索”、“导入”,使用吸附(sticky)头部 +- 页面分区:`SearchSection` 与 `ImportSection`,仅显示当前选中的模块 +- 保持响应式:移动端单列、桌面端居中卡片布局 + +## 视觉与主题 +- 全局白色背景(`body{background:#fff}`),系统暗黑模式下也保持白底 +- 高对比文本色(近黑 `#111`),卡片投影与边框弱化但清晰 +- 关键信息强调: + - 名称加粗加大 + - 卖价着重色(如 `#d32f2f`),进价次级色(如 `#455a64`) + - 条码固定等宽字体便于核对与复制 +- 统一间距与行高,提升可读性 + +## 搜索体验优化 +- 输入框占位提示区分条码/名称(支持回车搜索) +- 结果空态与错误态:展示“无结果/接口错误”提示 +- 加载态:搜索时按钮与列表显示“加载中” +- 条码精确命中优先;名称模糊查询展示列表 +- 结果操作:点击复制条码(可选增强) + +## 结果呈现 +- 结果卡片字段顺序:名称(粗体)→ 卖价(显著)→ 进价 → 条码(等宽) +- 列表模式下每条结果为独立卡片;单条命中时以详情样式展示 +- 支持分页/条数限制(沿用 `limit` 参数,可在前端提供下拉或固定值) + +## 导入模块 +- 独立导入区域仅在“导入”Tab显示 +- 显示文件名与导入进度,导入完成后展示统计(插入/更新/跳过) +- 失败提示与重试按钮;限制文件类型(`xlsx/xls`) +- 可选增强:拖拽上传 + +## 代码改动清单 +- `static/index.html` + - 添加顶部 `nav` Tabs(搜索/导入) + - 拆分两个主容器:`#searchSection` 与 `#importSection` +- `static/styles.css` + - 设置全局白色背景与高对比文本 + - Tabs样式(选中态、悬停态、移动端适配) + - 结果卡片样式(标题、价格颜色、条码等宽字体、间距) + - 空态/加载态提示样式 +- `static/app.js` + - 管理当前Tab状态与切换逻辑 + - 搜索渲染函数:突出关键字段、空态/错误态/加载态 + - 导入渲染函数:进度提示、结果统计 + - 小增强:复制条码、回车触发、简单节流 + +## 验收标准 +- 在系统暗黑模式下仍保持白底,内容清晰可见 +- 移动端下布局舒适、操作便捷;桌面端居中显示 +- 搜索与导入切换明确,互不串场 +- 名称、卖价、进价、条码呈现明显、易读 +- 无控制台错误,接口异常时有友好提示 + +## 风险与兼容 +- 中文编码与字体在部分终端显示差异;通过指定通用中文字体栈缓解 +- 保持原生HTML/CSS/JS,不引入新框架,降低兼容风险 +- 与现有接口契合,无需后端改动 + +## 实施步骤 +1. 修改 `index.html`:加入导航与两个分区容器 +2. 更新 `styles.css`:白色主题、Tabs与卡片样式、提示态 +3. 更新 `app.js`:Tab切换、渲染优化、状态管理(空/错/载) +4. 自测:手机与桌面浏览器,验证切换、搜索与导入流程 +5. 如有需要更新 `README.md` 截图与使用说明 \ No newline at end of file diff --git a/.trae/documents/在 Ubuntu 22.04(宝塔面板)部署商品查询系统的方案.md b/.trae/documents/在 Ubuntu 22.04(宝塔面板)部署商品查询系统的方案.md new file mode 100644 index 0000000..c3b12b0 --- /dev/null +++ b/.trae/documents/在 Ubuntu 22.04(宝塔面板)部署商品查询系统的方案.md @@ -0,0 +1,86 @@ +## 总览 +- 目标:在 Ubuntu 22.04(装有宝塔面板)部署现有 FastAPI + SQLite 项目,绑定域名并启用 HTTPS,支持前端查询与 Excel 导入。 +- 推荐路径:使用宝塔 Nginx 站点做反向代理,后端用虚拟环境 + `uvicorn` 常驻(systemd)或使用宝塔的 Python 项目插件;也可选 Docker 方案。 + +## 路径A:Python 虚拟环境 + systemd + Nginx 反代(稳定通用) +1. 上传项目到服务器 +- 位置:`/www/wwwroot/product-query`(宝塔默认网站目录) +- 结构:包含 `app/`、`static/`、`requirements.txt`、`scripts/`、`README.md` + +2. 安装依赖并准备运行目录 +- `sudo apt update && sudo apt install -y python3.10-venv`(22.04 默认 Python3.10) +- `cd /www/wwwroot/product-query` +- `python3 -m venv venv && source venv/bin/activate` +- `pip install -r requirements.txt` +- `mkdir -p data && chown -R www:www /www/wwwroot/product-query` + +3. 创建 systemd 服务 +- 新建 `/etc/systemd/system/product-query.service`: +``` +[Unit] +Description=Product Query FastAPI +After=network.target + +[Service] +WorkingDirectory=/www/wwwroot/product-query +ExecStart=/www/wwwroot/product-query/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 9000 +Restart=always +User=www +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target +``` +- 执行:`sudo systemctl daemon-reload && sudo systemctl enable product-query && sudo systemctl start product-query && sudo systemctl status product-query` + +4. 宝塔创建网站并配置反向代理 +- 在宝塔面板 → 网站 → 添加站点,绑定你的域名(示例 `query.example.com`) +- 在该站点的“反向代理”中设置: + - 目标地址:`http://127.0.0.1:9000` + - 开启“缓存/压力测试”时先关闭,调试稳定后再按需开启 +- SSL:在站点的“SSL”中申请 Let’s Encrypt 并开启强制 HTTPS + +5. 验证 +- `curl http://127.0.0.1:9000/health` 返回 `{"status":"ok"}` +- 访问 `https://query.example.com/` 打开前端页面 + +## 路径B:宝塔 Python 项目插件(图形化管理) +1. 在宝塔应用商店安装“Python 项目管理器”(版本需支持 FastAPI/uvicorn) +2. 新建项目: +- 运行目录:`/www/wwwroot/product-query` +- 选择 Python 解释器或创建虚拟环境 +- 安装依赖:在插件里执行 `pip install -r requirements.txt` +- 启动命令:`uvicorn app.main:app --host 127.0.0.1 --port 9000` +- 日志路径与守护设置按需配置 +3. 按路径A第4步在 Nginx 站点做反向代理与 SSL + +## 路径C:Docker / Compose(面板或命令行皆可) +1. 安装 Docker 与 Compose(或使用宝塔 Docker 管理器) +- `sudo apt install -y docker.io docker-compose` +2. 在项目目录执行: +- `docker compose up -d`(使用仓库内 `docker-compose.yml`) +- 容器监听 `8000`,宿主机映射 `8000:8000`,数据持久化到宿主 `./data` +3. 在宝塔 Nginx 站点反代到 `127.0.0.1:8000` 并配置 SSL + +## 数据与权限 +- 数据库存放在 `data/products.db`,务必保留该目录的持久化 +- 权限建议:`chown -R www:www /www/wwwroot/product-query`,确保 Nginx/反代用户读写正常(写入仅限后端进程) + +## 运维与排错 +- 服务日志: + - systemd:`journalctl -u product-query -f` + - Python插件/Docker:在面板或 `docker logs -f product-query` +- 常见问题: + - 反代 502:检查后端是否启动、端口是否匹配、站点安全组/防火墙是否放行 + - 中文编码:浏览器显示正常;命令行可能乱码,不影响功能 + +## 上线Checklist +- 站点域名与反向代理已配置 +- SSL 已启用且强制跳转 HTTPS +- 后端进程已常驻(systemd 或插件守护) +- `/import` 上传上限:在 Nginx/站点上传限制按需提高(如 50MB) +- 防火墙安全:只开放 80/443,后端端口用 127.0.0.1 回环 + +## 你需准备的信息 +- 目标域名(如需绑定) +- 选择的部署路径(A/B/C),我可以据此输出一键命令与面板操作截图式步骤 \ No newline at end of file diff --git a/.trae/documents/基于Excel的商品查询系统规划.md b/.trae/documents/基于Excel的商品查询系统规划.md new file mode 100644 index 0000000..0e08d4f --- /dev/null +++ b/.trae/documents/基于Excel的商品查询系统规划.md @@ -0,0 +1,78 @@ +## 核心目标 +- 以最新Excel为数据来源,支持重复导入并覆盖更新 +- 提供手机/电脑可访问的网页,快速查询“名称、进价、卖价、条码” + +## 功能范围 +- Excel导入:上传或本地选择文件,解析指定工作表与字段 +- 数据存储:结构化入库并做去重/更新(以条码为准) +- 查询接口:按条码精准查、按名称模糊查 +- 前端页面:响应式单页,支持键盘输入与移动端友好使用 + +## 技术选型 +- 后端:Python + FastAPI(轻量、易部署、带交互式文档) +- 存储:SQLite(零运维,小型数据量足够;建立索引) +- Excel解析:pandas + openpyxl(兼容中文列名与格式) +- 前端:原生HTML/CSS/JS(或轻量库),移动端响应式布局 + +## 数据模型 +- 表:`products` + - `barcode` TEXT 主键 + - `name` TEXT + - `purchase_price` REAL + - `sale_price` REAL + - 预留字段:`category`、`created_at`、`source_file` +- 索引:`name`(模糊查询加速) + +## Excel导入流程 +1. 读取`Sheet1`,识别列名:`名称(必填)`、`进货价(必填)`、`销售价(必填)`、`条码` +2. 清洗: + - 去首尾空格、统一类型;进价/卖价转数值 + - 条码为空的行跳过;负库存不入库(如需可扩展) +3. Upsert策略:以`条码`为主键;存在则更新名称/价格,不存在则插入 +4. 审计:记录导入时间与源文件名 + +## API设计 +- `POST /import` 上传Excel并执行导入,返回导入统计(插入/更新/跳过数) +- `GET /products/{barcode}` 按条码查询,返回名称/进价/卖价/条码 +- `GET /products?q=关键词&limit=20` 按名称模糊查询,分页 +- `GET /health` 健康检查 + +## 前端设计 +- 单页结构:搜索输入框 + 结果卡片 +- 查询模式: + - 条码:输入或粘贴条码,直接命中详情 + - 名称:模糊搜索,列表展示,点选查看详情 +- 关键字段展示:`名称、进价、卖价、条码` +- 响应式:移动端单列,桌面端居中卡片 + +## 权限与安全 +- 初期无需登录;可选在`/import`增加简单令牌(环境变量) +- 限制上传文件类型与大小;后端校验列名 + +## 性能与鲁棒性 +- SQLite足以支撑数千到数万条数据;建立`barcode`主键与`name`索引 +- 导入过程批量提交、类型稳健转换;错误行记录到日志 + +## 测试与验收 +- 单元测试: + - 列名映射与类型转换 + - Upsert逻辑(插入/更新) +- 集成测试: + - 上传Excel,查询接口返回期望数据 +- 验收标准: + - 可成功导入你现有的`商品资料.xlsx` + - 条码精确查询命中正确的名称/进价/卖价 + - 名称模糊查询返回合理结果 + +## 实施任务清单 +1. 初始化后端项目与SQLite数据表 +2. 实现Excel解析与清洗、Upsert导入模块 +3. 编写API:`/import`、`/products/{barcode}`、`/products` +4. 实现前端页面(响应式布局、搜索交互) +5. 编写测试与导入验证、性能与错误日志 +6. 打包运行脚本与简单部署说明(Windows环境) + +## 可选增强 +- 条码扫描(移动端摄像头 + 前端库,如ZXing/Quagga) +- 导入历史记录与回滚 +- 导入多工作表与列名自动适配 \ No newline at end of file diff --git a/.trae/documents/系统改进计划:搜索、性能、UX 与部署增强.md b/.trae/documents/系统改进计划:搜索、性能、UX 与部署增强.md new file mode 100644 index 0000000..1b6663e --- /dev/null +++ b/.trae/documents/系统改进计划:搜索、性能、UX 与部署增强.md @@ -0,0 +1,42 @@ +## 搜索与数据模型 +- 引入 FTS5 全文索引:为 `name`、`barcode`、(可选)`拼音码` 建立虚拟表,支持高性能模糊匹配与相关性排序 +- 扩展导入:将 Excel 的 `拼音码` 一并入库;若缺失可后续考虑使用库生成(规划阶段不变更依赖) +- 排序策略:前缀匹配 > 后缀匹配 > 任意包含;同分时按匹配长度和最近导入时间排序 +- 分页与上限:为 `/products` 增加 `page` 与 `limit`,默认 `limit=20`、`page=1` + +## API 与后端性能 +- WAL 模式:启用 SQLite `PRAGMA journal_mode=WAL` 与适度 `synchronous=NORMAL` 提升并发读性能 +- 统一查询端点:保留 `/products?q=`(已支持数字部分匹配),增加 `sort`/`order` 参数 +- 健壮性:为 `/import` 增加上传类型/大小校验与导入报告(插入/更新/跳过及示例错误行) + +## 前端 UX +- 结果高亮:在卡片中高亮匹配片段(名称与条码) +- 分页与空态:在列表底部提供分页控件;无结果时引导用户切换到“导入”或修改关键词 +- 条码复制与扫码:卡片“复制条码”按钮;可选增加移动端扫码(后续集成 ZXing) + +## 安全与配置 +- `/import` 简单令牌:通过环境变量 `IMPORT_TOKEN` 校验;面板或 Docker Compose 注入 +- CORS 与限流:允许必要来源;基础限流防滥用(规划阶段暂不改动) + +## 部署与维护 +- Docker:保留端口 `57777`;增加 `.env` 支持 `PORT`、`IMPORT_TOKEN`、`WAL_OPEN=1` +- 备份与导出:每日备份 `data/products.db`;提供 `/export` 导出 CSV(权限受控) + +## 监控与测试 +- 日志:结构化日志(导入错误行、查询参数与耗时) +- 测试:导入与搜索单测(前后缀、包含);接口集成测试覆盖分页与排序 + +## 验收标准 +- 搜索在 5–10k 条数据下 100ms–300ms 返回 +- 支持关键词与数字部分匹配,分页与排序可用 +- `/import` 需令牌;导入报告清晰,错误不影响其他行 +- 前端在手机/桌面下可用;匹配高亮明显;复制条码便捷 + +## 实施清单 +1. 数据层:开启 WAL;新增 FTS5 虚拟表与同步触发器 +2. 导入模块:扩展列映射、报告输出、基础校验 +3. API:分页/排序参数;统一查询逻辑对接 FTS5(无破坏旧参数) +4. 前端:高亮、分页控件、复制条码按钮(扫码为可选后续) +5. 安全:`IMPORT_TOKEN` 校验与 `.env` 注入;文档与面板配置说明 +6. 部署:Compose 加载 `.env`;备份脚本与导出接口 +7. 测试与日志:单测/集成测试与结构化日志 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a47d9b3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app app +COPY static static +COPY README.md README.md +ENV PORT=57777 +RUN mkdir -p /app/data +EXPOSE 57777 +CMD ["sh","-c","uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-57777}"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d31a134 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# 商品查询系统 + +## 功能 +- 导入Excel为数据库(以条码为主键Upsert) +- 查询商品:条码精确查、名称模糊查 +- 响应式前端,手机/电脑可用 + +## 运行 +1. 安装依赖:`python -m pip install fastapi "uvicorn[standard]" pandas openpyxl python-multipart` +2. 启动服务:`python -m uvicorn app.main:app --host 0.0.0.0 --port 57777`(或设置环境变量 `PORT=57777`) +3. 打开页面:`http://localhost:57777/` + +## 导入 +- 页面“导入Excel”选择文件并提交 +- 后端解析`Sheet1`并映射列:`名称(必填)`、`进货价(必填)`、`销售价(必填)`、`条码`、可选`分类(必填)` + +## 接口 +- `POST /import` 表单上传`file` +- `GET /products/{barcode}` 返回名称、进价、卖价、条码 +- `GET /products?q=关键词&limit=20` 名称模糊查询 +- `GET /health` 健康检查 + +## 数据库 +- SQLite 文件:`data/products.db` +- 表:`products(barcode primary key, name, purchase_price, sale_price, category, created_at, source_file)` +- 索引:`idx_products_name(name)` + +## 注意 +- Excel中文列名需一致 +- 非数字价格会被跳过或置为空 +- 条码为空的行跳过 +## Docker(推荐部署) +- 构建并启动:`docker compose up --build -d` +- 端口:宿主机映射 `57777:57777` +- 数据与静态资源挂载: + - `./data:/app/data`(SQLite 持久化) + - `./static:/app/static:ro`(前端文件,无需重建镜像即可更新) diff --git a/app/__pycache__/main.cpython-314.pyc b/app/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000..9fbbc1a Binary files /dev/null and b/app/__pycache__/main.cpython-314.pyc differ diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..34c174e --- /dev/null +++ b/app/main.py @@ -0,0 +1,179 @@ +from fastapi import FastAPI, UploadFile, File, Query +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +import sqlite3 +import pandas as pd +import os +from datetime import datetime + +DB_PATH = os.path.join(os.getcwd(), "data", "products.db") +os.makedirs(os.path.join(os.getcwd(), "data"), exist_ok=True) + +def get_conn(): + conn = sqlite3.connect(DB_PATH, check_same_thread=False) + try: + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + except Exception: + pass + return conn + +def init_db(): + conn = get_conn() + cur = conn.cursor() + cur.execute( + """ + create table if not exists products ( + barcode text primary key, + name text, + purchase_price real, + sale_price real, + category text, + created_at text, + source_file text + ) + """ + ) + cur.execute("create index if not exists idx_products_name on products(name)") + conn.commit() + conn.close() + +init_db() + +app = FastAPI() +app.mount("/static", StaticFiles(directory="static"), name="static") + +@app.get("/", response_class=HTMLResponse) +def root(): + return open(os.path.join("static", "index.html"), "r", encoding="utf-8").read() + +@app.get("/health") +def health(): + return {"status": "ok"} + +def normalize_price(v): + if v is None: + return None + try: + s = str(v).strip() + if s == "": + return None + return float(s) + except: + return None + +def normalize_text(v): + if v is None: + return None + s = str(v).strip() + return s if s != "" else None + +def import_df(df: pd.DataFrame, source_file: str): + required = ["名称(必填)", "进货价(必填)", "销售价(必填)", "条码"] + for col in required: + if col not in df.columns: + return {"error": f"missing column: {col}"} + df = df[["名称(必填)", "进货价(必填)", "销售价(必填)", "条码", "分类(必填)"]].copy() if "分类(必填)" in df.columns else df[["名称(必填)", "进货价(必填)", "销售价(必填)", "条码"]].copy() + df.columns = ["name", "purchase_price", "sale_price", "barcode"] + (["category"] if df.shape[1] == 5 else []) + df["name"] = df["name"].apply(normalize_text) + df["barcode"] = df["barcode"].apply(normalize_text) + df["purchase_price"] = df["purchase_price"].apply(normalize_price) + df["sale_price"] = df["sale_price"].apply(normalize_price) + if "category" in df.columns: + df["category"] = df["category"].apply(normalize_text) + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + inserted = 0 + updated = 0 + skipped = 0 + conn = get_conn() + cur = conn.cursor() + for _, row in df.iterrows(): + bc = row.get("barcode") + nm = row.get("name") + pp = row.get("purchase_price") + sp = row.get("sale_price") + ct = row.get("category") if "category" in df.columns else None + if bc is None: + skipped += 1 + continue + cur.execute("select barcode from products where barcode=?", (bc,)) + exists = cur.fetchone() + if exists: + cur.execute( + "update products set name=?, purchase_price=?, sale_price=?, category=?, created_at=?, source_file=? where barcode=?", + (nm, pp, sp, ct, now, source_file, bc), + ) + updated += 1 + else: + cur.execute( + "insert into products(barcode,name,purchase_price,sale_price,category,created_at,source_file) values(?,?,?,?,?,?,?)", + (bc, nm, pp, sp, ct, now, source_file), + ) + inserted += 1 + conn.commit() + conn.close() + return {"inserted": inserted, "updated": updated, "skipped": skipped} + +@app.post("/import") +async def import_excel(file: UploadFile = File(...)): + tmp = os.path.join(os.getcwd(), "data", f"upload_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{file.filename}") + with open(tmp, "wb") as f: + f.write(await file.read()) + try: + df = pd.read_excel(tmp, sheet_name="Sheet1") + result = import_df(df, os.path.basename(file.filename)) + return result if isinstance(result, dict) else {"error": "import failed"} + finally: + pass + +@app.get("/products/{barcode}") +def get_product(barcode: str): + conn = get_conn() + cur = conn.cursor() + cur.execute("select name,purchase_price,sale_price,barcode from products where barcode=?", (barcode,)) + row = cur.fetchone() + conn.close() + if not row: + return JSONResponse(status_code=404, content={"error": "not found"}) + return {"name": row[0], "purchase_price": row[1], "sale_price": row[2], "barcode": row[3]} + +@app.get("/products") +def search_products(q: str = Query("", min_length=0), limit: int = 20, page: int = 1, sort: str = "", order: str = "asc"): + conn = get_conn() + cur = conn.cursor() + offset = max((page - 1), 0) * max(limit, 1) + sort_map = {"name": "name", "sale_price": "sale_price", "purchase_price": "purchase_price", "created_at": "created_at", "barcode": "barcode"} + sort_col = sort_map.get(sort, "") + order_sql = "DESC" if str(order).lower() == "desc" else "ASC" + if q: + s = q.strip() + if s.isdigit(): + if sort_col: + cur.execute( + f"select distinct name,purchase_price,sale_price,barcode from products where barcode like ? or barcode like ? or barcode like ? or name like ? order by {sort_col} {order_sql} limit ? offset ?", + (f"{s}%", f"%{s}", f"%{s}%", f"%{s}%", limit, offset), + ) + else: + cur.execute( + "select name,purchase_price,sale_price,barcode, case when barcode like ? then 0 when barcode like ? then 1 when barcode like ? then 2 when name like ? then 3 else 4 end as score from products where barcode like ? or barcode like ? or barcode like ? or name like ? order by score, name limit ? offset ?", + (f"{s}%", f"%{s}", f"%{s}%", f"%{s}%", f"{s}%", f"%{s}", f"%{s}%", f"%{s}%", limit, offset), + ) + else: + if sort_col: + cur.execute( + f"select name,purchase_price,sale_price,barcode from products where name like ? order by {sort_col} {order_sql} limit ? offset ?", + (f"%{s}%", limit, offset), + ) + else: + cur.execute( + "select name,purchase_price,sale_price,barcode, case when instr(name, ?) = 1 then 0 when instr(name, ?) > 0 then 1 else 2 end as score from products where name like ? order by score, name limit ? offset ?", + (s, s, f"%{s}%", limit, offset), + ) + else: + if sort_col: + cur.execute(f"select name,purchase_price,sale_price,barcode from products order by {sort_col} {order_sql} limit ? offset ?", (limit, offset)) + else: + cur.execute("select name,purchase_price,sale_price,barcode from products order by name limit ? offset ?", (limit, offset)) + rows = cur.fetchall() + conn.close() + return [{"name": r[0], "purchase_price": r[1], "sale_price": r[2], "barcode": r[3]} for r in rows] diff --git a/data/products.db b/data/products.db new file mode 100644 index 0000000..7d8548c Binary files /dev/null and b/data/products.db differ diff --git a/data/upload_20251204_214124_商品资料.xlsx b/data/upload_20251204_214124_商品资料.xlsx new file mode 100644 index 0000000..c601044 Binary files /dev/null and b/data/upload_20251204_214124_商品资料.xlsx differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..35044d4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + web: + build: . + ports: + - "57777:57777" + volumes: + - ./data:/app/data + - ./static:/app/static:ro + restart: always diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8d6e5e6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.122.0 +uvicorn[standard]==0.38.0 +pandas==2.3.3 +openpyxl==3.1.5 +python-multipart==0.0.20 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..e46e8e3 --- /dev/null +++ b/static/app.js @@ -0,0 +1,145 @@ +const qs = (s) => document.querySelector(s); +const results = qs('#results'); +const input = qs('#q'); +const searchBtn = qs('#searchBtn'); +const fileInput = qs('#file'); +const importBtn = qs('#importBtn'); +const importStatus = qs('#importStatus'); +const tabs = document.querySelectorAll('.tab'); +const searchSection = qs('#searchSection'); +const importSection = qs('#importSection'); +const scanBtn = qs('#scanBtn'); +const scannerModal = qs('#scannerModal'); +const closeScanner = qs('#closeScanner'); +let scannerInst = null; +let videoEl = null; +let mediaStream = null; +// 无分页模式 + +let currentTab = 'search'; +let limit = 10000; +let currentQuery = ''; +function setTab(tab){ + currentTab = tab; + tabs.forEach(t=>{ + const active = t.dataset.tab===tab; + t.classList.toggle('active', active); + t.setAttribute('aria-selected', String(active)); + }); + if(tab==='search'){ searchSection.classList.remove('hidden'); importSection.classList.add('hidden'); } + else { importSection.classList.remove('hidden'); searchSection.classList.add('hidden'); } +} +tabs.forEach(t=>t.addEventListener('click',()=>setTab(t.dataset.tab))); +setTab('search'); + +async function fetchResults(){ + results.innerHTML = '
加载中...
'; + try{ + const r = await fetch(`/products?q=${encodeURIComponent(currentQuery)}&limit=${limit}`); + const d = await r.json(); + render(d); + }catch(e){ + results.innerHTML = '
请求失败
'; + } +} + +function search(){ + currentQuery = input.value.trim(); + fetchResults(); +} + +function render(items) { + if (!items || items.length === 0) { + results.innerHTML = '
无结果
'; + return; + } + const term = currentQuery; + const esc = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = term ? new RegExp(esc, 'ig') : null; + results.innerHTML = items.map(x => { + const name = x.name ?? ''; + const purchase = x.purchase_price ?? ''; + const sale = x.sale_price ?? ''; + const barcode = x.barcode ?? ''; + const nameH = re ? name.replace(re, m=>`${m}`) : name; + const barcodeH = re ? barcode.replace(re, m=>`${m}`) : barcode; + return ` +
+
${nameH}
+
+
卖价:${sale}
+
进价:${purchase}
+
+
条码:${barcodeH}
+
`; + }).join(''); + // 无分页模式 +} + +searchBtn.addEventListener('click', search); +input.addEventListener('keydown', e => { if (e.key === 'Enter') search(); }); + +async function openScanner(){ + scannerModal.classList.remove('hidden'); + try{ + if('BarcodeDetector' in window){ + const formats = ['ean_13','ean_8','code_128','code_39','upc_a','upc_e']; + const detector = new window.BarcodeDetector({ formats }); + mediaStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }); + videoEl = document.createElement('video'); + videoEl.autoplay = true; + videoEl.playsInline = true; + videoEl.srcObject = mediaStream; + const host = document.getElementById('scanner'); + host.innerHTML = ''; + host.appendChild(videoEl); + await videoEl.play(); + const loop = async()=>{ + try{ + const codes = await detector.detect(videoEl); + if(codes && codes.length){ + const text = codes[0].rawValue; + input.value = text; + currentQuery = text; + fetchResults(); + closeScannerFn(); + return; + } + }catch(_){ } + if(!scannerModal.classList.contains('hidden')) requestAnimationFrame(loop); + }; + requestAnimationFrame(loop); + } else { + scannerModal.classList.add('hidden'); + } + }catch(e){ + scannerModal.classList.add('hidden'); + } +} + +function closeScannerFn(){ + try{ if(scannerInst){ scannerInst.stop().then(()=>scannerInst.clear()); } }catch(_){ } + try{ if(mediaStream){ mediaStream.getTracks().forEach(t=>t.stop()); mediaStream=null; } }catch(_){ } + try{ const host = document.getElementById('scanner'); if(host){ host.innerHTML=''; } }catch(_){ } + scannerModal.classList.add('hidden'); +} + +scanBtn.addEventListener('click', openScanner); +closeScanner.addEventListener('click', closeScannerFn); + +// 无分页控件 + +importBtn.addEventListener('click', async () => { + const f = fileInput.files?.[0]; + if (!f) return; + const fd = new FormData(); + fd.append('file', f); + importStatus.textContent = '导入中...'; + try { + const r = await fetch('/import', { method: 'POST', body: fd }); + const d = await r.json(); + importStatus.textContent = `插入: ${d.inserted ?? 0},更新: ${d.updated ?? 0},跳过: ${d.skipped ?? 0}`; + } catch (e) { + importStatus.textContent = '导入失败'; + } +}); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..208e78a --- /dev/null +++ b/static/index.html @@ -0,0 +1,45 @@ + + + + + + 商品查询 + + + +
+
+ + +
+ +
+

商品查询

+ +
+
+ + +
+ + + + diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..eb27707 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,39 @@ +*{box-sizing:border-box} +body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto;background:#fff;color:#111} +.container{max-width:720px;margin:0 auto;padding:16px} +.tabs{position:sticky;top:0;background:#fff;padding:8px 0;margin-bottom:8px;border-bottom:1px solid #eee;z-index:10} +.tab{margin-right:8px;padding:10px 14px;border:1px solid #ddd;border-radius:8px;background:#f7f7f7;color:#111} +.tab.active{background:#1976d2;color:#fff;border-color:#1976d2} +h1{font-size:20px;margin:8px 0 16px} +.section{margin-top:8px} +.hidden{display:none} +.search{display:flex;gap:8px;margin-bottom:16px} +input{flex:1;padding:10px;border:1px solid #ccc;border-radius:6px;background:#fff;color:#111} +.search-input{position:relative;flex:1} +.search-input input{width:100%;padding-right:44px} +.scan-btn{position:absolute;right:8px;top:50%;transform:translateY(-50%);border:0;background:#f0f0f0;color:#111;border-radius:6px;padding:6px 10px} +button{padding:10px 14px;border:0;border-radius:6px;background:#1976d2;color:#fff} +.results{display:flex;flex-direction:column;gap:12px} +.card{border:1px solid #eee;border-radius:10px;padding:12px;box-shadow:0 1px 2px rgba(0,0,0,0.04)} +.name{font-weight:700;font-size:16px;margin-bottom:6px} +.prices{display:flex;gap:16px;margin-bottom:6px} +.price-sale{color:#d32f2f;font-weight:600} +.price-purchase{color:#455a64} +.barcode{font-family:ui-monospace, SFMono-Regular, Menlo, monospace;color:#222} +mark{background:#ffec99} +.row{display:flex;justify-content:space-between;padding:4px 0} +.row span:first-child{color:#555} +.empty{color:#999} +.loading{color:#1976d2} +.status{margin-top:8px;color:#555} +.import{margin-top:12px;display:flex;gap:8px;align-items:center} +.modal{position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center} +.modal.hidden{display:none} +.modal-content{background:#fff;border-radius:10px;padding:12px;width:92%;max-width:480px} +@media (max-width:600px){ + .container{padding:12px} + .tab{padding:8px 12px} + .prices{flex-direction:column} + .import{flex-direction:column;align-items:stretch} + .search{display:grid;grid-template-columns:4fr 1fr;gap:8px} +} diff --git a/商品资料.xlsx b/商品资料.xlsx new file mode 100644 index 0000000..c601044 Binary files /dev/null and b/商品资料.xlsx differ diff --git a/部署文档.md b/部署文档.md new file mode 100644 index 0000000..e413892 --- /dev/null +++ b/部署文档.md @@ -0,0 +1,69 @@ +# Docker 部署指南 + +## 环境准备 +- Ubuntu 22.04(或你的服务器环境) +- 已安装 Docker 与 Docker Compose(或宝塔 Docker 管理器) +- 域名与宝塔 Nginx(可选,用于反向代理与 SSL) + +## 端口与目录 +- 服务端口:`57777`(容器与宿主机均使用 57777) +- 数据持久化:`./data:/app/data`(SQLite 数据库) +- 静态资源挂载:`./static:/app/static:ro`(前端文件可直接热更新) + +## 项目结构 +- `Dockerfile`:镜像构建 +- `docker-compose.yml`:编排与端口/挂载 +- `app/`:后端 FastAPI +- `static/`:前端资源 +- `data/`:数据库(运行时生成) + +## Gitea 流程(示例) +1. 在 Gitea 创建仓库,推送本项目代码 +2. 服务器拉取: + - `git clone https://your-gitea/owner/repo.git /opt/product-query` + - `cd /opt/product-query` +3. 启动: + - `docker compose up --build -d` +4. 验证: + - `curl http://127.0.0.1:57777/health` 返回 `{"status":"ok"}` + - 浏览器打开 `http://SERVER_IP:57777/` + +## 宝塔面板(可选) +- 网站 → 添加站点 → 绑定域名 +- 反向代理:目标 `http://127.0.0.1:57777` +- SSL:申请 Let’s Encrypt 并开启强制 HTTPS + +## 更新前端与后端 +- 前端:修改 `static/` 文件后,容器会直接读取(因挂载为只读给容器) +- 后端:修改 `app/` 或依赖后: + - `docker compose up --build -d` +- 如构建缓存异常: + - `docker compose build --no-cache && docker compose up -d` + +## 数据与备份 +- 数据库文件位于宿主机 `./data/products.db` +- 建议每日备份该文件;导出可直接复制或后续加 `/export` 接口 + +## 常见问题 +- 502 或无法访问:确认容器已启动且端口映射为 `57777:57777` +- 浏览器不支持扫码:`BarcodeDetector` 在部分旧设备不可用,仍可通过手动输入条码查询 +- 中文显示乱码:仅限命令行输出,页面显示正常;确保浏览器编码为 UTF-8 + +## 一键命令汇总 +- 首次部署: +``` +cd /opt/product-query +docker compose up --build -d +``` +- 更新部署: +``` +git pull +docker compose up --build -d +``` + +## 安全建议 +- 宿主仅开放 80/443;`57777` 可通过回环访问并由 Nginx 反代对外 +- 上传导入大小可在 Nginx/面板调整;建议限制文件类型为 Excel(`.xlsx/.xls`) + +--- +如需将 Compose 改为读取 `.env`(例如 `PORT`、令牌等),我可以补充 `.env.example` 与 `docker-compose.yml` 的 `env_file` 配置。当前方案保持简洁,端口固定为 57777。