当前位置: 首页 > news >正文

从零构建智能购物清单应用:技术选型、架构设计与全栈实践

1. 项目概述与核心价值

最近在逛GitHub的时候,发现了一个挺有意思的项目,叫“akilli_market_listem”,直译过来就是“我的智能购物清单”。这个项目名听起来就挺接地气的,它本质上是一个开源的、可以自部署的智能购物清单应用。作为一个经常需要采购家庭物资、又总在超市里忘记要买啥的人来说,这类工具简直是刚需。市面上的购物清单App不少,但要么广告满天飞,要么功能臃肿,要么数据隐私让人不放心。而这个项目吸引我的点在于,它把“智能”和“清单”结合得相当巧妙,不仅仅是让你记录“牛奶、鸡蛋、面包”,而是试图通过一些简单的算法和设计,让购物这件事变得更高效、更省心。

这个项目的核心价值,我认为在于它解决了一个非常普遍但常被忽视的痛点:如何将零散的购物需求,转化为一次高效、无遗漏的采购行动。它不像一个复杂的ERP系统,而是聚焦于个人或家庭的日常消费场景。通过将商品分类、设置常用清单、甚至预测购买频率,它帮助用户从“想到什么记什么”的混乱状态,过渡到“按计划、按区域采购”的有序状态。对于喜欢折腾技术、又注重生活效率的开发者或极客用户来说,拥有一个完全受自己控制、数据私有的清单工具,无疑具有很大的吸引力。接下来,我就结合这个项目,深入拆解一下如何从零开始构建一个类似的智能购物清单应用,这里面涉及的技术选型、设计思路和那些只有实际做过才会知道的“坑”。

2. 整体架构设计与技术选型考量

2.1 核心功能模块拆解

在动手写代码之前,得先想清楚这个“智能购物清单”到底该有什么。我们不能做一个大而全的东西,必须抓住核心。基于“akilli_market_listem”这个项目名和常见的需求,我将其核心模块拆解为以下几个部分:

  1. 清单管理:这是基石。用户能创建多个购物清单(比如“每周超市采购”、“家居用品补货”、“周末烧烤食材”),每个清单里可以自由添加、删除、修改商品项。
  2. 商品库与分类:智能的基础。需要一个后台的商品数据库,包含商品名称、所属分类(如“乳制品”、“果蔬”、“清洁用品”)。用户添加商品时,可以从库中搜索选择,这保证了数据的一致性,也为后续的智能推荐打下基础。
  3. 智能建议与快速添加:这是“智能”的体现。系统可以根据用户的历史购买记录,在用户创建清单时,自动推荐“可能需要的商品”。例如,如果你上周买了牛奶,这周系统可能会提示“牛奶是否还需要?”。同时,提供“常用商品”一键添加功能。
  4. 清单状态与协同(可选但重要):清单有“待采购”、“采购中”、“已完成”状态。更高级一点,支持多用户(如家人)共享同一个清单,实时同步勾选情况,避免重复购买。
  5. 数据统计与洞察(进阶功能):分析用户的消费习惯,比如最常购买的商品类别、大致的购物周期等,以图表形式呈现。

为什么这么设计?因为一个单纯的记事本应用价值有限。加入了商品库和分类,清单才能结构化;有了智能建议,才能减少用户的记忆负担;支持状态和协同,才契合真实的家庭购物场景(经常是多人分工)。这些功能环环相扣,共同支撑起“智能”体验。

2.2 前后端技术栈选型分析

技术选型没有绝对的对错,只有是否合适。对于这样一个个人或小范围使用的工具类应用,我的选型原则是:轻量、快速开发、易于部署、维护成本低

后端选择:Node.js + Express 或 Python + Flask/FastAPI

  • Node.js (Express):优势在于JavaScript全栈,如果前端也打算用现代JS框架(如React, Vue),共享语言和部分工具链能提升开发效率。它非阻塞I/O的特性适合高并发的实时应用,虽然我们这个应用并发要求不高,但其轻量和丰富的npm生态是很大优点。
  • Python (Flask/FastAPI):优势在于开发效率极高,语法简洁,数据处理和机器学习库(如pandas, scikit-learn)丰富,如果未来想深入做基于购买历史的智能预测,Python是更自然的选择。FastAPI尤其适合构建API,自动生成交互式文档。
  • 我的选择与理由:考虑到项目原型阶段需要快速验证想法,并且智能推荐逻辑初期可能比较简单(如基于频率的规则),我倾向于使用Python + FastAPI。它的学习曲线相对平缓,依赖注入、数据验证等开箱即用的特性能让代码更健壮,而且写API接口非常快。对于初期可能只有自己或家人使用的场景,完全够用。

