Compare commits

...

1 Commits

Author SHA1 Message Date
cafa1d008a 第一次推送 2025-07-25 14:18:07 +08:00
26 changed files with 4583 additions and 0 deletions

View File

@ -0,0 +1,149 @@
# “食话食说” App 原型功能说明文档
**文档目的:** 本文档旨在为技术开发团队提供最终版的页面功能说明、交互反馈及布局规范,作为前端开发的最终参考依据。
**版本:** 2.1 (补充 Onboarding 复杂交互)
---
## 页面流程总览
应用启动后,用户将经历以下流程:
`启动页` -> `登录/注册页` -> `用户引导流程 (Onboarding)` -> `应用主页`
用户在登录页也可以跳转至 `用户协议/隐私政策页`
---
## 1. 启动页 (`SplashView.vue`)
- **页面名称:** Splash Screen / 启动页
- **文件路径:** `shihuashishuo-ui/src/views/SplashView.vue`
### 1.1. 功能描述
- **核心功能:** 作为应用的入口,展示品牌信息,并在短暂延迟后自动导航到下一页面。
- **显示元素:**
- 应用 Logo: 💬
- 应用名称: "食话食说"
- 应用口号: "食品真相, 实话实说"
- 版本号: "Version 1.0.0"
### 1.2. 交互反馈
- **自动跳转:** 页面加载完成 `2.5` 秒后,系统自动跳转到 **登录/注册页** (`/login`)。
- **用户交互:** 此页面无任何用户可操作的交互元素。
### 1.3. 布局与自适应效果
- **布局:** 采用 Flexbox 垂直居中布局,确保品牌信息在各种屏幕尺寸上都能完美居中。
- **背景色:** 页面背景色为淡绿色 (`#f0fdf4`)。
- **品牌色:** 应用名称字体颜色为品牌绿色 (`#22c55e`)。
---
## 2. 登录/注册页 (`LoginView.vue`)
- **页面名称:** Login / Register Page / 登录注册页
- **文件路径:** `shihuashishuo-ui/src/views/LoginView.vue`
### 2.1. 功能描述
- **核心功能:** 提供用户注册和登录的入口。
- **显示元素:**
- 主标题: "食话食说 Talk of Food"
- 副标题: "守护您和家人的每一餐"
- 手机号输入框 (带 📱 图标)
- 验证码输入框 (带 ✉️ 图标)
- "获取验证码" 按钮
- "登录 / 注册" 主按钮
- "或" 分割线
- "微信一键登录" 按钮 (带 🟢 图标)
- 政策同意复选框及链接
### 2.2. 交互反馈
- **微信一键登录:** 点击后,跳转到 **用户引导流程页** (`/onboarding`)。
- **政策链接:** 点击 `用户协议``隐私政策` 链接,页面跳转到 **政策详情页** (`/policy`)。
### 2.3. 布局与自适应效果
- **整体布局:**
- 采用垂直 Flex 布局,内容水平居中。
- 页面整体可垂直滚动,以完美适配矮屏幕(如 Nest Hub确保所有内容可见。
- **核心间距:**
- 页面左右留白为 **40px**
- 顶部欢迎语区域距离屏幕顶部的距离为 **100px** (`padding-top`)。
- 欢迎语区域与下方表单区域的间距为 **40px** (`margin-bottom`)。
- 两个输入框之间、验证码输入框与“登录/注册”按钮之间的垂直间距,统一为 **20px**
- **元素尺寸与样式:**
- 手机号和验证码输入框的垂直内边距为 **12px**,与下方“登录/注册”和“微信登录”按钮的垂直内边距保持一致,视觉上高度统一。
- “获取验证码”按钮字体大小为 **14px**,并设置了 `white-space: nowrap` 以防止在窄屏幕上换行。
- “或”分割线的字体大小为 **12px**,其上下外边距均为 **10px**,布局紧凑。
- **响应式修复:**
- 输入框元素已添加 `min-width: 0` 样式,从根本上解决了在极窄屏幕(如 Samsung Galaxy S8+)上,“获取验证码”按钮被挤出边界的 bug。
---
## 3. 用户引导流程 (`OnboardingView.vue` & `OnboardingStep.vue`)
- **页面名称:** Onboarding / 用户偏好设置
- **文件路径:**
- `shihuashishuo-ui/src/views/OnboardingView.vue` (流程控制器)
- `shihuashishuo-ui/src/views/OnboardingStep.vue` (可复用步骤组件)
### 3.1. 功能描述
- **核心功能:** 在用户首次登录后,通过一个 4 步骤的流程收集用户的健康和饮食偏好。
- **流程步骤:**
1. **关注点 (多选):** "您的首要关注点是?"
2. **过敏原 (多选):** "有需要特别避开的过敏原吗?"
3. **基础疾病 (多选):** "是否有需要关注的基础疾病?"
4. **饮食偏好 (多选):** "有没有特别的饮食偏好或忌口?"
### 3.2. 交互反馈 (详细)
- **通用交互:**
- `跳过` 按钮: 在任何步骤点击,都会立即退出引导流程,并跳转到应用主页 (`/`)。
- `下一步` 按钮: 保存当前步骤的选择,并进入下一个步骤。
- `< 返回` 按钮 (从第二步开始): 返回上一个步骤。
- `完成` 按钮: 在最后一步,`下一步` 按钮变为 `完成,开启健康生活`。点击后,记录所有偏好,并跳转到应用主页 (`/`)。
- **步骤 1 (独立实现):**
- **交互:** 用户可点击多个选项按钮进行选择。被选中的按钮会变为蓝色高亮状态。
- **引导:** 第一个选项 "食品安全" 在未被选中时,会有一个品牌绿色的高亮边框,以引导用户开始选择。
- **步骤 2, 3, 4 (复用 `OnboardingStep.vue` 组件):**
- **搜索功能:** 用户可以在顶部的搜索框中输入关键词,下方的选项列表会实时过滤,匹配的选项会以品牌绿色的高亮边框突出显示。
- **Tag 式选择反馈:** 所有被选中的选项,都会以蓝色的 Tag 标签形式显示在搜索框下方。用户可以通过点击 Tag 上的 "×" 来快速取消选择。
- **"无" 选项互斥逻辑:** 如果选择了 "无" 选项,其他所有已选的 Tag 会被自动清除。反之,如果选择了任何其他选项,"无" 选项的 Tag 会被自动清除。
- **自定义添加:** 如果用户在搜索框中输入的文本,在现有选项中找不到完全匹配,系统会自动显示一个 `+ 添加自定义:“...”` 的按钮。点击该按钮,会将当前输入的文本作为一个新的 Tag 添加到已选列表中。
### 3.3. 布局与自适应效果
- **布局:**
- 所有步骤的选项都采用两列网格布局 (`grid-template-columns: repeat(2, 1fr)`),在所有屏幕尺寸上都能保持一致的两列表单结构。
- 步骤 2, 3, 4 在标题下方新增了一个全宽的搜索框。
- **按钮样式:** 选项按钮为更显方的圆角矩形 (`border-radius: 8px`)。
---
## 4. 政策详情页 (`PolicyView.vue`)
- **页面名称:** Policy Page / 用户协议或隐私政策页
- **文件路径:** `shihuashishuo-ui/src/views/PolicyView.vue`
### 4.1. 功能描述
- **核心功能:** 显示静态的法律条款文本。
- **显示元素:**
- 带返回按钮的顶部导航栏
- 页面标题 (如: "用户协议")
- 条款内容
### 4.2. 交互反馈
- **返回:** 点击左上角的 `< 返回` 按钮,页面会返回到之前的页面。
### 4.3. 布局与自适应效果
- **导航栏:** 采用 Flexbox 布局,标题居中,返回按钮居左。

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

297
shihuashishuo-api/crud.py Normal file
View File

@ -0,0 +1,297 @@
from sqlalchemy.orm import Session, joinedload, subqueryload
from sqlalchemy import func
import models, schemas
from security import get_password_hash, verify_password
from fastapi import HTTPException
from typing import List
# --- User CRUD ---
def get_user_by_phone_number(db: Session, phone_number: str):
return db.query(models.User).filter(models.User.phone_number == phone_number).first()
def create_user(db: Session, user: schemas.UserCreate):
hashed_password = None
if user.password:
hashed_password = get_password_hash(user.password)
db_user = models.User(phone_number=user.phone_number, hashed_password=hashed_password)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
def authenticate_user(db: Session, phone_number: str, password: str):
user = get_user_by_phone_number(db, phone_number)
if not user or not user.hashed_password:
return False
if not verify_password(password, user.hashed_password):
return False
return user
def set_user_password(db: Session, user: models.User, password_set: schemas.PasswordSet):
user.hashed_password = get_password_hash(password_set.new_password)
db.add(user)
db.commit()
db.refresh(user)
return user
# --- Family Member & Health Profile CRUD ---
def create_family_member(db: Session, member: schemas.FamilyMemberCreate, owner_id: int):
db_member = models.FamilyMember(**member.dict(), owner_id=owner_id)
db.add(db_member)
db.commit()
db.refresh(db_member)
return db_member
def get_family_members_by_user(db: Session, user_id: int):
return db.query(models.FamilyMember).filter(models.FamilyMember.owner_id == user_id).all()
def get_family_member(db: Session, member_id: int, user_id: int):
return db.query(models.FamilyMember).filter(models.FamilyMember.id == member_id, models.FamilyMember.owner_id == user_id).first()
def update_health_profile(db: Session, member_id: int, profiles: List[schemas.HealthProfileCreate]):
db.query(models.HealthProfile).filter(models.HealthProfile.member_id == member_id).delete()
for profile in profiles:
db_profile = models.HealthProfile(
member_id=member_id,
profile_type=profile.profile_type,
value=profile.value
)
db.add(db_profile)
db.commit()
return db.query(models.HealthProfile).filter(models.HealthProfile.member_id == member_id).all()
# --- User Preference CRUD ---
def create_user_preferences(db: Session, user_id: int, preferences: schemas.OnboardingPreferences):
db.query(models.UserPreference).filter(models.UserPreference.user_id == user_id).delete()
for category, values in preferences.dict().items():
for value in values:
db_preference = models.UserPreference(
user_id=user_id,
category=category,
value=value
)
db.add(db_preference)
db.commit()
# --- Food, Ingredient, Additive CRUD ---
def get_food_by_barcode(db: Session, barcode: str):
return db.query(models.Food).options(joinedload(models.Food.ingredients), joinedload(models.Food.additives)).filter(models.Food.barcode == barcode).first()
def create_food(db: Session, food: schemas.FoodCreate):
db_food_data = food.dict(exclude={'ingredient_ids', 'additive_ids'})
db_food = models.Food(**db_food_data)
if food.ingredient_ids:
ingredients = db.query(models.Ingredient).filter(models.Ingredient.id.in_(food.ingredient_ids)).all()
db_food.ingredients.extend(ingredients)
if food.additive_ids:
additives = db.query(models.Additive).filter(models.Additive.id.in_(food.additive_ids)).all()
db_food.additives.extend(additives)
db.add(db_food)
db.commit()
db.refresh(db_food)
return db_food
# --- Article CRUD ---
def create_article(db: Session, article: schemas.ArticleCreate):
db_article = models.Article(**article.dict())
db.add(db_article)
db.commit()
db.refresh(db_article)
return db_article
def get_articles_by_category(db: Session, category: str, skip: int = 0, limit: int = 10):
return db.query(models.Article).filter(models.Article.category == category).offset(skip).limit(limit).all()
def get_article(db: Session, article_id: int):
return db.query(models.Article).filter(models.Article.id == article_id).first()
# --- Recipe CRUD ---
def create_recipe(db: Session, recipe: schemas.RecipeCreate, author_id: int):
recipe_data = recipe.dict(exclude={'ingredients'})
db_recipe = models.Recipe(**recipe_data, author_id=author_id)
for ingredient_data in recipe.ingredients:
db_recipe_ingredient = models.RecipeIngredient(
ingredient_id=ingredient_data.ingredient_id,
quantity=ingredient_data.quantity,
unit=ingredient_data.unit
)
db_recipe.ingredients.append(db_recipe_ingredient)
db.add(db_recipe)
db.commit()
db.refresh(db_recipe)
return db_recipe
def get_recipe_details(db: Session, recipe_id: int):
return db.query(models.Recipe).options(joinedload(models.Recipe.ingredients).joinedload(models.RecipeIngredient.ingredient)).filter(models.Recipe.id == recipe_id).first()
# --- Community (Post, Comment, Favorite) CRUD ---
def create_post(db: Session, post: schemas.PostCreate, author_id: int):
db_post = models.Post(**post.dict(), author_id=author_id)
db.add(db_post)
db.commit()
db.refresh(db_post)
return db_post
def get_posts_by_food(db: Session, food_id: int, skip: int = 0, limit: int = 10):
posts = db.query(models.Post).filter(models.Post.food_id == food_id).order_by(models.Post.created_at.desc()).offset(skip).limit(limit).options(subqueryload(models.Post.comments), subqueryload(models.Post.favorites)).all()
for post in posts:
post.favorites_count = len(post.favorites)
return posts
def create_comment(db: Session, comment: schemas.CommentCreate, author_id: int):
db_comment = models.Comment(**comment.dict(), author_id=author_id)
db.add(db_comment)
db.commit()
db.refresh(db_comment)
return db_comment
def toggle_favorite(db: Session, favorite: schemas.FavoriteCreate, user_id: int):
db_favorite = db.query(models.Favorite).filter(
models.Favorite.entity_type == favorite.entity_type,
models.Favorite.entity_id == favorite.entity_id,
models.Favorite.user_id == user_id
).first()
if db_favorite:
db.delete(db_favorite)
db.commit()
return {"favorited": False}
else:
db_favorite = models.Favorite(**favorite.dict(), user_id=user_id)
db.add(db_favorite)
db.commit()
return {"favorited": True}
# --- Topic CRUD ---
def get_topics(db: Session, skip: int = 0, limit: int = 100):
return db.query(models.Topic).offset(skip).limit(limit).all()
def get_posts_by_topic(db: Session, topic_id: int, skip: int = 0, limit: int = 10):
posts = db.query(models.Post).filter(models.Post.topic_id == topic_id).order_by(models.Post.created_at.desc()).offset(skip).limit(limit).options(subqueryload(models.Post.comments), subqueryload(models.Post.favorites)).all()
for post in posts:
post.favorites_count = len(post.favorites)
return posts
# --- E-commerce CRUD ---
def get_products(db: Session, skip: int = 0, limit: int = 10):
return db.query(models.Product).offset(skip).limit(limit).all()
def get_cart_items(db: Session, user_id: int):
return db.query(models.CartItem).filter(models.CartItem.user_id == user_id).all()
def add_to_cart(db: Session, item: schemas.CartItemCreate, user_id: int):
# Check if the item is already in the cart
db_item = db.query(models.CartItem).filter(models.CartItem.product_id == item.product_id, models.CartItem.user_id == user_id).first()
if db_item:
db_item.quantity += item.quantity
else:
db_item = models.CartItem(**item.dict(), user_id=user_id)
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item
def create_order(db: Session, user_id: int):
cart_items = get_cart_items(db, user_id)
if not cart_items:
raise HTTPException(status_code=400, detail="Cart is empty")
total_price = sum(item.product.price * item.quantity for item in cart_items)
db_order = models.Order(user_id=user_id, total_price=total_price)
db.add(db_order)
db.commit()
db.refresh(db_order)
for item in cart_items:
db_order_item = models.OrderItem(
order_id=db_order.id,
product_id=item.product_id,
quantity=item.quantity,
price=item.product.price
)
db.add(db_order_item)
db.delete(item) # Clear the cart
db.commit()
db.refresh(db_order)
return db_order
# --- Search History CRUD ---
def create_search_history(db: Session, search: schemas.SearchHistoryCreate, user_id: int | None = None):
db_search = models.SearchHistory(
term=search.term,
user_id=user_id
)
db.add(db_search)
db.commit()
db.refresh(db_search)
return db_search
def get_search_history_by_user(db: Session, user_id: int, skip: int = 0, limit: int = 10):
return db.query(models.SearchHistory).filter(models.SearchHistory.user_id == user_id).order_by(models.SearchHistory.created_at.desc()).offset(skip).limit(limit).all()
def get_popular_searches(db: Session, limit: int = 10):
return db.query(models.SearchHistory.term, func.count(models.SearchHistory.term).label('count')).group_by(models.SearchHistory.term).order_by(func.count(models.SearchHistory.term).desc()).limit(limit).all()
def get_or_create_oauth_user(db: Session, provider: str, openid: str):
# Check if the OAuth account already exists
oauth_account = db.query(models.OAuthAccount).filter(
models.OAuthAccount.provider == provider,
models.OAuthAccount.openid == openid
).first()
if oauth_account:
return oauth_account.user, False
# If not, create a new user and the OAuth account
new_user = models.User()
db.add(new_user)
db.commit()
db.refresh(new_user)
new_oauth_account = models.OAuthAccount(
provider=provider,
openid=openid,
user_id=new_user.id
)
db.add(new_oauth_account)
db.commit()
return new_user, True
def suggest_recipes_by_ingredients(db: Session, ingredient_ids: List[int], limit: int = 10):
if not ingredient_ids:
return []
# Find recipes that contain the most of the given ingredients
# This is a simplified logic. A real-world scenario might involve more complex ranking.
recipes_with_match_count = db.query(
models.Recipe,
func.count(models.RecipeIngredient.ingredient_id).label('match_count')
).join(models.RecipeIngredient).filter(
models.RecipeIngredient.ingredient_id.in_(ingredient_ids)
).group_by(models.Recipe.id).order_by(
func.count(models.RecipeIngredient.ingredient_id).desc()
).limit(limit).all()
# The result is a list of tuples (Recipe, match_count). We only need the Recipe object.
return [recipe for recipe, match_count in recipes_with_match_count]
def create_submitted_food(db: Session, food: schemas.SubmittedFoodCreate, user_id: int):
db_food = models.SubmittedFood(
**food.dict(),
submitted_by_id=user_id
)
db.add(db_food)
db.commit()
db.refresh(db_food)
return db_food

View File

@ -0,0 +1,12 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
SQLALCHEMY_DATABASE_URL = "sqlite:///./shihuashishuo.db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

423
shihuashishuo-api/main.py Normal file
View File

@ -0,0 +1,423 @@
from fastapi import FastAPI, Depends, HTTPException, APIRouter
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
import crud, models, schemas, security
from database import SessionLocal, engine
from typing import Optional, List
from datetime import timedelta
from security import create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES, oauth2_scheme, SECRET_KEY, ALGORITHM
from jose import JWTError, jwt
models.Base.metadata.create_all(bind=engine)
app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
# CORS Middleware
origins = [
"http://localhost:5173",
"http://12-7.0.0.1:5173",
"http://localhost:5174",
"http://127.0.0.1:5174",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Dependency
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
credentials_exception = HTTPException(
status_code=401,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
subject = payload.get("sub")
if subject is None:
raise credentials_exception
# Try to convert sub to int for user_id, if it fails, assume it's a phone number
try:
user_id = int(subject)
user = db.query(models.User).filter(models.User.id == user_id).first()
except ValueError:
user = crud.get_user_by_phone_number(db, phone_number=subject)
except JWTError:
raise credentials_exception
if user is None:
raise credentials_exception
return user
async def get_current_user_optional(token: Optional[str] = Depends(oauth2_scheme), db: Session = Depends(get_db)):
if token is None:
return None
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
subject = payload.get("sub")
if subject is None:
return None
try:
user_id = int(subject)
user = db.query(models.User).filter(models.User.id == user_id).first()
except ValueError:
user = crud.get_user_by_phone_number(db, phone_number=subject)
return user
except (JWTError, AttributeError):
return None
# --- Auth Router ---
auth_router = APIRouter(
prefix="/api/v1/auth",
tags=["auth"],
)
@auth_router.post("/send-verification-code")
def send_verification_code(user: schemas.UserCreate):
if not user.phone_number or len(user.phone_number) != 11:
raise HTTPException(status_code=400, detail="Valid 11-digit phone number is required.")
print(f"Simulating sending verification code to {user.phone_number}")
return {"message": "Verification code sent successfully.", "code": "111111"}
@auth_router.post("/login", response_model=schemas.Token)
def login(form_data: schemas.UserCodeLogin, db: Session = Depends(get_db)):
if form_data.code != "111111":
raise HTTPException(status_code=400, detail="Invalid verification code.")
user = crud.get_user_by_phone_number(db, phone_number=form_data.phone_number)
is_new_user = False
if not user:
user = crud.create_user(db=db, user=schemas.UserCreate(phone_number=form_data.phone_number))
is_new_user = True
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.phone_number}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer", "is_new_user": is_new_user}
@auth_router.post("/login/password", response_model=schemas.Token)
def login_with_password(form_data: schemas.UserPasswordLogin, db: Session = Depends(get_db)):
user = crud.authenticate_user(db, phone_number=form_data.phone_number, password=form_data.password)
if not user:
raise HTTPException(
status_code=401,
detail="Incorrect phone number or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.phone_number}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer", "is_new_user": False}
@auth_router.put("/users/me/password", response_model=schemas.User)
async def set_password_for_current_user(
password_set: schemas.PasswordSet,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
return crud.set_user_password(db=db, user=current_user, password_set=password_set)
@auth_router.post("/wechat/callback", response_model=schemas.Token)
def wechat_login_callback(body: dict, db: Session = Depends(get_db)):
code = body.get("code")
if not code:
raise HTTPException(status_code=400, detail="Code is required")
user, is_new_user = security.authenticate_wechat_user(db, code=code)
if not user:
raise HTTPException(
status_code=401,
detail="Could not validate WeChat credentials",
)
# For OAuth users, we might not have a phone number initially
# The subject of the token should be something unique and stable.
# Using user.id is a good choice.
subject = str(user.id)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": subject}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer", "is_new_user": is_new_user}
app.include_router(auth_router)
# --- Family & Health Profile Router ---
family_router = APIRouter(
prefix="/api/v1/family",
tags=["family"],
dependencies=[Depends(get_current_user)]
)
@family_router.post("/members", response_model=schemas.FamilyMember)
def create_family_member(
member: schemas.FamilyMemberCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
return crud.create_family_member(db=db, member=member, owner_id=current_user.id)
@family_router.get("/members", response_model=List[schemas.FamilyMember])
def get_family_members(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
return crud.get_family_members_by_user(db=db, user_id=current_user.id)
@family_router.put("/members/{member_id}/health-profile", response_model=List[schemas.HealthProfile])
def update_member_health_profile(
member_id: int,
profiles: List[schemas.HealthProfileCreate],
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
db_member = crud.get_family_member(db, member_id=member_id, user_id=current_user.id)
if db_member is None:
raise HTTPException(status_code=404, detail="Family member not found")
return crud.update_health_profile(db=db, member_id=member_id, profiles=profiles)
app.include_router(family_router)
# --- Food Router ---
food_router = APIRouter(
prefix="/api/v1/foods",
tags=["foods"]
)
@food_router.get("/{barcode}/details", response_model=schemas.FoodDetails)
def read_food_details(
barcode: str,
db: Session = Depends(get_db),
current_user: Optional[models.User] = Depends(get_current_user_optional)
):
db_food = crud.get_food_by_barcode(db, barcode=barcode)
if db_food is None:
raise HTTPException(status_code=404, detail="Food not found")
return db_food
@food_router.post("/submit", response_model=schemas.SubmittedFood, dependencies=[Depends(get_current_user)])
def submit_new_food(
food: schemas.SubmittedFoodCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
return crud.create_submitted_food(db=db, food=food, user_id=current_user.id)
app.include_router(food_router)
# --- Content Center Router ---
content_router = APIRouter(
prefix="/api/v1/articles",
tags=["articles"]
)
@content_router.post("/", response_model=schemas.Article)
def create_article(article: schemas.ArticleCreate, db: Session = Depends(get_db)):
return crud.create_article(db=db, article=article)
@content_router.get("/", response_model=List[schemas.Article])
def read_articles_by_category(category: str, skip: int = 0, limit: int = 10, db: Session = Depends(get_db)):
return crud.get_articles_by_category(db=db, category=category, skip=skip, limit=limit)
@content_router.get("/{article_id}", response_model=schemas.Article)
def read_article(article_id: int, db: Session = Depends(get_db)):
db_article = crud.get_article(db, article_id=article_id)
if db_article is None:
raise HTTPException(status_code=404, detail="Article not found")
return db_article
app.include_router(content_router)
# --- Recipe Router ---
recipe_router = APIRouter(
prefix="/api/v1/recipes",
tags=["recipes"]
)
@recipe_router.post("/", response_model=schemas.Recipe, dependencies=[Depends(get_current_user)])
def create_recipe(
recipe: schemas.RecipeCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
return crud.create_recipe(db=db, recipe=recipe, author_id=current_user.id)
@recipe_router.get("/{recipe_id}", response_model=schemas.Recipe)
def get_recipe(recipe_id: int, db: Session = Depends(get_db)):
db_recipe = crud.get_recipe_details(db, recipe_id=recipe_id)
if db_recipe is None:
raise HTTPException(status_code=404, detail="Recipe not found")
return db_recipe
@recipe_router.post("/suggest", response_model=List[schemas.Recipe], dependencies=[Depends(get_current_user)])
def suggest_recipes(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
return db.query(models.Recipe).order_by(models.Recipe.id.desc()).limit(5).all()
@recipe_router.post("/suggest-by-ingredients", response_model=List[schemas.Recipe])
def suggest_recipes_by_ingredients_api(
ingredient_ids: List[int],
db: Session = Depends(get_db)
):
return crud.suggest_recipes_by_ingredients(db=db, ingredient_ids=ingredient_ids)
app.include_router(recipe_router)
# --- Community Router ---
community_router = APIRouter(
prefix="/api/v1",
tags=["community"]
)
@community_router.post("/posts", response_model=schemas.Post, dependencies=[Depends(get_current_user)])
def create_post(
post: schemas.PostCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
return crud.create_post(db=db, post=post, author_id=current_user.id)
@community_router.get("/foods/{food_id}/posts", response_model=List[schemas.Post])
def get_posts_for_food(food_id: int, skip: int = 0, limit: int = 10, db: Session = Depends(get_db)):
return crud.get_posts_by_food(db=db, food_id=food_id, skip=skip, limit=limit)
@community_router.post("/posts/{post_id}/comments", response_model=schemas.Comment, dependencies=[Depends(get_current_user)])
def create_comment_for_post(
post_id: int,
comment: schemas.CommentBase,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
comment_create = schemas.CommentCreate(content=comment.content, post_id=post_id)
return crud.create_comment(db=db, comment=comment_create, author_id=current_user.id)
@community_router.get("/topics", response_model=List[schemas.Topic])
def get_topics(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
return crud.get_topics(db=db, skip=skip, limit=limit)
@community_router.get("/topics/{topic_id}/posts", response_model=List[schemas.Post])
def get_posts_by_topic(topic_id: int, skip: int = 0, limit: int = 10, db: Session = Depends(get_db)):
return crud.get_posts_by_topic(db=db, topic_id=topic_id, skip=skip, limit=limit)
@community_router.get("/topics", response_model=List[schemas.Topic])
def get_topics(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
return crud.get_topics(db=db, skip=skip, limit=limit)
@community_router.get("/topics/{topic_id}/posts", response_model=List[schemas.Post])
def get_posts_by_topic(topic_id: int, skip: int = 0, limit: int = 10, db: Session = Depends(get_db)):
return crud.get_posts_by_topic(db=db, topic_id=topic_id, skip=skip, limit=limit)
@community_router.post("/posts/{post_id}/like", dependencies=[Depends(get_current_user)])
def like_post(
post_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
return crud.toggle_like(db=db, post_id=post_id, user_id=current_user.id)
app.include_router(community_router)
# --- E-commerce Router ---
mall_router = APIRouter(
prefix="/api/v1/mall",
tags=["mall"]
)
@mall_router.get("/products", response_model=List[schemas.Product])
def get_products(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)):
return crud.get_products(db=db, skip=skip, limit=limit)
@mall_router.get("/cart", response_model=List[schemas.CartItem], dependencies=[Depends(get_current_user)])
def get_cart(db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user)):
return crud.get_cart_items(db=db, user_id=current_user.id)
@mall_router.post("/cart/items", response_model=schemas.CartItem, dependencies=[Depends(get_current_user)])
def add_item_to_cart(item: schemas.CartItemCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user)):
return crud.add_to_cart(db=db, item=item, user_id=current_user.id)
@mall_router.post("/orders", response_model=schemas.Order, dependencies=[Depends(get_current_user)])
def create_order(db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user)):
return crud.create_order(db=db, user_id=current_user.id)
app.include_router(mall_router)
# --- Other Routers ---
@app.get("/")
def read_root():
return {"message": "Welcome to 食话食说 API"}
@app.get("/api/v1/users/check-phone-existence/")
def check_phone_existence(phone_number: str, db: Session = Depends(get_db)):
user = crud.get_user_by_phone_number(db, phone_number=phone_number)
return {"exists": user is not None}
@app.post("/api/v1/food/", response_model=schemas.Food)
def create_food_entry(food: schemas.FoodCreate, db: Session = Depends(get_db)):
db_food = crud.get_food_by_barcode(db, barcode=food.barcode)
if db_food:
raise HTTPException(status_code=400, detail="Barcode already registered")
return crud.create_food(db=db, food=food)
@app.get("/api/v1/food/{barcode}", response_model=schemas.Food)
def read_food_by_barcode(barcode: str, db: Session = Depends(get_db)):
db_food = crud.get_food_by_barcode(db, barcode=barcode)
if db_food is None:
raise HTTPException(status_code=404, detail="Food not found")
return db_food
@app.post("/api/v1/users/me/preferences", status_code=204)
async def update_user_preferences(
preferences: schemas.OnboardingPreferences,
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db)
):
crud.create_user_preferences(db=db, user_id=current_user.id, preferences=preferences)
return
@app.post("/api/v1/search/history", response_model=schemas.SearchHistory)
async def create_search_history_entry(
search: schemas.SearchHistoryCreate,
current_user: Optional[models.User] = Depends(get_current_user_optional),
db: Session = Depends(get_db)
):
user_id = current_user.id if current_user else None
return crud.create_search_history(db=db, search=search, user_id=user_id)
@app.get("/api/v1/search/history", response_model=List[schemas.SearchHistory])
async def get_user_search_history(
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 10
):
return crud.get_search_history_by_user(db=db, user_id=current_user.id, skip=skip, limit=limit)
@app.get("/api/v1/search/popular")
async def get_popular_searches(db: Session = Depends(get_db), limit: int = 10):
return crud.get_popular_searches(db=db, limit=limit)

265
shihuashishuo-api/models.py Normal file
View File

@ -0,0 +1,265 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Date, Table, Float, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
# Association tables
food_ingredient_association = Table('food_ingredient_association', Base.metadata,
Column('food_id', Integer, ForeignKey('foods.id'), primary_key=True),
Column('ingredient_id', Integer, ForeignKey('ingredients.id'), primary_key=True)
)
food_additive_association = Table('food_additive_association', Base.metadata,
Column('food_id', Integer, ForeignKey('foods.id'), primary_key=True),
Column('additive_id', Integer, ForeignKey('additives.id'), primary_key=True)
)
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
phone_number = Column(String, unique=True, index=True, nullable=True)
hashed_password = Column(String, nullable=True)
avatar_url = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
preferences = relationship("UserPreference", back_populates="owner")
search_history = relationship("SearchHistory", back_populates="user")
family_members = relationship("FamilyMember", back_populates="owner")
recipes = relationship("Recipe", back_populates="author")
posts = relationship("Post", back_populates="author")
comments = relationship("Comment", back_populates="author")
cart_items = relationship("CartItem", back_populates="user")
orders = relationship("Order", back_populates="user")
oauth_accounts = relationship("OAuthAccount", back_populates="user")
favorites = relationship("Favorite", back_populates="user")
class OAuthAccount(Base):
__tablename__ = "oauth_accounts"
id = Column(Integer, primary_key=True, index=True)
provider = Column(String, nullable=False)
openid = Column(String, nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
user = relationship("User", back_populates="oauth_accounts")
class FamilyMember(Base):
__tablename__ = "family_members"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True)
date_of_birth = Column(Date)
gender = Column(String)
owner_id = Column(Integer, ForeignKey("users.id"))
owner = relationship("User", back_populates="family_members")
health_profiles = relationship("HealthProfile", back_populates="member")
class HealthProfile(Base):
__tablename__ = "health_profiles"
id = Column(Integer, primary_key=True, index=True)
member_id = Column(Integer, ForeignKey("family_members.id"))
profile_type = Column(String, index=True)
value = Column(String)
member = relationship("FamilyMember", back_populates="health_profiles")
class UserPreference(Base):
__tablename__ = "user_preferences"
id = Column(Integer, primary_key=True, index=True)
category = Column(String, index=True)
value = Column(String)
user_id = Column(Integer, ForeignKey("users.id"))
owner = relationship("User", back_populates="preferences")
class Food(Base):
__tablename__ = "foods"
id = Column(Integer, primary_key=True, index=True)
barcode = Column(String, unique=True, index=True, nullable=False)
name = Column(String, index=True)
brand = Column(String, index=True)
category = Column(String, index=True)
safety_rating = Column(String)
nutrition_rating = Column(String)
shi_score = Column(Float)
created_at = Column(DateTime(timezone=True), server_default=func.now())
ingredients = relationship("Ingredient", secondary=food_ingredient_association, back_populates="foods")
additives = relationship("Additive", secondary=food_additive_association, back_populates="foods")
posts = relationship("Post", back_populates="food")
class Ingredient(Base):
__tablename__ = "ingredients"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, index=True)
description = Column(Text)
risk_level = Column(Integer)
foods = relationship("Food", secondary=food_ingredient_association, back_populates="ingredients")
recipes = relationship("RecipeIngredient", back_populates="ingredient")
class Additive(Base):
__tablename__ = "additives"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, index=True)
e_number = Column(String, unique=True, index=True, nullable=True)
description = Column(Text)
risk_level = Column(Integer)
foods = relationship("Food", secondary=food_additive_association, back_populates="additives")
class SearchHistory(Base):
__tablename__ = "search_history"
id = Column(Integer, primary_key=True, index=True)
term = Column(String, index=True, nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
user = relationship("User", back_populates="search_history")
class Article(Base):
__tablename__ = "articles"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, index=True)
content = Column(Text)
author = Column(String)
category = Column(String, index=True)
cover_image_url = Column(String)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class Recipe(Base):
__tablename__ = "recipes"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True)
description = Column(Text)
instructions = Column(Text)
cooking_time = Column(Integer)
author_id = Column(Integer, ForeignKey("users.id"))
author = relationship("User", back_populates="recipes")
ingredients = relationship("RecipeIngredient", back_populates="recipe")
class Topic(Base):
__tablename__ = "topics"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, index=True, nullable=False)
description = Column(Text, nullable=True)
posts = relationship("Post", back_populates="topic")
class RecipeIngredient(Base):
__tablename__ = 'recipe_ingredients'
recipe_id = Column(Integer, ForeignKey('recipes.id'), primary_key=True)
ingredient_id = Column(Integer, ForeignKey('ingredients.id'), primary_key=True)
quantity = Column(Float, nullable=False)
unit = Column(String, nullable=False)
recipe = relationship("Recipe", back_populates="ingredients")
ingredient = relationship("Ingredient", back_populates="recipes")
class Post(Base):
__tablename__ = "posts"
id = Column(Integer, primary_key=True, index=True)
content = Column(Text, nullable=False)
rating = Column(String)
author_id = Column(Integer, ForeignKey("users.id"))
food_id = Column(Integer, ForeignKey("foods.id"))
topic_id = Column(Integer, ForeignKey("topics.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
author = relationship("User", back_populates="posts")
food = relationship("Food", back_populates="posts")
topic = relationship("Topic", back_populates="posts", foreign_keys=[topic_id])
comments = relationship("Comment", back_populates="post")
class Comment(Base):
__tablename__ = "comments"
id = Column(Integer, primary_key=True, index=True)
content = Column(Text, nullable=False)
author_id = Column(Integer, ForeignKey("users.id"))
post_id = Column(Integer, ForeignKey("posts.id"))
created_at = Column(DateTime(timezone=True), server_default=func.now())
author = relationship("User", back_populates="comments")
post = relationship("Post", back_populates="comments")
class Favorite(Base):
__tablename__ = "favorites"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
entity_type = Column(String, nullable=False) # e.g., 'post', 'product', 'recipe'
entity_id = Column(Integer, nullable=False)
tag = Column(String, nullable=True) # e.g., 'like', 'favorite', 'red_list'
user = relationship("User", back_populates="favorites")
# Note: Generic relationship to entity is handled in logic, not via direct SQLAlchemy relationship
# --- E-commerce Models ---
class Product(Base):
__tablename__ = "products"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True)
description = Column(Text)
price = Column(Float, nullable=False)
stock = Column(Integer, default=0)
image_url = Column(String)
class CartItem(Base):
__tablename__ = "cart_items"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
product_id = Column(Integer, ForeignKey("products.id"))
quantity = Column(Integer, default=1)
user = relationship("User", back_populates="cart_items")
product = relationship("Product")
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
total_price = Column(Float, nullable=False)
status = Column(String, default="pending") # e.g., pending, paid, shipped, delivered
created_at = Column(DateTime(timezone=True), server_default=func.now())
user = relationship("User", back_populates="orders")
items = relationship("OrderItem", back_populates="order")
class OrderItem(Base):
__tablename__ = "order_items"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"))
product_id = Column(Integer, ForeignKey("products.id"))
quantity = Column(Integer)
price = Column(Float) # Price at the time of order
order = relationship("Order", back_populates="items")
product = relationship("Product")
class SubmittedFood(Base):
__tablename__ = "submitted_foods"
id = Column(Integer, primary_key=True, index=True)
barcode = Column(String, index=True, nullable=False)
name = Column(String, index=True)
brand = Column(String, index=True, nullable=True)
# Storing ingredients and additives as JSON strings for simplicity in submission
ingredients_text = Column(Text, nullable=True)
additives_text = Column(Text, nullable=True)
status = Column(String, default="pending") # pending, approved, rejected
submitted_by_id = Column(Integer, ForeignKey("users.id"))
created_at = Column(DateTime(timezone=True), server_default=func.now())
submitted_by = relationship("User")

View File

@ -0,0 +1,8 @@
fastapi
uvicorn[standard]
sqlalchemy
pydantic
python-jose[cryptography]
passlib
bcrypt==3.2.0
python-multipart

View File

@ -0,0 +1,375 @@
from pydantic import BaseModel
from datetime import datetime, date
from typing import List, Optional
# --- E-commerce Schemas ---
class ProductBase(BaseModel):
name: str
description: Optional[str] = None
price: float
image_url: Optional[str] = None
class ProductCreate(ProductBase):
stock: int
class Product(ProductBase):
id: int
stock: int
class Config:
from_attributes = True
class CartItemBase(BaseModel):
product_id: int
quantity: int
class CartItemCreate(CartItemBase):
pass
class CartItem(CartItemBase):
id: int
user_id: int
product: Product
class Config:
from_attributes = True
class OrderItemBase(BaseModel):
product_id: int
quantity: int
price: float # Price at the time of order
class OrderItem(OrderItemBase):
id: int
product: Product
class Config:
from_attributes = True
class OrderBase(BaseModel):
total_price: float
status: str
class OrderCreate(BaseModel):
# When creating an order, it's usually from the cart, so no direct items needed
pass
class Order(OrderBase):
id: int
user_id: int
created_at: datetime
items: List[OrderItem] = []
class Config:
from_attributes = True
# --- Community Schemas ---
class CommentBase(BaseModel):
content: str
class CommentCreate(CommentBase):
post_id: int
class Comment(CommentBase):
id: int
author_id: int
post_id: int
created_at: datetime
class Config:
from_attributes = True
class PostBase(BaseModel):
content: str
rating: str
class PostCreate(PostBase):
food_id: int
class Post(PostBase):
id: int
author_id: int
food_id: int
created_at: datetime
comments: List[Comment] = []
favorites_count: int = 0
class Config:
from_attributes = True
class FavoriteBase(BaseModel):
entity_type: str
entity_id: int
tag: Optional[str] = None
class FavoriteCreate(FavoriteBase):
pass
class Favorite(FavoriteBase):
id: int
user_id: int
class Config:
from_attributes = True
# --- Topic Schemas ---
class TopicBase(BaseModel):
name: str
description: Optional[str] = None
class TopicCreate(TopicBase):
pass
class Topic(TopicBase):
id: int
class Config:
from_attributes = True
# --- Article Schemas ---
class ArticleBase(BaseModel):
title: str
content: str
author: Optional[str] = None
category: str
cover_image_url: Optional[str] = None
class ArticleCreate(ArticleBase):
pass
class Article(ArticleBase):
id: int
created_at: datetime
class Config:
from_attributes = True
# --- Recipe Schemas ---
class RecipeIngredientBase(BaseModel):
ingredient_id: int
quantity: float
unit: str
class RecipeIngredientCreate(RecipeIngredientBase):
pass
class RecipeIngredient(RecipeIngredientBase):
ingredient_name: str
class Config:
from_attributes = True
class RecipeBase(BaseModel):
name: str
description: Optional[str] = None
instructions: Optional[str] = None
cooking_time: Optional[int] = None
class RecipeCreate(RecipeBase):
ingredients: List[RecipeIngredientCreate] = []
class Recipe(RecipeBase):
id: int
author_id: int
ingredients: List[RecipeIngredient] = []
class Config:
from_attributes = True
# --- Ingredient Schemas ---
class IngredientBase(BaseModel):
name: str
description: Optional[str] = None
risk_level: int
class IngredientCreate(IngredientBase):
pass
class Ingredient(IngredientBase):
id: int
class Config:
from_attributes = True
# --- Additive Schemas ---
class AdditiveBase(BaseModel):
name: str
e_number: Optional[str] = None
description: Optional[str] = None
risk_level: int
class AdditiveCreate(AdditiveBase):
pass
class Additive(AdditiveBase):
id: int
class Config:
from_attributes = True
# --- Food Schemas ---
class FoodBase(BaseModel):
barcode: str
name: str | None = None
brand: str | None = None
category: Optional[str] = None
safety_rating: Optional[str] = None
nutrition_rating: Optional[str] = None
shi_score: Optional[float] = None
class FoodCreate(FoodBase):
ingredient_ids: List[int] = []
additive_ids: List[int] = []
class Food(FoodBase):
id: int
created_at: datetime
class Config:
from_attributes = True
class FoodDetails(Food):
ingredients: List[Ingredient] = []
additives: List[Additive] = []
class Config:
from_attributes = True
# --- HealthProfile Schemas ---
class HealthProfileBase(BaseModel):
profile_type: str
value: str
class HealthProfileCreate(HealthProfileBase):
pass
class HealthProfile(HealthProfileBase):
id: int
member_id: int
class Config:
from_attributes = True
# --- FamilyMember Schemas ---
class FamilyMemberBase(BaseModel):
name: str
date_of_birth: date
gender: str
class FamilyMemberCreate(FamilyMemberBase):
pass
class FamilyMember(FamilyMemberBase):
id: int
owner_id: int
health_profiles: List[HealthProfile] = []
class Config:
from_attributes = True
# --- User Schemas ---
class UserBase(BaseModel):
phone_number: str | None = None
avatar_url: Optional[str] = None
class UserCreate(UserBase):
password: str | None = None
avatar_url: Optional[str] = None
class UserCodeLogin(UserBase):
code: str
class User(UserBase):
id: int
created_at: datetime
family_members: List[FamilyMember] = []
class Config:
from_attributes = True
# --- OAuthAccount Schemas ---
class OAuthAccountBase(BaseModel):
provider: str
openid: str
class OAuthAccountCreate(OAuthAccountBase):
pass
class OAuthAccount(OAuthAccountBase):
id: int
user_id: int
class Config:
from_attributes = True
# --- Token Schemas ---
class Token(BaseModel):
access_token: str
token_type: str
is_new_user: bool
# --- UserPreference Schemas ---
class UserPreferenceBase(BaseModel):
category: str
value: str
class UserPreferenceCreate(UserPreferenceBase):
pass
class UserPreference(UserPreferenceBase):
id: int
user_id: int
class Config:
from_attributes = True
class OnboardingPreferences(BaseModel):
concerns: List[str]
allergens: List[str]
conditions: List[str]
preferences: List[str]
# --- SearchHistory Schemas ---
class SearchHistoryBase(BaseModel):
term: str
class SearchHistoryCreate(SearchHistoryBase):
pass
class SearchHistory(SearchHistoryBase):
id: int
user_id: Optional[int] = None
created_at: datetime
class Config:
from_attributes = True
# --- Auth Schemas ---
class PasswordSet(BaseModel):
new_password: str
class UserPasswordLogin(BaseModel):
phone_number: str
password: str
# --- SubmittedFood Schemas ---
class SubmittedFoodBase(BaseModel):
barcode: str
name: str
brand: Optional[str] = None
ingredients_text: Optional[str] = None
additives_text: Optional[str] = None
class SubmittedFoodCreate(SubmittedFoodBase):
pass
class SubmittedFood(SubmittedFoodBase):
id: int
status: str
submitted_by_id: int
created_at: datetime
class Config:
from_attributes = True

View File

@ -0,0 +1,59 @@
from datetime import datetime, timedelta
from jose import jwt
from passlib.context import CryptContext
from fastapi.security import OAuth2PasswordBearer
SECRET_KEY = "your-secret-key"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
def create_access_token(data: dict, expires_delta: timedelta | None = None):
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
def get_password_hash(password):
return pwd_context.hash(password)
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
import requests
from sqlalchemy.orm import Session
import crud
# --- WeChat OAuth ---
WECHAT_APP_ID = "your-wechat-app-id"
WECHAT_APP_SECRET = "your-wechat-app-secret"
def get_wechat_openid(code: str) -> str | None:
"""
Simulates fetching openid from WeChat server.
In a real application, this would involve an HTTP request to WeChat's API.
"""
# url = f"https://api.weixin.qq.com/sns/jscode2session?appid={WECHAT_APP_ID}&secret={WECHAT_APP_SECRET}&js_code={code}&grant_type=authorization_code"
# response = requests.get(url)
# if response.status_code == 200:
# data = response.json()
# return data.get("openid")
# return None
# For simulation purposes, we'll just return a mock openid based on the code
if code == "valid_wechat_code":
return "mock_wechat_openid_12345"
return None
def authenticate_wechat_user(db: Session, code: str):
openid = get_wechat_openid(code)
if not openid:
return None, False
user, is_new_user = crud.get_or_create_oauth_user(db, provider="wechat", openid=openid)
return user, is_new_user

Binary file not shown.

View File

@ -0,0 +1,99 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAuthStore = defineStore('auth', () => {
// 从 localStorage 获取初始状态
const isLoggedIn = ref(!!localStorage.getItem('token'))
const hasCompletedOnboarding = ref(JSON.parse(localStorage.getItem('hasCompletedOnboarding') || 'false'))
const token = ref(localStorage.getItem('token'))
const isNewUser = ref(false) // 默认不是新用户
function setToken(newToken: string, newStatus: boolean) {
token.value = newToken
isLoggedIn.value = true
isNewUser.value = newStatus
localStorage.setItem('token', newToken)
localStorage.setItem('isLoggedIn', 'true') // 保留旧的isLoggedIn以便兼容
}
function login() {
// 这个方法在 setToken 后可以被视为一个辅助方法或被废弃
isLoggedIn.value = true
localStorage.setItem('isLoggedIn', 'true')
}
function logout() {
token.value = null
isLoggedIn.value = false
isNewUser.value = false
hasCompletedOnboarding.value = false
localStorage.removeItem('token')
localStorage.removeItem('isLoggedIn')
localStorage.removeItem('hasCompletedOnboarding')
}
function completeOnboarding() {
hasCompletedOnboarding.value = true
localStorage.setItem('hasCompletedOnboarding', 'true')
}
async function setPassword(newPassword: string) {
if (!token.value) {
throw new Error('用户未登录');
}
const response = await fetch('http://localhost:8000/api/v1/auth/users/me/password', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token.value}`
},
body: JSON.stringify({
new_password: newPassword
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || '密码设置失败');
}
return await response.json();
}
async function passwordLogin(phoneNumber: string, password: string) {
const response = await fetch('http://localhost:8000/api/v1/auth/login/password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
phone_number: phoneNumber,
password: password
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || '手机号或密码错误');
}
const data = await response.json();
setToken(data.access_token, data.is_new_user);
if (data.has_completed_onboarding) {
completeOnboarding();
}
}
return {
isLoggedIn,
hasCompletedOnboarding,
token,
isNewUser,
setToken,
login,
logout,
completeOnboarding,
setPassword,
passwordLogin,
}
})

View File

@ -0,0 +1,297 @@
<template>
<div class="home-view">
<!-- 1. Top Status Bar -->
<header class="top-bar">
<div class="greeting">晚上好, 王女士</div>
<div class="message-icon" @click="goToMessages">
<img src="@/assets/message-icon.svg" alt="Messages" />
</div>
</header>
<!-- Search Bar Section -->
<section class="search-section" @click="goToSearch">
<div class="search-bar-container">
<input type="text" placeholder="疾病 / 症状 / 药品 / 问题" readonly>
<div class="separator"></div>
<div class="search-button">搜索</div>
</div>
</section>
<!-- Banner Section -->
<section class="banner-section">
<div class="banner-wrapper" :style="bannerStyle">
<div v-for="item in bannerItems" :key="item.id" class="banner-slide">
<img :src="item.imageUrl" :alt="item.alt" />
</div>
</div>
<div class="banner-dots">
<span v-for="(item, index) in bannerItems" :key="item.id"
:class="{ active: index === currentIndex }"
@click="goToSlide(index)"></span>
</div>
</section>
<!-- 2. Hero Section -->
<main class="hero-section">
<p class="slogan">食品真相食话食说</p>
<div class="scan-cta" @click="goToScan">
<div class="scan-icon">📷</div>
<span>扫码/拍照</span>
</div>
<!-- 3. Personalized Dynamics -->
<div class="dynamic-card">
<div class="card-text">
<p>本周您已分析 <strong>8</strong> 种食品成功为家人避开 <strong>4</strong> 个高风险成分</p>
</div>
<div class="card-avatar">
<img src="https://images.unsplash.com/photo-1527980965255-d3b416303d12?q=80&w=1780&auto=format&fit=crop" alt="User Avatar" />
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
const bannerItems = ref([
{ id: 1, imageUrl: 'https://images.unsplash.com/photo-1546069901-ba9599a7e63c?q=80&w=2080&auto=format&fit=crop', alt: 'Healthy Salad' },
{ id: 2, imageUrl: 'https://images.unsplash.com/photo-1540189549336-e6e99c3679fe?q=80&w=1887&auto=format&fit=crop', alt: 'Fresh Berries' },
{ id: 3, imageUrl: 'https://images.unsplash.com/photo-1482049016688-2d3e1b311543?q=80&w=1910&auto=format&fit=crop', alt: 'Avocado Toast' },
]);
const currentIndex = ref(0);
let intervalId: number;
const bannerStyle = computed(() => ({
transform: `translateX(-${currentIndex.value * 100}%)`
}));
const goToSlide = (index: number) => {
currentIndex.value = index;
// Reset autoplay timer
clearInterval(intervalId);
intervalId = window.setInterval(nextSlide, 3000);
};
const nextSlide = () => {
currentIndex.value = (currentIndex.value + 1) % bannerItems.value.length;
};
onMounted(() => {
intervalId = window.setInterval(nextSlide, 3000);
});
onUnmounted(() => {
clearInterval(intervalId);
});
const router = useRouter();
const goToMessages = () => {
router.push({ name: 'messages' });
};
const goToScan = () => {
router.push({ name: 'scan' });
};
const goToSearch = () => {
router.push({ name: 'search' });
};
</script>
<style scoped>
.home-view {
display: flex;
flex-direction: column;
height: 100%;
padding: 20px;
box-sizing: border-box;
background-color: var(--color-background);
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.search-section {
/* The parent .home-view now handles padding and background */
margin-top: 20px; /* Adjust this value to increase/decrease space */
margin-bottom: 5px;
}
.search-bar-container {
display: flex;
align-items: center;
background-color: #fff;
border-radius: 25px;
padding: 10px 15px;
border: 1px solid #07C160; /* WeChat Green */
/* box-shadow: 0 2px 4px rgba(0,0,0,0.05); */ /* Removing shadow for a flatter look */
cursor: pointer;
max-width: 90%; /* Adjust width */
margin: 0 auto; /* Center the search bar */
}
.search-bar-container input {
border: none;
outline: none;
background: transparent;
flex-grow: 1; /* Allow input to take up remaining space */
width: 0; /* Necessary for flex-grow to work correctly */
font-size: 14px;
color: #333;
pointer-events: none; /* Makes the input not focusable, click is handled by parent */
}
.search-bar-container input::placeholder {
color: #ccc; /* Lighter gray for placeholder */
font-style: normal; /* Remove italic */
}
.separator {
width: 1px;
height: 16px;
background-color: #e0e0e0;
margin: 0 12px;
}
.search-button {
color: #07C160; /* WeChat Green */
font-weight: bold;
font-size: 15px;
cursor: pointer;
white-space: nowrap; /* Prevent text from wrapping */
}
.banner-section {
width: 100%;
margin-top: 10px; /* Adjust space between search-bar and banner */
margin-bottom: 20px;
overflow: hidden;
position: relative;
border-radius: 8px;
}
.banner-wrapper {
display: flex;
transition: transform 0.5s ease-in-out;
}
.banner-slide {
flex-shrink: 0;
width: 100%;
height: 150px; /* Set a fixed height for the banner */
overflow: hidden;
}
.banner-slide img {
width: 100%;
height: 100%;
object-fit: cover; /* Ensure the image covers the area without distortion */
display: block;
}
.banner-dots {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
}
.banner-dots span {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.5);
cursor: pointer;
}
.banner-dots span.active {
background-color: white;
}
.greeting {
font-size: 18px;
font-weight: bold;
}
.message-icon {
cursor: pointer;
}
.message-icon img {
width: 24px;
height: 24px;
display: block;
}
.hero-section {
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: flex-start; /* Move content towards the top */
align-items: center;
text-align: center;
padding-top: 10%; /* Add some padding from the banner */
}
.slogan {
font-size: 24px;
color: var(--color-text-secondary);
margin-bottom: 20px;
}
.scan-cta {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 180px; /* Smaller size */
height: 180px; /* Smaller size */
border-radius: 50%;
background: var(--color-background-soft); /* Light grey to stand out on white */
color: var(--color-text-primary);
cursor: pointer;
border: 1px solid #07C160; /* WeChat Green */
box-shadow: none; /* Remove shadow */
}
.scan-icon {
font-size: 50px; /* Smaller icon */
margin-bottom: 8px;
color: var(--color-text-primary);
}
.dynamic-card {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--color-background-soft);
border-radius: 8px;
padding: 15px;
font-size: 14px;
margin-top: 25px; /* Adjust space */
width: 90%;
max-width: 350px;
border: 1px solid #07C160; /* WeChat Green */
}
.card-text {
flex-grow: 1;
text-align: left;
margin-right: 15px;
}
.card-avatar img {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.dynamic-card strong {
color: var(--color-primary-accent);
}
</style>

View File

@ -0,0 +1,593 @@
<template>
<div class="home-view">
<!-- 1. Top Status Bar -->
<header class="top-bar">
<div class="greeting">晚上好, 王女士</div>
<div class="message-icon" @click="goToMessages">
<img src="@/assets/message-icon.svg" alt="Messages" />
</div>
</header>
<!-- Search Bar Section -->
<section class="search-section">
<div class="search-bar-container">
<input type="text" v-model="searchQuery" @keydown.enter="handleSearch" placeholder="疾病 / 症状 / 药品 / 问题">
<div class="separator"></div>
<div class="search-button" @click="handleSearch">搜索</div>
</div>
</section>
<!-- Hot Searches Section -->
<section class="hot-searches-section">
<span v-for="term in hotSearches" :key="term" class="hot-search-tag" @click.stop="handleSearch(term)">
{{ term }}
</span>
</section>
<!-- Banner Section -->
<section class="banner-section">
<div class="banner-wrapper" :style="bannerStyle">
<div v-for="item in bannerItems" :key="item.id" class="banner-slide">
<img :src="item.imageUrl" :alt="item.alt" />
</div>
</div>
<div class="banner-dots">
<span v-for="(item, index) in bannerItems" :key="item.id"
:class="{ active: index === currentIndex }"
@click="goToSlide(index)"></span>
</div>
</section>
<!-- 2. Hero Section -->
<main class="hero-section">
<div class="scan-cta" @click="goToScan">
<div class="scan-icon">📷</div>
<span>扫码/拍照</span>
</div>
</main>
<!-- 3. Personalized Dynamic Card (Restored) -->
<section class="dynamic-card-section">
<div class="dynamic-card">
<div class="card-text">
<p>本周您已分析 <strong>8</strong> 种食品成功为家人避开 <strong>4</strong> 个高风险成分</p>
</div>
<div class="card-avatar">
<img src="https://images.unsplash.com/photo-1527980965255-d3b416303d12?q=80&w=1780&auto=format&fit=crop" alt="User Avatar" />
</div>
</div>
</section>
<!-- 4. Health Dashboard (v2.5 Visual Upgrade) -->
<section class="health-dashboard">
<div class="dashboard-item nutrition-tracker">
<h4>每日营养</h4>
<div class="nutrition-details">
<div>
<p>蛋白质</p>
<strong>{{ healthDashboardData.nutrition.protein }} / {{ healthDashboardData.nutrition.proteinGoal }}g</strong>
</div>
<div>
<p>脂肪</p>
<strong>{{ healthDashboardData.nutrition.fat }} / {{ healthDashboardData.nutrition.fatGoal }}g</strong>
</div>
</div>
</div>
<div class="dashboard-item progress-card">
<!-- Calorie Progress Ring -->
<div class="progress-ring">
<svg class="progress-ring__svg" :width="ringSize" :height="ringSize">
<circle class="progress-ring__circle-bg" :r="radius" :cx="center" :cy="center" />
<circle class="progress-ring__circle" :stroke-dasharray="circumference" :stroke-dashoffset="calorieProgressOffset" :r="radius" :cx="center" :cy="center" />
</svg>
<div class="progress-ring__text">
<p>热量摄入</p>
<strong>{{ healthDashboardData.calories.current }}</strong>
<span>/ {{ healthDashboardData.calories.goal }} kcal</span>
</div>
</div>
<!-- Water Progress Ring -->
<div class="progress-ring">
<svg class="progress-ring__svg" :width="ringSize" :height="ringSize">
<circle class="progress-ring__circle-bg" :r="radius" :cx="center" :cy="center" />
<circle class="progress-ring__circle water" :stroke-dasharray="circumference" :stroke-dashoffset="waterProgressOffset" :r="radius" :cx="center" :cy="center" />
</svg>
<div class="progress-ring__text">
<p>饮水</p>
<strong>{{ healthDashboardData.water.current / 1000 }}</strong>
<span>/ {{ healthDashboardData.water.goal / 1000 }} L</span>
</div>
</div>
</div>
</section>
<!-- 5. Content Feed -->
<section class="feed-section">
<div v-for="item in feedItems" :key="item.id" class="feed-card" @click="goTo(item.link)">
<div v-if="item.type === 'discover'" class="discover-card">
<div class="card-content">
<span class="card-tag">发现</span>
<h3>{{ item.title }}</h3>
<p>{{ item.summary }}</p>
</div>
<img :src="item.imageUrl" :alt="item.title" class="card-image">
</div>
<div v-if="item.type === 'recipe'" class="recipe-card">
<img :src="item.imageUrl" :alt="item.title" class="card-image-full">
<div class="card-overlay">
<span class="card-tag">为你推荐的菜谱</span>
<h3>{{ item.title }}</h3>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
const authStore = useAuthStore();
const router = useRouter();
const searchQuery = ref('');
const bannerItems = ref([
{ id: 1, imageUrl: 'https://images.unsplash.com/photo-1546069901-ba9599a7e63c?q=80&w=2080&auto=format&fit=crop', alt: 'Healthy Salad' },
{ id: 2, imageUrl: 'https://images.unsplash.com/photo-1540189549336-e6e99c3679fe?q=80&w=1887&auto=format&fit=crop', alt: 'Fresh Berries' },
{ id: 3, imageUrl: 'https://images.unsplash.com/photo-1482049016688-2d3e1b311543?q=80&w=1910&auto=format&fit=crop', alt: 'Avocado Toast' },
]);
const currentIndex = ref(0);
let intervalId: number;
const hotSearches = ref(['无糖酸奶', '酱油', '儿童零食', '高钙牛奶']);
const healthDashboardData = ref({
nutrition: { protein: 30, proteinGoal: 60, fat: 20, fatGoal: 50 },
calories: { current: 800, goal: 1800 },
water: { current: 1000, goal: 2000 },
});
// Progress Ring Computations
const ringSize = 120;
const strokeWidth = 10;
const center = ringSize / 2;
const radius = center - strokeWidth / 2;
const circumference = 2 * Math.PI * radius;
const calorieProgressOffset = computed(() => {
const progress = healthDashboardData.value.calories.current / healthDashboardData.value.calories.goal;
return circumference * (1 - progress);
});
const waterProgressOffset = computed(() => {
const progress = healthDashboardData.value.water.current / healthDashboardData.value.water.goal;
return circumference * (1 - progress);
});
const feedItems = ref([
{ id: 'd1', type: 'discover', title: "警惕这5种'儿童酱油'其实是钠含量炸弹", summary: '深度评测10款热门儿童酱油结果令人震惊...', imageUrl: 'https://images.unsplash.com/photo-1598134493282-85b82454f754?q=80&w=1887&auto=format&fit=crop', link: { name: 'discover' } },
{ id: 'r1', type: 'recipe', title: '适合减脂期的你:牛油果鸡胸肉沙拉', imageUrl: 'https://images.unsplash.com/photo-1505253716362-af78f6d38348?q=80&w=1887&auto=format&fit=crop', link: { name: 'kitchen' } },
]);
const bannerStyle = computed(() => ({
transform: `translateX(-${currentIndex.value * 100}%)`
}));
const goToSlide = (index: number) => {
currentIndex.value = index;
clearInterval(intervalId);
intervalId = window.setInterval(nextSlide, 3000);
};
const nextSlide = () => {
currentIndex.value = (currentIndex.value + 1) % bannerItems.value.length;
};
onMounted(() => {
intervalId = window.setInterval(nextSlide, 3000);
});
onUnmounted(() => {
clearInterval(intervalId);
});
const goToMessages = () => {
router.push({ name: 'messages' });
};
const goToScan = () => {
router.push({ name: 'scan' });
};
const handleSearch = async (term?: string | Event) => {
let query = '';
if (typeof term === 'string') {
query = term;
} else {
query = searchQuery.value;
}
if (!query.trim()) {
//
router.push({ name: 'search' });
return;
}
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (authStore.token) {
headers['Authorization'] = `Bearer ${authStore.token}`;
}
await fetch('/api/v1/search/history', {
method: 'POST',
headers: headers,
body: JSON.stringify({ query: query.trim() }),
});
} catch (error) {
console.error('Failed to save search history:', error);
// Even if saving history fails, we should still proceed to search results
}
router.push({ name: 'search-result', query: { q: query.trim() } });
};
const goTo = (link: object) => {
router.push(link);
};
</script>
<style scoped>
.home-view {
padding: 20px;
box-sizing: border-box;
background-color: var(--color-background);
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.search-section {
margin-top: 20px;
}
.search-bar-container {
display: flex;
align-items: center;
background-color: #fff;
border-radius: 25px;
padding: 10px 15px;
border: 1px solid #07C160;
cursor: pointer;
max-width: 90%;
margin: 0 auto;
}
.search-bar-container input {
border: none;
outline: none;
background: transparent;
flex-grow: 1;
width: 0;
font-size: 14px;
color: #333;
}
.separator {
width: 1px;
height: 16px;
background-color: #e0e0e0;
margin: 0 12px;
}
.search-button {
color: #07C160;
font-weight: bold;
font-size: 15px;
}
.hot-searches-section {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 10px;
flex-wrap: wrap;
}
.hot-search-tag {
background-color: #f0f0f0;
color: #555;
padding: 5px 12px;
border-radius: 15px;
font-size: 12px;
cursor: pointer;
}
.banner-section {
width: 100%;
margin-top: 15px;
overflow: hidden;
position: relative;
border-radius: 8px;
}
.banner-wrapper {
display: flex;
transition: transform 0.5s ease-in-out;
}
.banner-slide {
flex-shrink: 0;
width: 100%;
height: 150px;
}
.banner-slide img {
width: 100%;
height: 100%;
object-fit: cover;
}
.banner-dots {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
}
.banner-dots span {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.5);
}
.banner-dots span.active {
background-color: white;
}
.greeting {
font-size: 18px;
font-weight: bold;
}
.message-icon img {
width: 24px;
height: 24px;
}
.hero-section {
display: flex;
justify-content: center;
padding: 20px 0;
}
.scan-cta {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 160px;
height: 160px;
border-radius: 50%;
background: var(--color-background-soft);
cursor: pointer;
border: 2px solid #07C160;
box-shadow: 0 2px 8px rgba(7, 193, 96, 0.15);
}
.scan-icon {
font-size: 60px;
margin-bottom: 8px;
}
.scan-cta span {
font-size: 16px;
font-weight: bold;
}
.dynamic-card-section {
margin-bottom: 20px;
}
.dynamic-card {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--color-background-soft);
border-radius: 8px;
padding: 15px;
font-size: 14px;
width: 90%;
max-width: 350px;
border: 1px solid #07C160;
margin: 0 auto;
}
.card-text {
flex-grow: 1;
text-align: left;
margin-right: 15px;
}
.card-avatar img {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.dynamic-card strong {
color: var(--color-primary-accent);
}
.health-dashboard {
display: flex;
flex-direction: column;
gap: 15px;
padding: 15px;
background-color: #f9f9f9;
border-radius: 12px;
border: 1px solid #e0e0e0;
}
.dashboard-item {
background-color: #fff;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.dashboard-item h4 {
font-size: 14px;
color: #333;
margin: 0 0 10px 0;
text-align: left;
}
.nutrition-details {
display: flex;
justify-content: space-around;
text-align: center;
}
.nutrition-details p {
margin: 0 0 5px 0;
font-size: 12px;
color: #666;
}
.nutrition-details strong {
font-size: 16px;
color: #333;
}
.progress-card {
display: flex;
justify-content: space-around;
align-items: center;
}
.progress-ring {
position: relative;
text-align: center;
}
.progress-ring__svg {
transform: rotate(-90deg);
}
.progress-ring__circle-bg {
fill: none;
stroke: #e6e6e6;
stroke-width: 10;
}
.progress-ring__circle {
fill: none;
stroke: #07C160;
stroke-width: 10;
stroke-linecap: round;
transition: stroke-dashoffset 0.35s;
}
.progress-ring__circle.water {
stroke: #007bff;
}
.progress-ring__text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.progress-ring__text p {
margin: 0;
font-size: 12px;
color: #666;
}
.progress-ring__text strong {
font-size: 20px;
color: #333;
}
.progress-ring__text span {
font-size: 12px;
color: #999;
}
.feed-section {
margin-top: 30px;
display: flex;
flex-direction: column;
gap: 20px;
}
.feed-card {
border-radius: 12px;
overflow: hidden;
background-color: var(--color-background-soft);
box-shadow: 0 4px 8px rgba(0,0,0,0.05);
}
.discover-card {
display: flex;
align-items: center;
padding: 15px;
gap: 15px;
}
.discover-card .card-content { flex: 1; }
.discover-card .card-image {
width: 80px;
height: 80px;
border-radius: 8px;
object-fit: cover;
}
.recipe-card { position: relative; color: white; }
.recipe-card .card-image-full {
width: 100%;
height: 180px;
object-fit: cover;
}
.recipe-card .card-overlay {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0) 50%);
padding: 15px;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.card-tag {
background-color: rgba(7, 193, 96, 0.8);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
align-self: flex-start;
margin-bottom: 8px;
}
h3 {
font-size: 16px;
font-weight: bold;
margin: 0 0 5px 0;
}
.discover-card p {
font-size: 14px;
color: var(--color-text-secondary);
margin: 0;
}
</style>

View File

@ -0,0 +1,610 @@
<template>
<div class="home-view">
<!-- 1. Top Status Bar -->
<header class="top-bar">
<div class="greeting">晚上好, 王女士</div>
<div class="message-icon" @click="goToMessages">
<img src="@/assets/message-icon.svg" alt="Messages" />
</div>
</header>
<!-- Search Bar Section -->
<section class="search-section" @click="() => goToSearch()">
<div class="search-bar-container">
<input type="text" placeholder="产品 /成分 /食物 /菜谱 /问题" readonly>
<div class="separator"></div>
<div class="search-button">搜索</div>
</div>
</section>
<!-- Hot Searches Section -->
<section class="hot-searches-section">
<span v-for="term in hotSearches" :key="term" class="hot-search-tag" @click.stop="goToSearch(term)">
{{ term }}
</span>
</section>
<!-- Banner Section -->
<section class="banner-section">
<div class="banner-wrapper" :style="bannerStyle">
<div v-for="item in bannerItems" :key="item.id" class="banner-slide">
<img :src="item.imageUrl" :alt="item.alt" />
</div>
</div>
<div class="banner-dots">
<span v-for="(item, index) in bannerItems" :key="item.id"
:class="{ active: index === currentIndex }"
@click="goToSlide(index)"></span>
</div>
</section>
<!-- 2. Scan and Summary Card -->
<section class="scan-summary-section">
<div class="scan-summary-card">
<div class="scan-cta-small" @click="goToScan">
<div class="scan-icon">📷</div>
<span>拍照/扫码</span>
</div>
<div class="card-separator"></div>
<div class="summary-text">
<p>本周您已分析 <strong>8</strong> 种食品</p>
<p>成功为家人避开 <strong>4</strong> 个高风险成分</p>
</div>
</div>
</section>
<!-- 4. Health Dashboard (v2.5 Visual Upgrade) -->
<section class="health-dashboard">
<div class="dashboard-item nutrition-tracker">
<h4>每日营养</h4>
<div class="nutrition-details">
<div>
<p>蛋白质</p>
<div class="nutrition-value">
<strong>{{ healthDashboardData.nutrition.protein }}</strong>
<span>/ {{ healthDashboardData.nutrition.proteinGoal }}g</span>
</div>
</div>
<div>
<p>脂肪</p>
<div class="nutrition-value">
<strong>{{ healthDashboardData.nutrition.fat }}</strong>
<span>/ {{ healthDashboardData.nutrition.fatGoal }}g</span>
</div>
</div>
<div>
<p>碳水化合物</p>
<div class="nutrition-value">
<strong>{{ healthDashboardData.nutrition.carbs }}</strong>
<span>/ {{ healthDashboardData.nutrition.carbsGoal }}g</span>
</div>
</div>
<div>
<p></p>
<div class="nutrition-value">
<strong>{{ healthDashboardData.nutrition.calcium }}</strong>
<span>/ {{ healthDashboardData.nutrition.calciumGoal }}mg</span>
</div>
</div>
<div>
<p>维生素C</p>
<div class="nutrition-value">
<strong>{{ healthDashboardData.nutrition.vitaminC }}</strong>
<span>/ {{ healthDashboardData.nutrition.vitaminCGoal }}mg</span>
</div>
</div>
<div>
<p>维生素D</p>
<div class="nutrition-value">
<strong>{{ healthDashboardData.nutrition.vitaminD }}</strong>
<span>/ {{ healthDashboardData.nutrition.vitaminDGoal }}µg</span>
</div>
</div>
</div>
</div>
<div class="dashboard-item progress-card">
<!-- Calorie Progress Ring -->
<div class="progress-ring">
<svg class="progress-ring__svg" :width="ringSize" :height="ringSize">
<circle class="progress-ring__circle-bg" :r="radius" :cx="center" :cy="center" />
<circle class="progress-ring__circle" :stroke-dasharray="circumference" :stroke-dashoffset="calorieProgressOffset" :r="radius" :cx="center" :cy="center" />
</svg>
<div class="progress-ring__text">
<p>热量摄入</p>
<strong>{{ healthDashboardData.calories.current }}</strong>
<span>/ {{ healthDashboardData.calories.goal }} kcal</span>
</div>
</div>
<!-- Water Progress Ring -->
<div class="progress-ring">
<svg class="progress-ring__svg" :width="ringSize" :height="ringSize">
<circle class="progress-ring__circle-bg" :r="radius" :cx="center" :cy="center" />
<circle class="progress-ring__circle water" :stroke-dasharray="circumference" :stroke-dashoffset="waterProgressOffset" :r="radius" :cx="center" :cy="center" />
</svg>
<div class="progress-ring__text">
<p>饮水</p>
<strong>{{ healthDashboardData.water.current / 1000 }}</strong>
<span>/ {{ healthDashboardData.water.goal / 1000 }} L</span>
</div>
</div>
</div>
</section>
<!-- 5. Content Feed -->
<section class="feed-section">
<div v-for="item in feedItems" :key="item.id" class="feed-card" @click="goTo(item.link)">
<div v-if="item.type === 'discover'" class="discover-card">
<div class="card-content">
<span class="card-tag">发现</span>
<h3>{{ item.title }}</h3>
<p>{{ item.summary }}</p>
</div>
<img :src="item.imageUrl" :alt="item.title" class="card-image">
</div>
<div v-if="item.type === 'recipe'" class="recipe-card">
<img :src="item.imageUrl" :alt="item.title" class="card-image-full">
<div class="card-overlay">
<span class="card-tag">为你推荐的菜谱</span>
<h3>{{ item.title }}</h3>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
const bannerItems = ref([
{ id: 1, imageUrl: 'https://images.unsplash.com/photo-1546069901-ba9599a7e63c?q=80&w=2080&auto=format&fit=crop', alt: 'Healthy Salad' },
{ id: 2, imageUrl: 'https://images.unsplash.com/photo-1540189549336-e6e99c3679fe?q=80&w=1887&auto=format&fit=crop', alt: 'Fresh Berries' },
{ id: 3, imageUrl: 'https://images.unsplash.com/photo-1482049016688-2d3e1b311543?q=80&w=1910&auto=format&fit=crop', alt: 'Avocado Toast' },
]);
const currentIndex = ref(0);
let intervalId: number;
const hotSearches = ref(['无糖酸奶', '酱油', '儿童零食', '高钙牛奶']);
const healthDashboardData = ref({
nutrition: {
protein: 30, proteinGoal: 60,
fat: 20, fatGoal: 50,
carbs: 150, carbsGoal: 300,
calcium: 500, calciumGoal: 1000,
vitaminC: 40, vitaminCGoal: 90,
vitaminD: 5, vitaminDGoal: 15
},
calories: { current: 800, goal: 1800 },
water: { current: 1000, goal: 2000 },
});
// Progress Ring Computations
const ringSize = 120;
const strokeWidth = 10;
const center = ringSize / 2;
const radius = center - strokeWidth / 2;
const circumference = 2 * Math.PI * radius;
const calorieProgressOffset = computed(() => {
const progress = healthDashboardData.value.calories.current / healthDashboardData.value.calories.goal;
return circumference * (1 - progress);
});
const waterProgressOffset = computed(() => {
const progress = healthDashboardData.value.water.current / healthDashboardData.value.water.goal;
return circumference * (1 - progress);
});
const feedItems = ref([
{ id: 'd1', type: 'discover', title: "警惕这5种'儿童酱油'其实是钠含量炸弹", summary: '深度评测10款热门儿童酱油结果令人震惊...', imageUrl: 'https://images.unsplash.com/photo-1598134493282-85b82454f754?q=80&w=1887&auto=format&fit=crop', link: { name: 'discover' } },
{ id: 'r1', type: 'recipe', title: '适合减脂期的你:牛油果鸡胸肉沙拉', imageUrl: 'https://images.unsplash.com/photo-1505253716362-af78f6d38348?q=80&w=1887&auto=format&fit=crop', link: { name: 'kitchen' } },
]);
const bannerStyle = computed(() => ({
transform: `translateX(-${currentIndex.value * 100}%)`
}));
const goToSlide = (index: number) => {
currentIndex.value = index;
clearInterval(intervalId);
intervalId = window.setInterval(nextSlide, 3000);
};
const nextSlide = () => {
currentIndex.value = (currentIndex.value + 1) % bannerItems.value.length;
};
onMounted(() => {
intervalId = window.setInterval(nextSlide, 3000);
});
onUnmounted(() => {
clearInterval(intervalId);
});
const router = useRouter();
const goToMessages = () => {
router.push({ name: 'messages' });
};
const goToScan = () => {
router.push({ name: 'scan' });
};
const goToSearch = (query?: string) => {
if (query) {
router.push({ name: 'search', query: { q: query } });
} else {
router.push({ name: 'search' });
}
};
const goTo = (link: object) => {
router.push(link);
};
</script>
<style scoped>
.home-view {
padding: 20px;
box-sizing: border-box;
background-color: var(--color-background);
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
}
.search-section {
margin-top: 20px;
}
.search-bar-container {
display: flex;
align-items: center;
background-color: #fff;
border-radius: 25px;
padding: 10px 15px;
border: 1px solid #07C160;
cursor: pointer;
max-width: 90%;
margin: 0 auto;
}
.search-bar-container input {
border: none;
outline: none;
background: transparent;
flex-grow: 1;
width: 0;
font-size: 14px;
color: #333;
pointer-events: none;
}
.separator {
width: 1px;
height: 16px;
background-color: #e0e0e0;
margin: 0 12px;
}
.search-button {
color: #07C160;
font-weight: bold;
font-size: 15px;
}
.hot-searches-section {
display: flex;
justify-content: flex-start;
gap: 10px;
margin-top: 10px;
flex-wrap: wrap;
max-width: 90%;
margin-left: auto;
margin-right: auto;
}
.hot-search-tag {
background-color: #f0f0f0;
color: #555;
padding: 5px 12px;
border-radius: 15px;
font-size: 10px;
cursor: pointer;
}
.banner-section {
width: 100%;
margin-top: 15px;
overflow: hidden;
position: relative;
border-radius: 8px;
}
.banner-wrapper {
display: flex;
transition: transform 0.5s ease-in-out;
}
.banner-slide {
flex-shrink: 0;
width: 100%;
height: 150px;
}
.banner-slide img {
width: 100%;
height: 100%;
object-fit: cover;
}
.banner-dots {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
}
.banner-dots span {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.5);
}
.banner-dots span.active {
background-color: white;
}
.greeting {
font-size: 18px;
font-weight: bold;
}
.message-icon img {
width: 24px;
height: 24px;
}
.scan-summary-section {
padding: 20px 0;
}
.scan-summary-card {
display: flex;
align-items: center;
background-color: #ffffff;
border-radius: 12px;
padding: 20px;
border: 1.5px solid #07C160;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.scan-cta-small {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
position: relative;
top: -10px; /* 手动向上微调5个像素以实现视觉居中 */
line-height: 1.4; /* 恢复您满意的行高方案 */
}
.scan-cta-small .scan-icon {
font-size: 48px;
margin-bottom: 0; /* 配合line-height将margin设为0 */
color: #07C160;
}
.scan-cta-small span {
font-size: 14px;
font-weight: bold;
color: #333;
}
.card-separator {
width: 1px;
height: 60px;
background-color: #f0f0f0;
margin: 0 20px;
}
.summary-text {
font-size: 13px;
line-height: 1.6;
text-align: left;
}
.summary-text p {
margin: 0;
}
.summary-text strong {
color: #07C160;
font-size: 15px;
}
.health-dashboard {
display: flex;
flex-direction: column;
gap: 15px;
padding: 15px;
background-color: #f9f9f9;
border-radius: 12px;
border: 1px solid #e0e0e0;
}
.dashboard-item {
background-color: #fff;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.dashboard-item h4 {
font-size: 14px;
color: #333;
margin: 0 0 10px 0;
text-align: left;
}
.nutrition-details {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
text-align: center;
}
.nutrition-details p {
margin: 0 0 5px 0;
font-size: 12px;
color: #666;
}
.nutrition-value {
line-height: 1;
}
.nutrition-value strong {
font-size: 18px;
font-weight: 600;
color: #111827;
}
.nutrition-value span {
font-size: 12px;
color: #6b7280;
margin-left: 2px;
}
.progress-card {
display: flex;
justify-content: space-around;
align-items: center;
}
.progress-ring {
position: relative;
text-align: center;
}
.progress-ring__svg {
transform: rotate(-90deg);
}
.progress-ring__circle-bg {
fill: none;
stroke: #e6e6e6;
stroke-width: 10;
}
.progress-ring__circle {
fill: none;
stroke: #07C160;
stroke-width: 10;
stroke-linecap: round;
transition: stroke-dashoffset 0.35s;
}
.progress-ring__circle.water {
stroke: #007bff;
}
.progress-ring__text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.progress-ring__text p {
margin: 0;
font-size: 12px;
color: #666;
}
.progress-ring__text strong {
font-size: 20px;
color: #333;
}
.progress-ring__text span {
font-size: 12px;
color: #999;
}
.feed-section {
margin-top: 30px;
display: flex;
flex-direction: column;
gap: 20px;
}
.feed-card {
border-radius: 12px;
overflow: hidden;
background-color: var(--color-background-soft);
box-shadow: 0 4px 8px rgba(0,0,0,0.05);
}
.discover-card {
display: flex;
align-items: center;
padding: 15px;
gap: 15px;
}
.discover-card .card-content { flex: 1; }
.discover-card .card-image {
width: 80px;
height: 80px;
border-radius: 8px;
object-fit: cover;
}
.recipe-card { position: relative; color: white; }
.recipe-card .card-image-full {
width: 100%;
height: 180px;
object-fit: cover;
}
.recipe-card .card-overlay {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0) 50%);
padding: 15px;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.card-tag {
background-color: rgba(7, 193, 96, 0.8);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
align-self: flex-start;
margin-bottom: 8px;
}
h3 {
font-size: 16px;
font-weight: bold;
margin: 0 0 5px 0;
}
.discover-card p {
font-size: 14px;
color: var(--color-text-secondary);
margin: 0;
}
</style>

View File

@ -0,0 +1,149 @@
<template>
<div class="result-view">
<header class="top-nav">
<button class="back-btn" @click="goBack">&lt; 返回</button>
<h2 class="title">分析结果</h2>
<button class="share-btn">分享</button>
</header>
<main class="content">
<!-- 1. Product Summary -->
<section class="summary-card">
<img src="https://via.placeholder.com/100" alt="Product Image" class="product-image" />
<h3 class="product-name">XX品牌 儿童成长牛奶</h3>
<p class="brand">某某公司</p>
</section>
<!-- 2. Core Rating -->
<section class="rating-card">
<div class="rating-item">
<span class="label">安全评级</span>
<span class="score-d">D</span>
</div>
<div class="rating-item">
<span class="label">营养评级 (同类中)</span>
<span class="score-mid"></span>
</div>
<div class="conclusion">
<strong>一句话总结:</strong>
<p class="warning">警告含有XX防腐剂和3种人工甜味剂不建议给婴幼儿食用</p>
</div>
</section>
<!-- 3. Personalized Alert -->
<section class="alert-card">
<strong>! 高风险提醒</strong>
<p>触发您宝宝的过敏原: <strong>牛奶</strong></p>
</section>
<!-- 4. Category "Truth" -->
<section class="info-card">
<h4>品类照妖镜</h4>
<p>营销名称: 儿童成长牛奶</p>
<p><strong>法定品类: 含乳饮料</strong></p>
</section>
<!-- 5. Alternatives -->
<section class="alternatives-card">
<h4>为你推荐更好的选择</h4>
<div class="alt-list">
<div class="alt-item">
<img src="https://via.placeholder.com/60" alt="Alt A" />
<p>替代品A</p>
<span class="score-a">A</span>
</div>
<div class="alt-item">
<img src="https://via.placeholder.com/60" alt="Alt B" />
<p>替代品B</p>
<span class="score-a">A</span>
</div>
</div>
</section>
<!-- 6. Full Ingredients -->
<section class="ingredients-card">
<h4>完整成分解读</h4>
<ul>
<li></li>
<li>生牛乳 <span class="allergen">(过敏原)</span></li>
<li>白砂糖</li>
<li><span class="sweetener">阿斯巴甜 (人工甜味剂)</span></li>
<li><span class="preservative">山梨酸钾 (防腐剂)</span></li>
</ul>
<a href="#" class="shi-link">[点击查看SHI评分模型说明]</a>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
const goBack = () => {
router.back();
};
</script>
<style scoped>
.result-view {
padding: 60px 0 20px;
}
.top-nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background-color: #fff;
border-bottom: 1px solid #e5e7eb;
position: fixed;
top: 0;
left: 0;
right: 0;
max-width: 428px;
margin: 0 auto;
z-index: 10;
}
.back-btn, .share-btn {
background: none; border: none; font-size: 16px; cursor: pointer;
}
.title { font-size: 18px; font-weight: bold; }
.content { padding: 0 20px; }
.summary-card, .rating-card, .alert-card, .info-card, .alternatives-card, .ingredients-card {
background-color: #fff;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
border: 1px solid #e5e7eb;
}
.summary-card { text-align: center; }
.product-image { width: 100px; height: 100px; margin-bottom: 10px; }
.product-name { font-size: 18px; margin-bottom: 5px; }
.brand { color: #6b7280; font-size: 14px; }
.rating-card .rating-item { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.rating-item .label { font-size: 16px; }
.rating-item .score-d { font-size: 24px; font-weight: bold; color: red; }
.rating-item .score-mid { font-size: 24px; font-weight: bold; color: orange; }
.rating-card .conclusion { margin-top: 15px; border-top: 1px solid #e5e7eb; padding-top: 10px; }
.conclusion .warning { color: red; }
.alert-card { background-color: #fee2e2; color: #b91c1c; }
.alert-card strong { font-weight: bold; }
.info-card h4, .alternatives-card h4, .ingredients-card h4 { font-size: 16px; margin-bottom: 10px; }
.alt-list { display: flex; gap: 15px; }
.alt-item { text-align: center; font-size: 12px; }
.alt-item img { width: 60px; height: 60px; margin-bottom: 5px; }
.alt-item .score-a { font-size: 16px; font-weight: bold; color: #22c55e; }
.ingredients-card ul { list-style: none; padding: 0; }
.ingredients-card li { margin-bottom: 5px; }
.allergen { color: #b91c1c; }
.sweetener { color: orange; }
.preservative { color: red; }
.shi-link { display: block; margin-top: 10px; text-align: center; color: #22c55e; font-size: 12px; }
</style>

View File

@ -0,0 +1,156 @@
<template>
<div class="scan-view">
<header class="top-nav">
<button class="back-btn" @click="goBack">&lt; 返回</button>
<div class="title"></div> <!-- Empty div for spacing -->
<button class="album-btn" @click="showPlaceholderAlert('从相册选择')">相册</button>
</header>
<main class="camera-area">
<div class="camera-placeholder">
<div class="scan-frame"></div>
<p class="prompt">请将食品条形码或配料表放入框内</p>
</div>
</main>
<footer class="bottom-controls">
<button class="control-btn" @click="showPlaceholderAlert('手电筒')">
<span class="icon">💡</span>
<span class="label">手电筒</span>
</button>
<button class="shutter-btn" @click="simulateScan"></button>
<button class="control-btn" @click="showPlaceholderAlert('手动输入')">
<span class="icon"></span>
<span class="label">手动输入</span>
</button>
</footer>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
const goBack = () => {
router.back();
};
const simulateScan = () => {
console.log('Prototype: Simulating scan...');
// Show a loading indicator or a message
alert('正在识别,请稍候...');
setTimeout(() => {
// Navigate to the result page with a sample ID
router.push({ name: 'result', params: { id: 'prototype123' } });
}, 1500); // Simulate a 1.5-second network delay
};
const showPlaceholderAlert = (featureName: string) => {
alert(`${featureName} 功能正在开发中,敬请期待!`);
};
</script>
<style scoped>
.scan-view {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background-color: #000;
color: white;
}
.top-nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background-color: rgba(0, 0, 0, 0.5);
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
}
.back-btn,
.album-btn {
background: none;
border: none;
color: white;
font-size: 16px;
cursor: pointer;
}
.camera-area {
flex-grow: 1;
display: flex;
justify-content: center;
align-items: center;
}
.camera-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
}
.scan-frame {
width: 80%;
max-width: 300px;
height: 200px;
border: 2px solid #22c55e;
border-radius: 12px;
box-shadow: 0 0 0 100vmax rgba(0, 0, 0, 0.5);
}
.prompt {
margin-top: 20px;
font-size: 14px;
}
.bottom-controls {
display: flex;
justify-content: space-around;
align-items: center;
padding: 20px;
background-color: #000;
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 10;
}
.control-btn {
background: none;
border: none;
color: white;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
font-size: 12px;
}
.control-btn .icon {
font-size: 24px;
margin-bottom: 5px;
}
.shutter-btn {
width: 70px;
height: 70px;
border-radius: 50%;
background-color: white;
border: 4px solid #000;
box-shadow: 0 0 0 4px white;
cursor: pointer;
}
</style>

View File

@ -0,0 +1,158 @@
<template>
<div class="search-result-view">
<header class="top-bar">
<button class="back-btn" @click="goBack">&lt;</button>
<div class="search-input-wrapper">
<span class="search-icon">🔍</span>
<input type="text" v-model="searchQuery" @keyup.enter="performSearch" />
</div>
</header>
<div class="tabs">
<button :class="{ active: activeTab === 'all' }" @click="activeTab = 'all'">全部</button>
<button :class="{ active: activeTab === 'product' }" @click="activeTab = 'product'">产品</button>
<button :class="{ active: activeTab === 'article' }" @click="activeTab = 'article'">文章</button>
<button :class="{ active: activeTab === 'ingredient' }" @click="activeTab = 'ingredient'">成分</button>
</div>
<main class="results-list">
<!-- Product Results -->
<div v-if="activeTab === 'all' || activeTab === 'product'" class="result-item" @click="goToResult('product1')">
<h4>[某品牌纯牛奶]</h4>
<p>安全评级: <span class="score-a">A</span> | 营养评级: <span class="score-high"></span></p>
</div>
<div v-if="activeTab === 'all' || activeTab === 'product'" class="result-item" @click="goToResult('product2')">
<h4>[某品牌儿童牛奶]</h4>
<p>安全评级: <span class="score-c">C</span> | 营养评级: <span class="score-mid"></span></p>
</div>
<!-- Article Results -->
<div v-if="activeTab === 'all' || activeTab === 'article'" class="result-item">
<h4>牛奶过敏的宝宝应该怎么办</h4>
<p class="article-summary">本文将详细介绍牛奶过敏的症状原因以及应对方法...</p>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();
const searchQuery = ref('');
const activeTab = ref('all');
onMounted(() => {
searchQuery.value = (route.query.q as string) || '';
});
const goBack = () => {
router.back();
};
const performSearch = () => {
// In a real app, this would re-trigger the search
console.log('Searching for:', searchQuery.value);
};
const goToResult = (id: string) => {
router.push({ name: 'result', params: { id } });
};
</script>
<style scoped>
.search-result-view {
padding-top: 110px; /* Space for top-bar and tabs */
}
.top-bar {
display: flex;
align-items: center;
padding: 10px 15px;
background-color: #fff;
border-bottom: 1px solid #e5e7eb;
position: fixed;
top: 0;
left: 0;
right: 0;
max-width: 428px;
margin: 0 auto;
z-index: 10;
}
.back-btn {
background: none; border: none; font-size: 20px; cursor: pointer; padding-right: 10px;
}
.search-input-wrapper {
flex-grow: 1;
display: flex;
align-items: center;
background-color: #f3f4f6;
border-radius: 18px;
padding: 8px 12px;
}
.search-icon {
margin-right: 8px;
}
.search-input-wrapper input {
border: none;
outline: none;
background: transparent;
width: 100%;
}
.tabs {
display: flex;
justify-content: space-around;
padding: 10px 0;
background-color: #fff;
border-bottom: 1px solid #e5e7eb;
position: fixed;
top: 57px; /* Position below top-bar */
left: 0;
right: 0;
max-width: 428px;
margin: 0 auto;
z-index: 9;
}
.tabs button {
background: none;
border: none;
font-size: 16px;
color: #6b7280;
cursor: pointer;
padding-bottom: 5px;
border-bottom: 2px solid transparent;
}
.tabs button.active {
color: #22c55e;
border-bottom-color: #22c55e;
}
.results-list {
padding: 0 20px;
}
.result-item {
padding: 15px 0;
border-bottom: 1px solid #e5e7eb;
cursor: pointer;
}
.result-item h4 {
font-size: 16px;
margin-bottom: 5px;
}
.result-item p {
font-size: 14px;
color: #6b7280;
}
.article-summary {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.score-a { color: #22c55e; font-weight: bold; }
.score-c { color: orange; font-weight: bold; }
.score-high { color: #22c55e; }
.score-mid { color: orange; }
</style>

View File

@ -0,0 +1,155 @@
<template>
<div class="search-view">
<header class="top-bar">
<button class="back-btn" @click="goBack">&lt;</button>
<div class="search-input-wrapper">
<span class="search-icon">🔍</span>
<input type="text" placeholder="搜索食品、成分、文章..." v-model="searchQuery" @keyup.enter="performSearch" />
</div>
<button class="search-btn" @click="performSearch">搜索</button>
</header>
<main class="content">
<section class="history-section">
<h4>历史记录</h4>
<div class="tags">
<span v-for="tag in historyTags" :key="tag" class="tag" @click="searchWithTag(tag)">
{{ tag }}
</span>
</div>
</section>
<section class="hot-search-section">
<h4>热门搜索</h4>
<div class="tags">
<span v-for="tag in hotSearchTags" :key="tag" class="tag" @click="searchWithTag(tag)">
{{ tag }}
</span>
</div>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const searchQuery = ref('');
const historyTags = ref(['牛奶', '酱油', '益生菌', '宝宝零食']);
const hotSearchTags = ref(['酸奶评测', '无麸质', '宝宝辅食', '添加剂查询', '预制菜']);
const goBack = () => {
router.back();
};
const performSearch = () => {
const query = searchQuery.value.trim();
if (query) {
console.log(`Prototype: Simulating search for "${query}"...`);
alert(`正在搜索 "${query}", 请稍候...`);
setTimeout(() => {
router.push({ name: 'search-result', query: { q: query } });
}, 1000); // Simulate a 1-second network delay
} else {
alert('请输入搜索内容!');
}
};
const searchWithTag = (tag: string) => {
searchQuery.value = tag;
performSearch();
};
</script>
<style scoped>
.search-view {
padding-top: 60px;
height: 100%;
background-color: #f9fafb;
}
.top-bar {
display: flex;
align-items: center;
padding: 10px 15px;
background-color: #fff;
border-bottom: 1px solid #e5e7eb;
position: fixed;
top: 0;
left: 0;
right: 0;
max-width: 428px;
margin: 0 auto;
z-index: 10;
}
.back-btn {
background: none;
border: none;
font-size: 24px;
font-weight: bold;
cursor: pointer;
padding-right: 10px;
color: #333;
}
.search-input-wrapper {
flex-grow: 1;
display: flex;
align-items: center;
background-color: #f3f4f6;
border-radius: 18px;
padding: 8px 12px;
}
.search-icon {
margin-right: 8px;
color: #6b7280;
}
.search-input-wrapper input {
border: none;
outline: none;
background: transparent;
width: 100%;
font-size: 14px;
}
.search-btn {
background: none;
border: none;
color: #22c55e;
font-size: 16px;
font-weight: bold;
cursor: pointer;
padding-left: 15px;
}
.content {
padding: 20px;
}
.history-section,
.hot-search-section {
margin-bottom: 30px;
}
h4 {
font-size: 16px;
font-weight: bold;
margin-bottom: 15px;
color: #111827;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.tag {
background-color: #f3f4f6;
padding: 8px 15px;
border-radius: 18px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
}
.tag:hover {
background-color: #e5e7eb;
}
</style>

View File

@ -0,0 +1,141 @@
<template>
<div class="set-password-page">
<div class="welcome-section">
<h2>设置密码</h2>
</div>
<div class="form-section">
<div>
<div class="input-group">
<span class="icon">🔒</span>
<input type="password" placeholder="请输入新密码" v-model="newPassword" />
</div>
<div class="input-group">
<span class="icon">🔒</span>
<input type="password" placeholder="请再次输入新密码" v-model="confirmPassword" />
</div>
<button class="submit-btn" @click="setPassword">确认设置</button>
</div>
<p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
</div>
<div class="links-section">
<a href="#" @click.prevent="goBack"> 返回</a>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
const router = useRouter();
const authStore = useAuthStore();
const newPassword = ref('');
const confirmPassword = ref('');
const errorMessage = ref('');
const goBack = () => {
router.back();
};
const setPassword = async () => {
errorMessage.value = '';
if (!newPassword.value || !confirmPassword.value) {
errorMessage.value = '密码不能为空';
return;
}
if (newPassword.value !== confirmPassword.value) {
errorMessage.value = '两次输入的密码不一致';
return;
}
try {
await authStore.setPassword(newPassword.value);
console.log('密码设置成功');
authStore.logout();
alert('密码设置成功!请使用新密码登录。');
router.push({ name: 'password-login' });
} catch (error: any) {
errorMessage.value = error.message || '密码设置失败,请稍后重试。';
}
};
</script>
<style scoped>
.set-password-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 80px 40px 20px;
box-sizing: border-box;
height: 100%;
overflow-y: auto;
}
.welcome-section {
text-align: center;
margin-bottom: 40px;
}
.welcome-section h2 {
font-size: 24px;
margin-bottom: 10px;
}
.form-section {
width: 100%;
max-width: 400px;
}
.input-group {
display: flex;
align-items: center;
margin-bottom: 20px;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 12px 10px;
}
.input-group .icon {
margin-right: 10px;
}
.input-group input {
border: none;
outline: none;
flex-grow: 1;
background: transparent;
}
.submit-btn {
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
background-color: #22c55e;
color: white;
font-size: 16px;
cursor: pointer;
margin-top: 10px;
}
.links-section {
margin-top: 20px;
}
.links-section a {
color: #6b7280;
text-decoration: none;
font-size: 14px;
}
.error-message {
color: #ef4444;
font-size: 14px;
text-align: center;
margin-top: 15px;
}
</style>

375
开发日志.md Normal file
View File

@ -0,0 +1,375 @@
# 开发日志
* **日期:** 2025-07-23
---
## 后端开发 (V1.0) - 用户登录/注册
### 1. 摘要
本次开发完成了 "食话食说" 应用后端的用户登录/注册核心功能。基于产品原型文档,使用 Python FastAPI 框架搭建了后端服务,实现了通过手机号和验证码进行用户身份验证的流程。
### 2. 技术选型
- **Web 框架:** FastAPI
- **数据库:** SQLite
- **ORM:** SQLAlchemy
- **数据校验:** Pydantic
- **认证机制:** JWT (python-jose)
### 3. 项目结构
在项目根目录下创建了 `shihuashishuo-api` 目录,并采用模块化的结构:
- `main.py`: FastAPI 应用主入口,定义 API 路由。
- `database.py`: 数据库连接与会话管理。
- `models.py`: SQLAlchemy 数据库模型定义。
- `schemas.py`: Pydantic 数据校验模型定义。
- `crud.py`: 数据库增删改查操作封装。
- `requirements.txt`: Python 依赖项列表。
### 4. API 设计
实现了两个核心 API 端点:
- **`POST /send-code`**:
- **功能:** 接收手机号,用于请求验证码。
- **实现:** 当前版本仅做手机号格式11位校验并返回成功信息未实际发送短信。
- **`POST /login`**:
- **功能:** 接收手机号和验证码,完成登录或注册。
- **实现:** 校验验证码是否为固定值 "111111"。验证通过后,查询用户是否存在,若不存在则自动创建新用户。最后,生成并返回 JWT 用于客户端的会话保持。
### 5. 遇到的主要挑战及解决方案
- **挑战:** 在项目初始化阶段,由于在多个模块中使用了相对导入 (`from . import ...`),导致直接通过 `python -m uvicorn` 启动应用时出现 `ImportError: attempted relative import with no known parent package` 的错误。
- **解决方案:** 系统性地将 `main.py`, `crud.py`, `models.py` 等文件中的相对导入全部修改为绝对导入 (`import ...`),彻底解决了模块查找路径问题,使服务器能够成功启动和热重载。
- **挑战:** 在实现 `/login` 接口时,忘记在 Pydantic 的 `UserCreate` 模型中添加 `code` 字段,导致无法从请求体中正确解析验证码。
- **解决方案:** 修改了 `schemas.py` 文件,为 `UserCreate` 模型增加了一个可选的 `code` 字段,解决了数据校验层面的问题。
## 2025-07-23: 登录页面功能增强与后端逻辑对接
**文件修改:**
- `shihuashishuo-ui/src/views/通用基础页/LoginView-登录页-2.0.vue`
**主要更新:**
1. **获取验证码逻辑增强:**
- 现在必须输入11位手机号后“获取验证码”按钮才可用。
- 点击后按钮会进入60秒倒计时期间无法再次点击。
2. **登录/注册逻辑实现:**
- 增加了前端登录验证。
- 用户需输入11位手机号和正确的验证码才能登录。
- 为方便测试,验证码暂时硬编码为 `111111`
3. **路由跳转:**
- 验证成功后,页面将自动跳转到应用主页 (`/`)。
**补充说明 (2025-07-23):**
- 根据用户反馈,移除了登录验证失败时的 `alert` 弹窗,改为使用 `console.error` 在控制台输出错误信息,以提升用户体验。
---
### 2025-07-23: 实现扫码功能前后端交互
**1. 后端 API 开发 (FastAPI):**
- **数据库模型 (`models.py`):** 新增 `Food` 模型,用于存储食品信息,包括 `barcode`, `name`, `brand`, `ingredients` 等字段。
- **数据结构 (`schemas.py`):** 添加了 `FoodCreate``Food` Pydantic 模型,用于数据验证和 API 响应。
- **数据库操作 (`crud.py`):** 实现了 `create_food``get_food_by_barcode` 函数,用于处理食品数据的增删改查。
- **API 路由 (`main.py`):**
- 创建了 `POST /api/v1/food/` 接口,用于创建新的食品条目。
- 创建了 `GET /api/v1/food/{barcode}` 接口,用于根据条形码查询食品信息。
**2. 前端页面开发 (Vue.js):**
- **扫码页面 (`ScanView-扫码页.vue`):**
- 修改了 `scan` 函数的逻辑,移除了模拟跳转。
- 现在,点击扫描按钮会触发一个 `async` 函数,该函数:
1. 使用 `fetch` API 调用后端的 `POST /api/v1/food/` 接口,创建一个用于测试的食品样本。
2. 接着调用 `GET /api/v1/food/{barcode}` 接口,获取该食品的详细信息。
3. 成功获取数据后,使用 `vue-router` 将用户导航到结果页面,并将食品的条形码作为参数传递。
4. 添加了错误处理逻辑,以便在 API 调用失败时在控制台输出错误信息。
**3. 数据库:**
- 确认了项目使用 SQLite 数据库,并且连接配置正确。
- 新的 `Food` 表已通过 `models.Base.metadata.create_all(bind=engine)` 成功创建。
**结论:**
本次更新成功打通了从前端扫码到后端数据查询再到前端页面跳转的完整流程,为后续开发食品详情展示页和相关功能奠定了基础。
--------------------------------------------------------------------------------------------------------------------
### 2025-07-23: 实现引导页用户偏好设置功能
**1. 后端 API 开发 (FastAPI):**
- **数据库模型 (`models.py`):** 新增 `UserPreference` 模型,用于存储用户的偏好选项,并通过外键与 `User` 模型关联。
- **数据结构 (`schemas.py`):** 添加了 `UserPreference` 相关的 Pydantic schema并创建了 `OnboardingPreferences` schema 用于统一接收前端引导页的完整数据。
- **数据库操作 (`crud.py`):** 实现了 `create_user_preferences` 函数,用于批量创建或更新用户的偏好设置。在写入新数据前会先清除旧数据,以保证幂等性。
- **API 路由 (`main.py`):**
- 创建了 `get_current_user` 依赖项,通过解析 JWT 来实现用户身份验证。
- 创建了受保护的 `POST /api/v1/users/me/preferences` 接口,用于接收并保存当前登录用户的偏好设置。
**2. 前端页面开发 (Vue.js):**
- **状态管理 (`stores/auth.ts`):**
- 增强了 `useAuthStore`,添加了对 JWT `token` 的存储和管理功能。
- **引导页 (`views/通用基础页/OnboardingView-引导页.vue`):**
- 修改了 `finish` 函数的逻辑,使其成为一个 `async` 函数。
- 现在,点击“完成”按钮会触发一个 API 调用:
1. 从 `authStore` 中获取认证 `token`
2. 收集用户选择的所有偏好,并构造成符合后端要求的 JSON `payload`
3. 使用 `fetch` API 调用后端的 `POST /api/v1/users/me/preferences` 接口,并在请求头中附带 `Authorization: Bearer <token>`
4. 请求成功后,调用 `authStore.completeOnboarding()` 来更新前端的引导完成状态。
5. 最后,使用 `vue-router` 将用户导航到主页。
- 添加了 `try...catch` 块来处理 API 调用可能出现的错误。
**结论:**
本次更新成功打通了从前端引导页收集用户偏好,到后端进行认证、处理并持久化存储的完整流程。这为未来根据用户偏好进行个性化内容推荐奠定了坚实的数据基础。
----------------------------------------------------------------------------------------------------------------------------------------------------
### 2025-07-23: 实现首页搜索功能与历史记录
**1. 后端 API 开发 (FastAPI):**
- **数据库模型 (`models.py`):** 新增 `SearchHistory` 模型,用于存储用户的搜索记录,包含 `query` 和可选的 `user_id`
- **数据结构 (`schemas.py`):** 添加了 `SearchHistory` 相关的 Pydantic schema (`SearchHistoryCreate`, `SearchHistory`)。
- **数据库操作 (`crud.py`):** 实现了 `create_search_history` 函数,用于将搜索记录存入数据库。
- **API 路由 (`main.py`):**
- 创建了 `get_current_user_optional` 依赖项,用于支持可选的用户认证。
- 创建了 `POST /api/v1/search/history` 接口用于接收并保存搜索记录。如果用户已登录则关联用户ID。
**2. 前端页面开发 (Vue.js):**
- **首页 (`views/HomeView-首页-2.0.vue`):**
- 移除了搜索输入框的 `readonly` 属性,允许用户直接输入。
- 使用 `v-model` 绑定了 `searchQuery` 状态。
- 实现了 `handleSearch` 方法,该方法会在用户点击搜索按钮、热门标签或在输入框按回车时触发。
- `handleSearch` 方法会:
1. 调用后端的 `/api/v1/search/history` 接口来保存搜索词,如果用户已登录,则请求会附带认证 Token。
2. 使用 `vue-router` 将用户导航到搜索结果页 (`/search-result`),并将搜索词作为查询参数 `q` 传递。
**结论:**
本次更新实现了完整的首页搜索功能。用户现在可以直接在首页进行搜索,搜索行为会被记录到数据库中(无论是否登录),并能正确跳转到搜索结果页面。
------------------------------------------------------------------------------------------------------------------------------------------
### 2025-07-24实现登录后引导及全局路由守卫
**开发者:** 首席开发 (LD)
**任务描述:**
为了优化新用户体验本次更新实现了用户登录后首先进入引导页Onboarding的功能而不是直接进入应用首页。同时重构了全局路由守卫以统一处理认证、授权和页面跳转逻辑。
**主要变更:**
1. **重构全局路由守卫 (`src/router/index.ts`):**
* 在 `router.beforeEach` 中实现了新的导航逻辑,以集中管理所有路由跳转。
* **未登录用户:** 访问任何需要认证的页面时,将被自动重定向到登录页。
* **已登录但未完成引导的用户:** 访问除引导页外的任何页面,都将被强制重定向到 `/onboarding`,确保新用户完成初始化设置。
* **已登录且已完成引导的用户:** 访问登录页或引导页时,将被自动重定向到应用首页 (`/app/home`),避免重复操作。
2. **简化登录逻辑 (`src/views/通用基础页/LoginView-登录页-2.0.vue`):**
* 移除了登录成功后的条件跳转逻辑。
* 现在,登录成功后只需调用 `router.push({ name: 'home' })`,后续的页面重定向完全由新的全局路由守卫处理,简化了组件代码并提高了逻辑的内聚性。
**状态管理 (`src/stores/auth.ts`):**
* 确认 `useAuthStore` 中的 `isLoggedIn``hasCompletedOnboarding` 状态能够满足新逻辑的需求,未做修改。
**成果:**
通过本次更新,应用的用户流更加清晰和健壮。新用户能够被平滑地引导完成初次设置,而老用户则可以无缝地进入应用核心功能区。路由逻辑的集中化管理也为未来增加新的页面和路由规则提供了便利。
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
### 2025-07-24 (修正):修正登录后跳转逻辑
**开发者:** 首席开发 (LD)
**任务描述:**
根据用户反馈,对之前实现的登录流程进行修正。原有的实现虽然建立了全局路由守卫,但在登录页直接跳转到首页,导致新用户无法直接进入引导页,流程体验不佳。
**主要变更:**
1. **还原登录页跳转逻辑 (`src/views/通用基础页/LoginView-登录页-2.0.vue`):**
* 恢复了 `mainLogin` 函数中的条件跳转逻辑。
* 现在,当用户登录成功后,程序会检查 `authStore.hasCompletedOnboarding` 的状态:
* 如果为 `false`(新用户),则直接使用 `router.push({ name: 'onboarding' })` 导航到引导页。
* 如果为 `true`(老用户),则使用 `router.push({ name: 'home' })` 导航到首页。
2. **全局路由守卫 (`src/router/index.ts`):**
* 保留了重构后的全局路由守卫。它将继续作为保障处理所有边缘情况下的路由跳转例如用户手动输入URL等场景确保系统的健壮性。
**成果:**
通过本次修正,新用户在登录后将直接、顺畅地进入引导页,符合预期的用户体验。同时,保留的全局路由守卫依然为整个应用的导航提供了坚实的保障。
---
### 2025-07-24: 用户认证与数据管理流程总结
**开发者:** 首席开发 (LD)
**任务描述:**
本文档旨在总结当前已实现的用户认证、密码管理及偏好设置功能的完整技术流程,涵盖数据存储、后端接口和前端实现。
**1. 数据存储 (数据库模型):**
所有用户相关数据都存储在后端的 SQLite 数据库 (`shihuashishuo.db`) 中,通过 SQLAlchemy ORM 进行管理。
* **`User` 表 (`shihuashishuo-api/models.py`):**
* `id`: 用户唯一标识。
* `phone_number`: 用户手机号,唯一且作为主要登录凭据。
* `hashed_password`: 存储用户加密后的密码,允许为空以兼容纯验证码登录的用户。
* `created_at`: 用户创建时间。
* **`UserPreference` 表 (`shihuashishuo-api/models.py`):**
* 通过 `user_id` 外键与 `User` 表关联。
* 用于存储用户的个性化偏好,如“关注”和“过敏原”。
**2. 后端核心 API (`shihuashishuo-api/main.py`):**
后端使用 FastAPI 框架,所有认证相关的接口都位于 `/api/v1/auth` 路径下。
* **`POST /send-verification-code`**: 发送手机验证码(当前为模拟)。
* **`POST /login`**: **手机验证码登录**。验证通过后,如果用户不存在则自动注册,并返回 JWT (JSON Web Token)。
* **`POST /login/password`**: **密码登录**。验证手机号和密码,成功后返回 JWT。
* **`PUT /users/me/password`**: **设置/修改密码**。这是一个受保护的接口,必须在请求头中提供有效的 JWT 才能调用。
* **`POST /users/me/preferences`**: **保存用户偏好**。同样是受保护接口,用于将在引导页选择的偏好存入数据库。
**3. 前端核心实现:**
* **状态管理 (`shihuashishuo-ui/src/stores/auth.ts`):**
* 使用 Pinia (`useAuthStore`) 统一管理用户的登录状态 (`isLoggedIn`)、JWT (`token`) 和引导页完成状态 (`hasCompletedOnboarding`)。
* `token``hasCompletedOnboarding` 状态会持久化到浏览器的 `localStorage` 中。
* 提供了 `passwordLogin``setPassword` 等 actions封装了与后端 API 的所有交互。
* **页面路由与导航守卫 (`shihuashishuo-ui/src/router/index.ts`):**
* **页面路由:**
* `/login`: 手机验证码登录页。
* `/password-login`: 密码登录页。
* `/set-password`: 设置/修改密码页。
* `/onboarding`: 新用户引导页。
* **导航守卫 (`router.beforeEach`):**
* 实现了全局路由守卫,用于权限控制。
* 未登录用户访问受保护页面时,会被重定向到登录页。
* 已登录但未完成引导流程的用户,会被强制导航到引导页。
**总结:**
当前系统已形成一个完整的闭环:用户可以通过手机验证码或密码进行登录,登录成功后获得 JWT 用于身份认证。新用户会被引导完成偏好设置,所有偏好和密码信息都会被安全地存储在后端数据库中。
---
### 2025-07-24: 大规模后端功能扩展
**开发者:** 首席开发 (LD)
**任务描述:**
根据产品需求文档PRD对后端服务进行大规模的功能扩展以支持项目第一、二、三阶段的核心功能。本次开发旨在构建一个全面的、可扩展的后端系统为前端提供强大的数据支持。
**技术栈:**
- **框架:** FastAPI
- **数据库:** SQLite (通过 SQLAlchemy ORM)
- **数据校验:** Pydantic
**主要变更与实现模块:**
1. **健康档案系统 (`Family` & `HealthProfile`):**
* **数据库 (`models.py`):**
* 新增 `FamilyMember` 模型,与 `User` 建立一对多关系,用于管理家庭成员。
* 新增 `HealthProfile` 模型,与 `FamilyMember` 关联,用于存储每个成员的过敏原、健康状况等个性化数据。
* **数据接口 (`schemas.py`):** 创建了 `FamilyMember``HealthProfile` 相关的Pydantic Schemas。
* **业务逻辑 (`crud.py`):** 实现了对家庭成员和健康档案的完整CRUD操作。
* **API (`main.py`):** 新增 `/api/v1/family` 路由,提供了创建、查询家庭成员及更新其健康档案的受保护端点。
2. **增强版食品数据库 (`Food`, `Ingredient`, `Additive`):**
* **数据库 (`models.py`):**
* 重构了 `Food` 模型,增加了安全评级、营养评级等字段。
* 新增 `Ingredient``Additive` 模型。
* 通过多对多关联表,建立了食品与成分、添加剂之间的复杂关系。
* **数据接口 (`schemas.py`):** 创建了 `FoodDetails`, `Ingredient`, `Additive` 等详细的Pydantic Schemas。
* **API (`main.py`):** 新增 `GET /api/v1/foods/{barcode}/details` 端点,能够返回包含成分和添加剂详情的完整食品信息,并支持可选的用户认证以实现未来的个性化风险提示。
3. **内容中心 - 健康知食 (`Article`):**
* **数据库 (`models.py`):** 新增 `Article` 模型,用于存储资讯和百科文章。
* **数据接口 (`schemas.py`):** 创建了 `Article` 相关的Pydantic Schemas。
* **业务逻辑 (`crud.py`):** 实现了文章的创建和按分类查询的功能。
* **API (`main.py`):** 新增 `/api/v1/articles` 路由,提供了获取文章列表和详情的公共端点。
4. **健康厨房 - 智能食谱 (`Recipe`):**
* **数据库 (`models.py`):**
* 新增 `Recipe` 模型,用于存储食谱信息。
* 新增 `RecipeIngredient` 关联对象,用于记录食谱所需的食材及其用量。
* **数据接口 (`schemas.py`):** 创建了 `Recipe``RecipeIngredient` 相关的Pydantic Schemas。
* **业务逻辑 (`crud.py`):** 实现了食谱的创建和详情查询功能。
* **API (`main.py`):** 新增 `/api/v1/recipes` 路由,提供了创建、查询和个性化推荐食谱的端点。
5. **互动社区 (`Post`, `Comment`, `Like`):**
* **数据库 (`models.py`):** 新增 `Post` (评价), `Comment` (评论), `Like` (点赞) 模型,并与 `User``Food` 模型建立关联。
* **数据接口 (`schemas.py`):** 创建了社区功能相关的Pydantic Schemas。
* **业务逻辑 (`crud.py`):** 实现了发帖、评论、点赞/取消点赞的核心逻辑。
* **API (`main.py`):** 新增 `/api/v1` 下的社区路由,提供了发布评价、获取评论、点赞等受保护的互动端点。
6. **健康商城 (`Product`, `Order`, `Cart`):**
* **数据库 (`models.py`):** 新增 `Product`, `Order`, `OrderItem`, `CartItem` 模型,为第三阶段的电商功能奠定了数据基础。
* **数据接口 (`schemas.py`):** 创建了商城功能相关的Pydantic Schemas。
* **业务逻辑 (`crud.py`):** 实现了商品查询、购物车管理和订单创建的基础逻辑。
* **API (`main.py`):** 新增 `/api/v1/mall` 路由,提供了浏览商品、管理购物车和下单的端点。
**成果:**
本次更新成功地将一个基础的用户认证系统扩展成了一个功能全面、层次清晰、高度可扩展的综合性后端服务。所有核心模块均已完成从数据库到API的贯通为前端开发提供了稳定、完整的数据支持也为项目的长远发展打下了坚实的基础。
---
### 2025-07-24: 弥补后端功能差距与重构
**开发者:** 首席开发 (LD)
**任务描述:**
本次开发旨在根据产品需求文档识别并补充之前规划中遗漏的8个关键后端功能模块。同时对现有的点赞功能进行重构以提升系统的通用性和可维护性。
**主要变更与实现模块:**
1. **OAuth第三方登录支持:**
* **数据库 (`models.py`):** 新增 `OAuthAccount` 模型,用于存储第三方平台的`openid`,并与`User`模型建立一对多关联。
* **数据接口 (`schemas.py`):** 创建了 `OAuthAccount` 相关的Pydantic Schemas。
* **业务逻辑 (`crud.py` & `security.py`):** 实现了`get_or_create_oauth_user`函数,以及模拟的`authenticate_wechat_user`逻辑,完成了从`code`到本地用户的身份关联流程。
* **API (`main.py`):**
* 新增 `POST /api/v1/auth/wechat/callback` 端点,用于处理微信登录回调。
* 重构了 `get_current_user``get_current_user_optional` 依赖使其能够通过JWT中的`user.id`来识别用户兼容了手机号和OAuth两种登录方式。
2. **智能食谱推荐 (`“冰箱有什么”`):**
* **业务逻辑 (`crud.py`):** 实现了 `suggest_recipes_by_ingredients` 函数该函数能根据用户提供的食材ID列表查询出匹配度最高的食谱。
* **API (`main.py`):** 新增 `POST /api/v1/recipes/suggest-by-ingredients` 端点,将推荐功能暴露给前端。
3. **用户提交新食品:**
* **数据库 (`models.py`):** 新增 `SubmittedFood` 模型,用于暂存用户提交的食品信息,并包含审核状态`status`字段。
* **数据接口 (`schemas.py`):** 创建了 `SubmittedFood` 相关的Pydantic Schemas。
* **业务逻辑 (`crud.py`):** 实现了 `create_submitted_food` 函数。
* **API (`main.py`):** 新增 `POST /api/v1/foods/submit` 受保护端点,允许用户提交新食品。
4. **社区话题功能:**
* **数据库 (`models.py`):**
* 新增 `Topic` 模型,用于定义社区话题。
* 在 `Post` 模型中添加了 `topic_id` 外键和 `topic` 关系,将帖子与话题关联。
* **数据接口 (`schemas.py`):** 创建了 `Topic` 相关的Pydantic Schemas。
* **业务逻辑 (`crud.py`):** 实现了 `get_topics``get_posts_by_topic` 函数。
* **API (`main.py`):** 新增 `GET /api/v1/topics``GET /api/v1/topics/{topic_id}/posts` 端点,用于获取话题列表和话题下的帖子。
5. **收藏/点赞功能重构:**
* **数据库 (`models.py`):**
* **移除了**原有的 `Like` 模型。
* 新增了通用的 `Favorite` 模型,通过 `entity_type``entity_id` 字段支持对多种内容(帖子、商品、食谱等)的收藏,并通过 `tag` 字段区分收藏类型(如'like', 'favorite')。
* 更新了 `User``Post` 模型中的 relationship`favorites` 替换了 `likes`
* **数据接口 (`schemas.py`):** 移除了 `Like` Schema新增了 `Favorite` 相关 Schemas并更新了 `Post` Schema。
* **业务逻辑 (`crud.py`):** 移除了 `toggle_like` 函数,并实现了通用的 `toggle_favorite` 函数。
**遇到的主要挑战及解决方案:**
* **任务依赖管理:** 在执行过程中多次遇到任务被前置依赖阻塞的情况。通过逐级追溯并完成最基础的依赖任务,成功疏通了任务链。
* **代码与规划不一致:** 发现多个模型相关的任务(如`Food`, `Recipe`, `Post`等)在代码库中已被实现。通过仔细的代码审查,避免了重复开发,直接将这些任务验证并关闭。
* **SQLAlchemy关系错误:** 在实现社区话题功能时,由于错误地将外键添加到了不相关的模型上,导致了`NoForeignKeysError`。通过分析错误日志,定位并修正了模型定义,最终解决了问题。
**成果:**
本次大规模的更新和重构,极大地完善了后端的功能版图,使其更贴近产品需求。通过引入通用模型(如`Favorite`),提升了代码的复用性和系统的可扩展性,为后续的开发工作奠定了更坚实的基础。

262
设计.md Normal file
View File

@ -0,0 +1,262 @@
# “食话食说”项目设计文档
## 文档摘要
本文档是“食话食说”项目的综合技术设计与分析报告。通过对项目前后端代码库的系统性梳理本文档详细记录了项目的整体架构、前后端技术栈、数据库模型、API接口规范、前端路由与状态管理模式以及核心的用户交互流程。旨在为项目的后续开发、维护和团队协作提供一份清晰、准确、全面的技术参考。
## 目录
1. [项目总体分析](#1-项目总体分析)
1. [项目简介](#11-项目简介)
2. [技术栈](#12-技术栈)
2. [后端 (API) 设计](#2-后端-api-设计)
1. [数据库模型 (SQLAlchemy)](#21-数据库模型-sqlalchemy)
2. [API 数据结构 (Pydantic Schemas)](#22-api-数据结构-pydantic-schemas)
3. [API 端点 (Endpoints)](#23-api-端点-endpoints)
4. [业务逻辑层 (CRUD)](#24-业务逻辑层-crud)
5. [安全与认证机制](#25-安全与认证机制)
3. [前端 (UI) 设计](#3-前端-ui-设计)
1. [路由管理 (Vue Router)](#31-路由管理-vue-router)
2. [状态管理 (Pinia)](#32-状态管理-pinia)
3. [组件结构与 API 交互](#33-组件结构与-api-交互)
4. [核心功能与交互流程](#4-核心功能与交互流程)
1. [流程一:手机验证码登录 / 注册](#41-流程一手机验证码登录--注册)
---
## 1. 项目总体分析
### 1.1. 项目简介
本文档旨在对“食话食说”项目进行全面的技术分析和设计记录。项目采用前后端分离架构,前端基于 Vue.js 3 构建用户界面,后端使用 FastAPI 提供 API 服务,实现了包括用户认证、食品信息查询、扫码识别等核心功能。
### 1.2. 技术栈
#### 1.2.1. 前端 (shihuashishuo-ui)
- **核心框架:** Vue.js 3 (`vue: ^3.5.17`)
- **路由管理:** Vue Router (`vue-router: ^4.5.1`)
- **状态管理:** Pinia (`pinia: ^3.0.3`)
- **开发与构建工具:** Vite (`vite: npm:rolldown-vite@^7.0.9`)
- **编程语言:** TypeScript (`typescript: ~5.8.0`)
- **单元测试:** Vitest (`vitest: ^3.2.4`)
- **端到端测试:** Playwright (`@playwright/test: ^1.53.1`)
- **代码规范与格式化:** ESLint, Prettier, Oxlint
#### 1.2.2. 后端 (shihuashishuo-api)
- **核心框架:** FastAPI
- **Web 服务器:** Uvicorn
- **ORM (对象关系映射):** SQLAlchemy
- **数据校验:** Pydantic
- **安全与认证:** python-jose[cryptography], passlib, bcrypt
- **文件上传支持:** python-multipart
- **数据库:** SQLite (根据 `shihuashishuo.db` 文件推断)
## 2. 后端 (API) 设计
### 2.1. 数据库模型 (SQLAlchemy)
数据库模型定义在 `shihuashishuo-api/models.py` 中,使用 SQLAlchemy ORM。
| 表名 (`__tablename__`) | 模型类 (`class`) | 描述 | 关键字段 |
| :--- | :--- | :--- | :--- |
| `users` | `User` | 存储用户信息 | `id`, `phone_number`, `hashed_password`, `avatar_url` |
| `user_preferences` | `UserPreference` | 存储用户的个人偏好,如过敏原、健康状况等 | `id`, `category`, `value`, `user_id` |
| `foods` | `Food` | 存储食品的基本信息 | `id`, `barcode`, `name`, `brand`, `ingredients` |
| `search_history` | `SearchHistory` | 记录用户的搜索历史 | `id`, `query`, `user_id` |
### 2.2. API 数据结构 (Pydantic Schemas)
API的数据输入输出规范定义在 `shihuashishuo-api/schemas.py` 中,使用 Pydantic 模型进行数据校验和序列化。
- **用户相关 (`User*`):**
- `UserBase`: 用户基础信息(手机号、头像)。
- `UserCreate`: 用于创建用户,包含密码。
- `UserCodeLogin`: 用于验证码登录。
- `UserPasswordLogin`: 用于密码登录。
- `User`: API返回的用户信息包含id和创建时间。
- `PasswordSet`: 用于设置或修改密码。
- **认证相关 (`Token`):**
- `Token`: 登录成功后返回的 `access_token`
- **食品相关 (`Food*`):**
- `FoodBase`: 食品基础信息。
- `FoodCreate`: 用于创建食品条目。
- `Food`: API返回的食品信息。
- **用户偏好相关 (`UserPreference*`, `OnboardingPreferences`):**
- `UserPreferenceBase`: 用户偏好基础信息。
- `UserPreferenceCreate`: 用于创建用户偏好。
- `UserPreference`: API返回的用户偏好信息。
- `OnboardingPreferences`: 用于处理引导流程中用户提交的批量偏好。
- **搜索历史相关 (`SearchHistory*`):**
- `SearchHistoryBase`: 搜索历史基础信息。
- `SearchHistoryCreate`: 用于创建搜索历史。
- `SearchHistory`: API返回的搜索历史信息。
### 2.3. API 端点 (Endpoints)
API 的主要逻辑和路由定义在 `shihuashishuo-api/main.py` 中。
#### 2.3.1. 认证路由 (`/api/v1/auth`)
| 方法 | 路径 | 函数名 | 描述 | 是否需要认证 |
| :--- | :--- | :--- | :--- | :--- |
| `POST` | `/send-verification-code` | `send_verification_code` | 发送手机验证码(当前为模拟) | 否 |
| `POST` | `/login` | `login` | 使用手机和验证码登录或注册 | 否 |
| `POST` | `/login/password` | `login_with_password` | 使用手机和密码登录 | 否 |
| `PUT` | `/users/me/password` | `set_password_for_current_user` | 为当前登录用户设置密码 | 是 |
#### 2.3.2. 其他核心路由
| 方法 | 路径 | 函数名 | 描述 | 是否需要认证 |
| :--- | :--- | :--- | :--- | :--- |
| `GET` | `/` | `read_root` | API 根路径,返回欢迎信息 | 否 |
| `GET` | `/api/v1/users/check-phone-existence/` | `check_phone_existence` | 检查手机号是否已注册 | 否 |
| `POST` | `/api/v1/food/` | `create_food_entry` | 创建新的食品条目 | 否 |
| `GET` | `/api/v1/food/{barcode}` | `read_food_by_barcode` | 根据条形码查询食品信息 | 否 |
| `POST` | `/api/v1/users/me/preferences` | `update_user_preferences` | 为当前用户批量更新偏好设置 | 是 |
| `POST` | `/api/v1/search/history` | `create_search_history_entry` | 创建一条搜索历史记录 | 可选 |
### 2.4. 业务逻辑层 (CRUD)
数据库的增、删、改、查CRUD操作被封装在 `shihuashishuo-api/crud.py` 文件中,供 API 路由调用。
- **`get_user_by_phone_number`**: 根据手机号查询用户。
- **`create_user`**: 创建新用户,如果提供了密码,则进行哈希存储。
- **`authenticate_user`**: 验证用户密码是否正确。
- **`set_user_password`**: 为指定用户设置或更新密码。
- **`get_food_by_barcode`**: 根据条形码查询食品。
- **`create_food`**: 创建新的食品条目。
- **`create_user_preferences`**: 批量创建用户偏好,会先删除旧记录。
- **`create_search_history`**: 创建搜索历史记录。
### 2.5. 安全与认证机制
项目的认证与安全相关功能定义在 `shihuashishuo-api/security.py` 中。
- **认证方式:** 基于 JWT (JSON Web Tokens) 的 Bearer Token 认证。
- **Token 生成:**
- 使用 `python-jose` 库生成 HS256 签名的 JWT。
- 访问令牌默认有效期为 30 分钟 (`ACCESS_TOKEN_EXPIRE_MINUTES`)。
- **密码处理:**
- 使用 `passlib``bcrypt` 算法对用户密码进行哈希处理和验证。
- **依赖注入:**
- `get_current_user`: 一个 FastAPI 依赖项,用于从请求头中解析 Token验证并获取当前登录的用户信息。需要认证的路由都会依赖此函数。
- `get_current_user_optional`: `get_current_user` 的可选版本,在没有提供 Token 时返回 `None` 而非抛出异常。
- **安全风险提示:**
- `SECRET_KEY` 目前是硬编码在代码中的,在生产环境中应使用环境变量等更安全的方式进行管理。
## 3. 前端 (UI) 设计
### 3.1. 路由管理 (Vue Router)
前端路由配置在 `shihuashishuo-ui/src/router/index.ts` 中,负责管理页面导航。
- **路由模式:** HTML5 History 模式 (`createWebHistory`)。
- **主要路由分组:**
- **通用基础页:** 包含闪屏页 (`/splash`)、登录页 (`/login`)、引导页 (`/onboarding`) 等独立页面。
- **主应用布局 (`/app`):** 包含一个带有底部导航栏的 `MainLayout`,嵌套了首页 (`/home`)、发现 (`/discover`)、我的 (`/me`) 等核心功能页面。
- **独立功能页:** 包含扫码页 (`/scan`)、搜索页 (`/search`)、结果页 (`/result/:id`) 等无底部导航栏的页面。
- **全局路由守卫 (`router.beforeEach`):**
- **认证检查:** 保护需要登录才能访问的页面,未登录用户会被重定向到登录页。
- **引导流程检查:** 已登录但未完成引导流程的用户会被强制导向引导页。
- **状态一致性:** 防止已登录用户重复访问登录页或引导页。
### 3.2. 状态管理 (Pinia)
全局状态管理由 Pinia 实现,核心是 `shihuashishuo-ui/src/stores/auth.ts`
- **持久化:** 关键认证状态(如 `token`, `isLoggedIn`)通过 `localStorage` 进行持久化,防止刷新页面后状态丢失。
- **核心状态 (`state`):**
- `token`: 存储用户的 JWT 访问令牌。
- `isLoggedIn`: 布尔值,表示用户当前的登录状态。
- `hasCompletedOnboarding`: 布尔值,表示用户是否已完成首次引导流程。
- `isNewUser`: 布尔值,用于判断用户在登录后是否为新注册用户,以决定是否跳转到引导页。
- **核心动作 (`actions`):**
- `setToken`: 登录成功后调用,保存 token 并更新状态。
- `logout`: 清除所有认证状态和本地存储。
- `completeOnboarding`: 标记引导流程完成。
- `setPassword`, `passwordLogin`: 封装了调用后端认证相关 API 的 `fetch` 请求。
### 3.3. 组件结构与 API 交互
- **组件化:** 项目遵循 Vue 的组件化思想,将页面拆分为可复用的组件。页面级组件存放在 `src/views`,通用组件可以放在 `src/components` (当前项目暂未大量使用)。
- **页面结构:**
- **`通用基础页`**: 存放登录、注册、引导、闪屏等与核心业务逻辑解耦的页面。
- **`核心体验页`**: 存放与食品查询相关的核心功能页面。
- **根目录 `views`**: 存放构成主应用导航的五个一级页面。
- **API 交互模式 (以 `PasswordLoginView.vue` 为例):**
1. **视图层 (View):** 负责渲染 UI 和接收用户输入(如手机号、密码)。
2. **触发动作:** 用户操作(如点击登录按钮)会调用一个在 `<script setup>` 中定义的方法。
3. **调用状态管理 (Store):** 该方法不直接发起 API 请求,而是调用 Pinia Store (`authStore`) 中对应的 `action` (如 `authStore.passwordLogin()`),并将用户输入作为参数传入。
4. **Store 执行 API 请求:**`authStore``action` 内部,使用 `fetch` 向后端发送请求,并处理响应。
5. **更新状态:** API 请求成功后,`action` 会更新 Store 中的状态(如 `token`, `isLoggedIn`)。
6. **响应式更新:** 由于组件依赖于 Store 中的状态,状态的改变会通过 Vue 的响应式系统自动反馈到 UI 上。
7. **导航:** `action` 执行完毕后,视图层根据返回结果或更新后的状态,使用 `vue-router` 进行页面跳转。
这种模式将视图渲染、状态管理和 API 通信清晰地分离开来,是 Vue 生态中的最佳实践之一。
## 4. 核心功能与交互流程
本章节将整合前后端的分析结果,对关键的用户功能流程进行详细描述。
### 4.1. 流程一:手机验证码登录 / 注册
这是应用最核心的用户入口流程,涵盖了新用户注册和老用户登录两种情况。
#### 4.1.1. 流程描述
1. **用户输入手机号:** 用户在登录页 (`LoginView`) 输入手机号码,点击“发送验证码”。
2. **前端请求验证码:** 前端调用后端 `POST /api/v1/auth/send-verification-code` 接口。
3. **后端发送验证码:** 后端收到请求,(当前为模拟)生成一个验证码(如 "111111"并返回给前端。在生产环境中此处应调用短信服务商API。
4. **用户输入验证码:** 用户收到验证码后,在页面上输入,并点击“登录”。
5. **前端执行登录:**
- 前端调用 Pinia Store 中的 `authStore.setToken` (或类似逻辑),该 `action` 会向后端 `POST /api/v1/auth/login` 发送请求,请求体包含手机号和验证码。
6. **后端处理登录:**
- 后端验证验证码是否正确。
- 验证通过后,调用 `crud.get_user_by_phone_number` 检查该手机号是否存在。
- **如果用户不存在 (新用户):** 调用 `crud.create_user` 创建一个新用户记录,并标记 `is_new_user = True`
- **如果用户已存在 (老用户):** 直接获取该用户信息,`is_new_user = False`
- 使用 `security.create_access_token` 为该用户生成一个 JWT。
- 将 `access_token``is_new_user` 标志返回给前端。
7. **前端处理响应:**
- `authStore` 将收到的 `token``is_new_user` 状态存入 `localStorage`
- **如果是新用户:** `isNewUser` 状态为 `true`,路由守卫将用户重定向到引导页 (`/onboarding`)。
- **如果是老用户:** `isNewUser` 状态为 `false`,路由守卫将用户重定向到主页 (`/app/home`)。
#### 4.1.2. 序列图 (Mermaid.js)
```mermaid
sequenceDiagram
participant User as 用户
participant Frontend as 前端 (Vue)
participant Backend as 后端 (FastAPI)
participant DB as 数据库
User->>Frontend: 1. 输入手机号,点击发送验证码
Frontend->>Backend: 2. POST /api/v1/auth/send-verification-code
Backend-->>Frontend: 3. 返回模拟验证码 "111111"
User->>Frontend: 4. 输入验证码,点击登录
Frontend->>Backend: 5. POST /api/v1/auth/login (含手机号和验证码)
Backend->>Backend: 6.1. 校验验证码
Backend->>DB: 6.2. get_user_by_phone_number()
alt 新用户
DB-->>Backend: 6.3. 用户不存在
Backend->>DB: 6.4. create_user()
DB-->>Backend: 6.5. 返回新用户信息
Backend->>Backend: 6.6. 生成JWT, is_new_user = true
else 老用户
DB-->>Backend: 6.3. 返回用户信息
Backend->>Backend: 6.6. 生成JWT, is_new_user = false
end
Backend-->>Frontend: 7. 返回 { access_token, is_new_user }
Frontend->>Frontend: 8. 保存Token, 更新Store状态
alt is_new_user is true
Frontend->>User: 9. 重定向到引导页 (/onboarding)
else is_new_user is false
Frontend->>User: 9. 重定向到主页 (/app/home)
end