一个手机快速搜索商品的网页
This commit is contained in:
commit
fcbcdb7f95
70
.trae/documents/优化前端查询与导入界面.md
Normal file
70
.trae/documents/优化前端查询与导入界面.md
Normal file
@ -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` 截图与使用说明
|
||||
86
.trae/documents/在 Ubuntu 22.04(宝塔面板)部署商品查询系统的方案.md
Normal file
86
.trae/documents/在 Ubuntu 22.04(宝塔面板)部署商品查询系统的方案.md
Normal file
@ -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),我可以据此输出一键命令与面板操作截图式步骤
|
||||
78
.trae/documents/基于Excel的商品查询系统规划.md
Normal file
78
.trae/documents/基于Excel的商品查询系统规划.md
Normal file
@ -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)
|
||||
- 导入历史记录与回滚
|
||||
- 导入多工作表与列名自动适配
|
||||
42
.trae/documents/系统改进计划:搜索、性能、UX 与部署增强.md
Normal file
42
.trae/documents/系统改进计划:搜索、性能、UX 与部署增强.md
Normal file
@ -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. 测试与日志:单测/集成测试与结构化日志
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@ -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}"]
|
||||
37
README.md
Normal file
37
README.md
Normal file
@ -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`(前端文件,无需重建镜像即可更新)
|
||||
BIN
app/__pycache__/main.cpython-314.pyc
Normal file
BIN
app/__pycache__/main.cpython-314.pyc
Normal file
Binary file not shown.
179
app/main.py
Normal file
179
app/main.py
Normal file
@ -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]
|
||||
BIN
data/products.db
Normal file
BIN
data/products.db
Normal file
Binary file not shown.
BIN
data/upload_20251204_214124_商品资料.xlsx
Normal file
BIN
data/upload_20251204_214124_商品资料.xlsx
Normal file
Binary file not shown.
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
@ -0,0 +1,9 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "57777:57777"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./static:/app/static:ro
|
||||
restart: always
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@ -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
|
||||
145
static/app.js
Normal file
145
static/app.js
Normal file
@ -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 = '<div class="loading">加载中...</div>';
|
||||
try{
|
||||
const r = await fetch(`/products?q=${encodeURIComponent(currentQuery)}&limit=${limit}`);
|
||||
const d = await r.json();
|
||||
render(d);
|
||||
}catch(e){
|
||||
results.innerHTML = '<div class="empty">请求失败</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function search(){
|
||||
currentQuery = input.value.trim();
|
||||
fetchResults();
|
||||
}
|
||||
|
||||
function render(items) {
|
||||
if (!items || items.length === 0) {
|
||||
results.innerHTML = '<div class="empty">无结果</div>';
|
||||
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=>`<mark>${m}</mark>`) : name;
|
||||
const barcodeH = re ? barcode.replace(re, m=>`<mark>${m}</mark>`) : barcode;
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="name">${nameH}</div>
|
||||
<div class="prices">
|
||||
<div class="price-sale">卖价:${sale}</div>
|
||||
<div class="price-purchase">进价:${purchase}</div>
|
||||
</div>
|
||||
<div class="barcode">条码:${barcodeH}</div>
|
||||
</div>`;
|
||||
}).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 = '导入失败';
|
||||
}
|
||||
});
|
||||
45
static/index.html
Normal file
45
static/index.html
Normal file
@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>商品查询</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="tabs" role="tablist">
|
||||
<button class="tab active" role="tab" aria-selected="true" aria-controls="searchSection" data-tab="search">搜索</button>
|
||||
<button class="tab" role="tab" aria-selected="false" aria-controls="importSection" data-tab="import">导入</button>
|
||||
</div>
|
||||
|
||||
<section id="searchSection" class="section">
|
||||
<h1>商品查询</h1>
|
||||
<div class="search">
|
||||
<div class="search-input">
|
||||
<input id="q" type="text" placeholder="输入条码或名称,支持前缀/后缀/包含" />
|
||||
<button id="scanBtn" class="scan-btn" aria-label="扫码">📷</button>
|
||||
</div>
|
||||
<button id="searchBtn">查询</button>
|
||||
</div>
|
||||
<div id="results" class="results"></div>
|
||||
</section>
|
||||
|
||||
<section id="importSection" class="section hidden">
|
||||
<h1>导入Excel</h1>
|
||||
<div class="import">
|
||||
<input id="file" type="file" accept=".xlsx,.xls" />
|
||||
<button id="importBtn">导入</button>
|
||||
<div id="importStatus" class="status"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div id="scannerModal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<div id="scanner"></div>
|
||||
<button id="closeScanner">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
39
static/styles.css
Normal file
39
static/styles.css
Normal file
@ -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}
|
||||
}
|
||||
69
部署文档.md
Normal file
69
部署文档.md
Normal file
@ -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。
|
||||
Loading…
Reference in New Issue
Block a user