数据库选择:SQLite 或 PostgreSQL

  • SQLite:嵌入式数据库,无需单独安装数据库服务,数据存储在单个文件中,备份和迁移极其方便。对于单机部署或用户量极少的场景,它是完美选择。性能在正确使用索引的情况下,应对这个应用绰绰有余。
  • PostgreSQL:功能更强大的关系型数据库,支持更复杂的数据类型、查询和并发控制。如果预期未来会有多用户同时读写,或者数据关系变得复杂,PostgreSQL是更稳妥的选择。
  • 我的选择与理由:秉承“如无必要,勿增实体”的原则,在项目初期,SQLite是首选。它极大地简化了部署复杂度——你只需要把代码和那个.db文件拷贝到服务器就行。当应用真的增长到需要更强数据库时,从SQLite迁移到PostgreSQL也有成熟的方案。因此,起步阶段用SQLite能让我们更专注于业务逻辑。

前端选择:Vue.js 3 + Vite 或 React

  • Vue.js 3:渐进式框架,上手友好,模板语法直观,对于构建这种交互较多的中后台管理界面或移动端友好的SPA(单页应用)非常合适。其响应式系统和组合式API让状态管理变得清晰。
  • React:生态庞大,社区活跃,灵活性极高。如果团队更熟悉React,或者需要集成大量第三方React组件,它是好选择。
  • 我的选择与理由:我个人更偏好Vue.js的简洁和“开箱即用”的感觉,特别是其脚手架工具Vite能提供极快的热更新速度,提升开发体验。对于购物清单这种需要频繁更新UI(勾选、添加商品)的应用,Vue的响应式系统用起来会很顺手。我们将构建一个前后端分离的应用,前端通过RESTful API或GraphQL与后端通信。

部署与运维:Docker + Docker Compose

  • 即使初期很简单,我也强烈建议使用Docker。它将应用及其所有依赖(Python环境、Node环境、甚至数据库)打包成一个镜像,保证了环境的一致性。docker-compose.yml文件可以一键启动整个服务栈(后端、前端、数据库)。这让你在本地开发、测试和生产部署时拥有完全相同的环境,避免了“在我机器上好好的”这类问题。对于个人项目,你可以轻松地将Docker镜像部署到任何支持Docker的VPS或云服务上。

注意:技术选型不是一成不变的。这里的选择是基于“快速构建一个可用、可维护的智能清单工具”的目标。如果你的团队有特殊技术储备或偏好,完全可以根据情况调整。例如,如果你对Go语言很熟,用Go写后端可能性能更高;如果你喜欢一体化方案,Nuxt.js (Vue) 或 Next.js (React) 的服务端渲染能力也是不错的选择。

3. 数据库设计与核心模型解析

数据库是应用的“记忆中枢”,设计得好不好,直接关系到后续功能扩展和代码编写的复杂度。围绕我们的核心功能,我们来设计几个主要的实体(表)。

3.1 核心表结构设计

我们将设计四张核心表:users(用户)、categories(分类)、products(商品)、shopping_lists(购物清单)以及关联表list_items(清单项)。

1.users用户表这是系统的基础,用于支持多用户和未来的共享功能。即使初期只给自己用,预留用户体系也是好的设计。

CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username VARCHAR(50) UNIQUE NOT NULL, -- 用户名,用于登录 email VARCHAR(100) UNIQUE, -- 邮箱,可选 password_hash VARCHAR(255) NOT NULL, -- 加密后的密码 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );
  • 设计理由id是主键。usernameemail设唯一约束,防止重复注册。password_hash存储的是经过bcrypt或类似算法加密的哈希值,绝对不要明文存储密码created_at记录注册时间,可用于分析。

2.categories商品分类表这是实现智能分类和统计的基础。分类最好是预置的,由系统初始化。

CREATE TABLE categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(50) NOT NULL UNIQUE, -- 分类名,如“乳制品” icon VARCHAR(50), -- 可选的图标类名或URL,用于前端展示 sort_order INTEGER DEFAULT 0 -- 排序字段,控制前端显示顺序 );
  • 设计理由:分类相对固定,独立成表便于管理。sort_order字段允许你自定义在App中分类的显示顺序(比如把最常用的放前面)。

3.products商品表这是系统的“知识库”,存储所有可能的商品。用户添加商品时,优先从这里选择。

CREATE TABLE products ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(100) NOT NULL UNIQUE, -- 商品名,如“鲜牛奶” category_id INTEGER NOT NULL, -- 外键,关联分类 default_unit VARCHAR(20), -- 默认单位,如“瓶”、“个”、“kg” created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT ); CREATE INDEX idx_products_category ON products(category_id); -- 为分类查询建索引 CREATE INDEX idx_products_name ON products(name); -- 为商品名搜索建索引
  • 设计理由name唯一确保商品不重复。category_id外键关联分类,这是后续按分类筛选、统计的关键。default_unit很有用,比如买苹果,可以记“3个”或“1kg”。务必建立索引,尤其是在category_idname上,当商品数据量增大时,能极大提升查询清单和搜索商品的速度。

4.shopping_lists购物清单表代表一次具体的购物任务。

CREATE TABLE shopping_lists ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, -- 清单所有者 title VARCHAR(100) NOT NULL, -- 清单标题,如“周末大采购” status VARCHAR(20) DEFAULT 'active', -- 状态:active, completed, cancelled store_name VARCHAR(100), -- 计划去的商店,可选 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX idx_lists_user_status ON shopping_lists(user_id, status); -- 复合索引,快速查询用户某状态的清单
  • 设计理由user_id关联清单所有者。status字段实现清单状态流转。store_name是个贴心设计,有时我们去特定超市买特定东西。updated_at字段由触发器或ORM自动更新,用于排序或同步。为(user_id, status)创建复合索引,因为最常见的查询就是“获取某个用户所有活跃的清单”。

5.list_items清单项表这是清单和商品的多对多关联表,记录清单中具体要买什么、买多少、是否已买。

CREATE TABLE list_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, list_id INTEGER NOT NULL, product_id INTEGER NOT NULL, -- 关联商品库中的商品 quantity REAL DEFAULT 1, -- 数量,可以是小数(如0.5kg) unit VARCHAR(20), -- 单位,可覆盖商品的默认单位 is_checked BOOLEAN DEFAULT FALSE, -- 是否已勾选(已购买) notes TEXT, -- 备注,如“要熟透的香蕉” created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (list_id) REFERENCES shopping_lists(id) ON DELETE CASCADE, FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE RESTRICT, UNIQUE(list_id, product_id) -- 防止同一商品在同一清单重复添加 ); CREATE INDEX idx_items_list ON list_items(list_id); CREATE INDEX idx_items_checked ON list_items(is_checked);
  • 设计理由:这是整个系统的核心关系表。quantityunit分离,灵活应对不同计量方式。is_checked是购物时的核心交互点。UNIQUE(list_id, product_id)约束是个关键设计,它确保同一个商品在同一个清单里只能出现一次,避免数据混乱。索引同样必不可少。

3.2 关系与查询优化思考

这样的设计形成了一个清晰的关系网:用户拥有多个清单,每个清单包含多个商品项,每个商品属于一个分类。当我们需要“获取用户A所有活跃清单及其商品项,并按分类分组显示”时,SQL查询会涉及到多表JOIN。这正是索引发挥作用的地方,能避免全表扫描,尤其在移动设备上网络请求和数据处理资源有限时,后端高效的查询至关重要。

另外,考虑到未来可能的“智能推荐”,我们还可以添加一张purchase_history(购买历史)表,记录用户每次勾选(购买)某个商品的时间、清单和数量。这张表的数据是进行购买频率分析、生成智能推荐的核心燃料。初期可以不做,但在数据库设计时心里要留有这块位置。

4. 后端API实现与业务逻辑剖析

有了数据库设计蓝图,我们就可以用FastAPI来搭建后端的“骨架”和“肌肉”了。FastAPI的自动文档生成、数据验证和依赖注入系统会让我们的开发过程非常顺畅。

4.1 项目结构与依赖管理

首先,建立清晰的项目结构:

akilli_market_listem_backend/ ├── app/ │ ├── __init__.py │ ├── main.py # FastAPI应用实例和路由总汇 │ ├── core/ # 核心配置(数据库、安全等) │ ├── models/ # SQLAlchemy ORM 模型(对应数据库表) │ ├── schemas/ # Pydantic 模型(用于请求/响应数据验证) │ ├── crud/ # 增删改查的通用函数 │ ├── api/ # 路由端点 │ │ ├── __init__.py │ │ ├── endpoints/ # 具体的路由文件,如 items.py, lists.py │ │ └── deps.py # 依赖项,如获取当前用户 │ └── database.py # 数据库会话管理 ├── requirements.txt └── Dockerfile

requirements.txt中,我们需要的关键依赖有:

fastapi uvicorn[standard] # ASGI服务器,用于运行应用 sqlalchemy # ORM,用于操作数据库 pydantic # 数据验证(FastAPI已内置,但明确版本) passlib[bcrypt] # 密码哈希 python-jose[cryptography] # JWT令牌操作 python-multipart # 支持表单数据解析

使用虚拟环境安装后,我们就可以开始编码了。

4.2 用户认证与授权实现

对于个人工具,认证可以简单,但不能不安全。我们将采用经典的JWT (JSON Web Token)无状态认证。

1. 核心工具函数 (app/core/security.py)这里负责密码哈希和JWT令牌的创建与验证。

from passlib.context import CryptContext from jose import JWTError, jwt from datetime import datetime, timedelta SECRET_KEY = "your-secret-key-please-change-in-production" # 务必在生产环境更换! ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 令牌有效期,例如7天 pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") def verify_password(plain_password, hashed_password): """验证密码""" return pwd_context.verify(plain_password, hashed_password) def get_password_hash(password): """生成密码哈希""" return pwd_context.hash(password) def create_access_token(data: dict, expires_delta: timedelta = None): """创建JWT访问令牌""" to_encode = data.copy() if expires_delta: expire = datetime.utcnow() + expires_delta else: expire = datetime.utcnow() + timedelta(minutes=15) to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt

2. 获取当前用户的依赖项 (app/api/deps.py)这个依赖项会用于所有需要认证的路由,它从请求头中提取Token,验证其有效性,并返回对应的用户对象。

from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import JWTError from sqlalchemy.orm import Session from app.core import security from app.core.database import get_db from app.models.user import User from app.crud import user as crud_user oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") # 指向你的登录端点 async def get_current_user( token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) ): credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) try: payload = jwt.decode(token, security.SECRET_KEY, algorithms=[security.ALGORITHM]) username: str = payload.get("sub") # 我们约定把用户名存在'sub'字段 if username is None: raise credentials_exception except JWTError: raise credentials_exception user = crud_user.get_user_by_username(db, username=username) if user is None: raise credentials_exception return user

3. 登录与注册端点 (app/api/endpoints/auth.py)

from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session from datetime import timedelta from app.core import security from app.core.database import get_db from app.schemas.token import Token from app.schemas.user import UserCreate from app.crud import user as crud_user router = APIRouter() @router.post("/token", response_model=Token) async def login_for_access_token( form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db) ): user = crud_user.authenticate_user(db, form_data.username, form_data.password) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}, ) access_token_expires = timedelta(minutes=security.ACCESS_TOKEN_EXPIRE_MINUTES) access_token = security.create_access_token( data={"sub": user.username}, expires_delta=access_token_expires ) return {"access_token": access_token, "token_type": "bearer"} @router.post("/register", response_model=UserSchema) # 假设有UserSchema async def register_user( user_in: UserCreate, db: Session = Depends(get_db) ): # 检查用户名是否已存在 user = crud_user.get_user_by_username(db, username=user_in.username) if user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Username already registered", ) # 创建新用户 user = crud_user.create_user(db, user_in) return user

这样,一个基本的、安全的认证流程就搭建好了。前端在登录后拿到Token,后续请求在Authorization头中带上Bearer <token>即可访问受保护的API。

4.3 购物清单核心CRUD与业务逻辑

以购物清单的创建、获取、更新为例,展示如何结合ORM和Pydantic实现清晰的业务层。

1. CRUD层 (app/crud/list.py)这里封装所有与shopping_lists表交互的数据库操作。

from sqlalchemy.orm import Session from typing import List, Optional from app.models.shopping_list import ShoppingList from app.schemas.list import ListCreate, ListUpdate def get_list(db: Session, list_id: int, user_id: int): """获取指定用户的特定清单""" return db.query(ShoppingList).filter( ShoppingList.id == list_id, ShoppingList.user_id == user_id ).first() def get_lists_by_user(db: Session, user_id: int, skip: int = 0, limit: int = 100): """获取用户的所有清单(可分页)""" return db.query(ShoppingList).filter( ShoppingList.user_id == user_id ).order_by(ShoppingList.updated_at.desc()).offset(skip).limit(limit).all() def create_list(db: Session, list_in: ListCreate, user_id: int): """为用户创建一个新清单""" db_list = ShoppingList(**list_in.dict(), user_id=user_id) db.add(db_list) db.commit() db.refresh(db_list) return db_list def update_list(db: Session, db_list: ShoppingList, list_in: ListUpdate): """更新清单信息(如标题、状态)""" update_data = list_in.dict(exclude_unset=True) # 只更新提供的字段 for field, value in update_data.items(): setattr(db_list, field, value) db.add(db_list) db.commit() db.refresh(db_list) return db_list def delete_list(db: Session, list_id: int, user_id: int): """删除用户的清单(数据库级联删除会同时删除关联的list_items)""" db_list = get_list(db, list_id, user_id) if db_list: db.delete(db_list) db.commit() return db_list

2. 路由端点 (app/api/endpoints/lists.py)这里处理HTTP请求,调用CRUD函数,并返回响应。

from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from typing import List from app.core.database import get_db from app.api.deps import get_current_user from app.models.user import User from app.schemas.list import ListSchema, ListCreate, ListUpdate from app.crud import list as crud_list router = APIRouter() @router.get("/", response_model=List[ListSchema]) def read_lists( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), skip: int = 0, limit: int = 100 ): """获取当前用户的所有购物清单""" lists = crud_list.get_lists_by_user(db, user_id=current_user.id, skip=skip, limit=limit) return lists @router.post("/", response_model=ListSchema, status_code=status.HTTP_201_CREATED) def create_list( *, db: Session = Depends(get_db), list_in: ListCreate, current_user: User = Depends(get_current_user) ): """为当前用户创建一个新的购物清单""" # 可以在这里添加业务逻辑,比如检查清单标题是否重复等 list_obj = crud_list.create_list(db, list_in=list_in, user_id=current_user.id) return list_obj @router.put("/{list_id}", response_model=ListSchema) def update_list( *, db: Session = Depends(get_db), list_id: int, list_in: ListUpdate, current_user: User = Depends(get_current_user) ): """更新指定购物清单的信息(如标记为完成)""" db_list = crud_list.get_list(db, list_id=list_id, user_id=current_user.id) if not db_list: raise HTTPException(status_code=404, detail="List not found") # 业务逻辑:例如,不能修改已完成的清单? # if db_list.status == "completed": # raise HTTPException(status_code=400, detail="Cannot modify a completed list") list_obj = crud_list.update_list(db, db_list=db_list, list_in=list_in) return list_obj @router.delete("/{list_id}", response_model=ListSchema) def delete_list( *, db: Session = Depends(get_db), list_id: int, current_user: User = Depends(get_current_user) ): """删除指定的购物清单""" db_list = crud_list.delete_list(db, list_id=list_id, user_id=current_user.id) if not db_list: raise HTTPException(status_code=404, detail="List not found") return db_list

3. “智能”推荐的简单实现“智能”听起来高大上,但初期我们可以从简单的规则开始。例如,在获取清单或添加商品时,推荐“高频商品”或“上次购买过的商品”。 我们可以在list_items表的基础上,创建或虚拟出一张purchase_history视图或表。然后在GET /lists/{list_id}/suggestions这样的端点中,实现推荐逻辑:

# 伪代码,在某个服务或CRUD函数中 def get_suggestions_for_user(db: Session, user_id: int, limit: int = 10): """获取对用户的商品购买建议(基于购买频率)""" # 查询该用户最近一段时间内,购买次数最多的商品 suggestion_query = ( db.query( Product, func.count(ListItem.id).label('purchase_count') ) .join(ListItem, ListItem.product_id == Product.id) .join(ShoppingList, ShoppingList.id == ListItem.list_id) .filter( ShoppingList.user_id == user_id, ListItem.is_checked == True, # 只统计已购买项 ShoppingList.updated_at > (datetime.now() - timedelta(days=30)) # 最近30天 ) .group_by(Product.id) .order_by(desc('purchase_count')) .limit(limit) ) return suggestion_query.all()

这个查询会返回用户最近30天内购买频率最高的商品。你可以将这个结果作为“智能建议”展示在创建清单的页面。随着数据积累,可以引入更复杂的算法,比如协同过滤(如果有多用户数据)或基于时间的周期性预测。

实操心得:在实现API时,一个常见的“坑”是N+1查询问题。例如,在返回清单列表时,如果每条清单都要显示其包含的商品项,懒加载可能会导致对list_items表的多次查询。解决方法是使用SQLAlchemy的joinedloadselectinload等加载策略,在查询清单时一次性通过JOIN或子查询加载所有关联的商品项数据,这在crud_list.get_lists_by_user中可以通过.options(joinedload(ShoppingList.items))来实现,能显著提升接口性能。

5. 前端界面构建与交互体验设计

后端API准备好了,我们需要一个界面来使用它。前端的目标是:清晰、快捷、移动端友好。我们将使用Vue 3的组合式API和Composition API来构建。

5.1 项目初始化与状态管理

使用Vite快速搭建Vue项目:

npm create vue@latest akilli-market-frontend # 按照提示选择需要的特性:TypeScript, Vue Router, Pinia cd akilli-market-frontend npm install npm run dev

状态管理:对于购物清单应用,状态并不算极其复杂,但为了更好的可维护性,我们使用Pinia。主要需要管理的状态有:

  • 用户认证状态(Token、用户信息)
  • 当前激活的购物清单
  • 商品分类数据(全局缓存)
  • 临时的新增商品表单状态

创建一个stores目录,里面存放各个Store。例如auth.store.ts管理登录状态,list.store.ts管理清单数据。

5.2 核心页面组件与路由设计

应用主要包含以下几个页面/组件:

  1. 登录/注册页 (Login.vue):处理用户认证,成功后跳转至清单列表页。
  2. 清单列表页 (ListView.vue):展示用户所有清单(按状态分组),提供创建新清单、进入清单详情、删除清单的入口。
  3. 清单详情页 (ListDetail.vue)核心交互页面。展示该清单的所有商品项,按分类分组。每个商品项有复选框(is_checked)、数量、单位。提供添加商品(从搜索或推荐中选择)、修改数量、删除项、清空已购项等功能。
  4. 商品管理页 (Products.vue)(可选):管理全局商品库,供管理员添加/编辑商品和分类。

路由配置 (router/index.ts) 大致如下:

const routes = [ { path: '/', redirect: '/lists' }, { path: '/login', name: 'Login', component: () => import('../views/Login.vue'), meta: { requiresAuth: false } }, { path: '/lists', name: 'Lists', component: () => import('../views/ListView.vue'), meta: { requiresAuth: true } }, { path: '/list/:id', name: 'ListDetail', component: () => import('../views/ListDetail.vue'), meta: { requiresAuth: true } } ]

5.3 清单详情页的关键交互实现

清单详情页是用户花费时间最多的地方,其交互流畅度至关重要。

1. 获取并渲染清单数据

<template> <div v-if="currentList"> <h1>{{ currentList.title }}</h1> <div v-for="category in groupedItems" :key="category.id"> <h3>{{ category.name }}</h3> <ul> <li v-for="item in category.items" :key="item.id"> <input type="checkbox" :checked="item.is_checked" @change="toggleItem(item)"> <span :class="{ 'line-through': item.is_checked }"> {{ item.product.name }} - {{ item.quantity }} {{ item.unit || item.product.default_unit }} </span> <button @click="deleteItem(item)">删除</button> </li> </ul> </div> </div> </template> <script setup lang="ts"> import { ref, computed, onMounted } from 'vue' import { useRoute } from 'vue-router' import { useListStore } from '@/stores/list' import type { ShoppingList } from '@/types' const route = useRoute() const listStore = useListStore() const currentList = ref<ShoppingList | null>(null) onMounted(async () => { const listId = parseInt(route.params.id as string) await listStore.fetchListById(listId) currentList.value = listStore.currentList }) // 使用计算属性按分类分组商品项 const groupedItems = computed(() => { if (!currentList.value?.items) return [] const groups: Record<number, { id: number; name: string; items: any[] }> = {} currentList.value.items.forEach(item => { const catId = item.product.category.id if (!groups[catId]) { groups[catId] = { id: catId, name: item.product.category.name, items: [] } } groups[catId].items.push(item) }) return Object.values(groups) }) const toggleItem = async (item: any) => { await listStore.updateListItem(item.list_id, item.id, { is_checked: !item.is_checked }) // 更新本地数据或重新获取 } const deleteItem = async (item: any) => { if (confirm('确定删除此项?')) { await listStore.deleteListItem(item.list_id, item.id) } } </script>

2. 添加商品功能这需要一个搜索/选择组件。可以是一个模态框(Modal),包含一个搜索输入框(实时搜索商品库)、一个常用商品推荐列表、一个手动输入新商品的表单(如果允许用户添加不在库中的商品)。

<!-- AddItemModal.vue 组件 --> <template> <div class="modal"> <input v-model="searchQuery" @input="onSearch" placeholder="搜索商品..."/> <div v-if="searchResults.length"> <div v-for="product in searchResults" :key="product.id" @click="selectProduct(product)"> {{ product.name }} ({{ product.category.name }}) </div> </div> <div v-else> <h4>常用商品</h4> <div v-for="suggestion in suggestions" :key="suggestion.id" @click="selectProduct(suggestion)"> {{ suggestion.name }} </div> </div> <!-- 手动输入表单 --> <form @submit.prevent="addCustomItem"> <input v-model="customName" placeholder="新商品名"/> <button type="submit">添加</button> </form> </div> </template>

这里的onSearch函数会去调用后端的商品搜索API(例如GET /products?q=牛奶)。suggestions则来自后端的智能推荐API。

3. 实时同步与乐观更新为了更好的用户体验,当用户勾选商品或修改数量时,可以采用乐观更新策略:先立即更新前端UI,然后异步发送API请求。如果请求失败,再回滚UI并提示错误。

// 在Pinia store或组件方法中 async function updateListItemOptimistic(listId, itemId, updates) { // 1. 保存旧状态(用于回滚) const oldItem = findItemInState(listId, itemId) const oldState = { ...oldItem } // 2. 乐观更新:立即修改本地状态 updateLocalItemState(listId, itemId, updates) try { // 3. 发起实际API请求 await api.updateListItem(listId, itemId, updates) } catch (error) { // 4. 失败:回滚本地状态,并提示用户 updateLocalItemState(listId, itemId, oldState) showErrorToast('更新失败,请重试') } }

对于家人共享清单的场景,则需要引入WebSocket或使用轮询(Polling)来实时同步其他成员的修改。对于个人项目,轮询(比如每30秒获取一次清单最新状态)是一个简单可行的起步方案。

注意事项:前端状态管理要特别注意数据的一致性。例如,当在清单详情页删除了一个商品项,这个变化也应该及时反映在Pinia中存储的清单列表里,避免用户返回列表页时看到陈旧的数据。确保所有对状态的修改都通过Store的Action进行,保持单一数据源。

6. 部署上线与持续维护

开发完成,我们需要让应用在服务器上跑起来,并能被稳定访问。

6.1 使用Docker Compose一键部署

这是最推荐的方式,它能将前端、后端、数据库打包在一起。

1. 后端 Dockerfile (backend/Dockerfile)

FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

2. 前端 Dockerfile (frontend/Dockerfile)我们需要构建静态文件,并用一个轻量级Web服务器(如Nginx)来服务它们。

# 构建阶段 FROM node:18-alpine as build-stage WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # 生产阶段 FROM nginx:alpine COPY --from=build-stage /app/dist /usr/share/nginx/html # 可以复制自定义的nginx配置 # COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]

3. Docker Compose 配置文件 (docker-compose.yml)

version: '3.8' services: backend: build: ./backend ports: - "8000:8000" environment: - DATABASE_URL=sqlite:///./app.db # 或使用 PostgreSQL URL volumes: - ./backend_data:/app/data # 持久化SQLite数据库文件 # depends_on: # - db # 如果使用PostgreSQL frontend: build: ./frontend ports: - "8080:80" depends_on: - backend # 如果使用PostgreSQL,取消注释以下部分 # db: # image: postgres:15 # environment: # POSTGRES_USER: user # POSTGRES_PASSWORD: password # POSTGRES_DB: marketlist # volumes: # - postgres_data:/var/lib/postgresql/data # volumes: # postgres_data:

在服务器上,只需安装好Docker和Docker Compose,将代码和这个docker-compose.yml文件上传,然后运行docker-compose up -d,整个应用就启动起来了。前端通过8080端口访问,后端API在8000端口。

6.2 域名、HTTPS与反向代理

直接通过IP和端口访问不友好也不安全。我们需要:

  1. 购买域名并解析:将域名(如list.yourdomain.com)的A记录指向你的服务器IP。
  2. 安装Nginx作为反向代理:在服务器上安装Nginx,配置它将80/443端口的请求,根据域名转发到对应的Docker服务。
    # /etc/nginx/sites-available/marketlist server { listen 80; server_name list.yourdomain.com; # 重定向到HTTPS return 301 https://$server_name$request_uri; } server { listen 443 ssl http2; server_name list.yourdomain.com; ssl_certificate /path/to/your/fullchain.pem; ssl_certificate_key /path/to/your/privkey.pem; # ... 其他SSL优化配置 location / { proxy_pass http://localhost:8080; # 前端服务 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /api { proxy_pass http://localhost:8000; # 后端API服务 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }
  3. 获取SSL证书:使用Let‘s Encrypt的Certbot工具,可以免费获取并自动续签SSL证书,实现HTTPS加密。命令类似:sudo certbot --nginx -d list.yourdomain.com

6.3 数据备份与基础监控

对于个人项目,数据备份至关重要,尤其是你的购物历史。

  • 数据库备份:如果使用SQLite,备份就是拷贝那个.db文件。可以写一个简单的cron定时任务,每天将数据库文件压缩并备份到另一个位置或云存储。
    # 示例cron任务 (crontab -e) 0 2 * * * cd /path/to/your/app && tar -czf /backup/marketlist-$(date +\%Y\%m\%d).tar.gz data/app.db
  • 日志查看:使用Docker Compose的日志命令查看服务运行状态:docker-compose logs -f backend。将重要的错误日志收集起来,有助于排查问题。
  • 健康检查:可以在后端API添加一个/health端点,返回简单的状态(如数据库连接是否正常)。然后使用UptimeRobot之类的免费服务定期访问这个端点,如果失败就发送邮件或短信通知你。

6.4 后续迭代方向

应用上线后,可以根据反馈持续迭代:

  1. 移动端体验优化:使用响应式设计或开发PWA(渐进式Web应用),让它在手机上有类似原生App的体验,可以添加到主屏幕。
  2. 智能推荐升级:收集更多购买数据后,可以尝试更复杂的推荐算法,比如基于物品的协同过滤(“买了面包的人通常也买牛奶”)。
  3. 语音输入:集成语音识别API,支持“嘿,把鸡蛋加到购物清单里”这样的语音指令。
  4. 与智能家居联动(极客玩法):通过Home Assistant等平台,当冰箱门传感器被触发或食物存量摄像头识别到牛奶快喝完时,自动向你的API发送请求,将商品加入清单。

构建这样一个项目,从设计到部署,不仅是一个编程练习,更是一次完整的产品思维和实践的锻炼。它让你思考用户真实的需求,做出合理的技术权衡,并最终交付一个能为自己或他人创造真实价值的工具。这个过程里踩的每一个“坑”,解决的每一个问题,都是宝贵的经验。

http://www.jsqmd.com/news/815977/

相关文章:

  • 别急着写Verilog!用Logisim手搓一个运动码表,可能是你理解数字系统最好的方式
  • 如何用magnetW一站式聚合20+磁力搜索源快速找到高质量资源?
  • 【数据分析】由分数导数齐纳模型控制的基底隔离基准建筑模拟,方位轴承附matlab代码
  • 告别“龙虾”部署烦恼:聚焦信创适配龙虾智能体的企业级智能体平台深度解析 - 品牌2025
  • 不只是改密码:深入Kali Linux单用户模式,解锁系统维护与故障排查新姿势
  • Java 4——方法的重写 多态
  • 避坑指南:Python爬取立创商城LCSC价格时,如何应对动态加载与反爬?
  • MAA:明日方舟游戏日常任务的自动化解放方案
  • 企业如何利用Taotoken统一管理多团队的AI模型用量与成本
  • 企业内如何利用Taotoken实现API Key的统一管理与审计
  • 3步实现Illustrator批量替换自动化,设计效率提升10倍
  • Chapter 03:Rules 进阶 - 企业级规则配置实战
  • 告别硬件:用Keil5逻辑分析仪‘看’GD32F305的GPIO与串口数据
  • 开源监控仪表盘架构解析:从数据源集成到可视化实践
  • 忠告:专业测试人员,尽量不要碰国内Y测与Z测平台
  • ElevenLabs语音情感引擎失效真相:当“庄重感”参数设为0.82时,脑电α波响应率骤降41%(fNIRS实测报告)
  • 在OpenClaw中配置Taotoken作为Agent任务的模型提供商
  • [Dify 实战] 将私有 LLM 模型接入 Dify:从本地推理到企业级 AI 平台
  • 2026 年 5 月武汉闲置奢侈品回收横向测评,合扬老店脱颖而出 - 奢侈品回收测评
  • 新手也能搞定的CREE SiC MOSFET驱动板:从原理图到四层PCB的保姆级设计流程
  • 告别静电损伤!手把手教你为单片机/树莓派GPIO口设计低成本ESD防护电路
  • 独立开发者如何借助Taotoken Token Plan套餐优化项目预算
  • Cursor Pro功能无限试用:开源自动化工具原理与实战部署指南
  • 终极GTA圣安地列斯存档编辑器:跨平台游戏修改完全指南
  • 人工智能通识课:机器学习之强化学习
  • Moltbook MCP Server:零代码将AI Agent接入ChatGPT/Claude的远程工具平台
  • Unity开发效率翻倍!用Hot Reload插件告别反复重启,实测2023.2版本可用
  • Taotoken用量看板与账单明细带来的成本管理清晰度
  • Taotoken的按Token计费模式让开发测试阶段的成本更加清晰
  • 【研报 A124】太空算力重构算力供给与产业格局:AI奔赴星辰大海