fix: 修复 VoiceController Map.of 兼容性 + ExploreController 参数不匹配

- VoiceController: Map.of() -> Collections.singletonMap() 兼容 Java 8
- ExploreController: 补齐 takeoutService.roll() 缺失的 taste/priceRange/allergies 参数

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
王鹏
2026-05-08 20:02:27 +08:00
commit 802b4ba229
98 changed files with 5761 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
# IDE
.idea/
*.iml
# Build output
target/
build/
dist/
# OS
.DS_Store
Thumbs.db
# WeChat
miniapp/project.private.config.json

108
CLAUDE.md Normal file
View File

@@ -0,0 +1,108 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project: ChowBox (吃啥盲盒)
WeChat Mini Program for solving "what to eat" decisions. Three modes:
- **Takeout Box (外卖盲盒)**: Random nearby quality delivery recommendation, jump to Meituan/Ele.me to order.
- **Fridge Box (冰箱盲盒)**: Input ingredients, get recipe matches ranked by match %, highlight missing items.
- **Explore Box (探店盲盒)**: Random nearby dine-in restaurant discovery, navigate via map apps.
**Status**: Pre-development planning. Zero code written. Docs in `doc/` are the source of truth.
### Tech Stack
- **Frontend**: Native WeChat Mini Program (WXML + WXSS + JavaScript), WeChat Developer Tools
- **Backend**: Java 8 + Spring Boot 2.x (classic MVC)
- **Database**: MySQL 5.7+ (primary) + Redis (cache)
- **External APIs**: Amap Web API (POI search, primary), Tencent Location Service (backup), WeChat Open APIs (navigation, subscribe messages)
### Design Tokens
- Primary: `#FF6A3D` (orange), Background: `#FFFBF4` (warm white), Text: `#333`, Accent: `#4CAF50`
- Card border-radius: 16px, Button border-radius: 24px
- Font: WeChat default Chinese font, bold titles at 16-20px
### Architecture
- Bottom tab navigation: Home (盲盒大厅), Records (记录), My (我的)
- Data flow: user location → backend → map POI API (cached by geo-grid + time) → filtering/weighting → weighted random selection → result
- POI cache: 1h client-side, 6h server-side for hot zones
- Box opening animation: 1.8-2.2s, 3-act (shake → lid open with glow → card reveal), Lottie + CSS keyframes, degrade to CSS-only on low-end devices (see `doc/box.md`)
### Key Docs
- `doc/plan.md` — Full product plan v1.0 (features, architecture, roadmap, budget)
- `doc/box.md` — Opening animation storyboard and dev parameters
- `doc/ui.md` — ASCII wireframes for all 6 core pages
### Roadmap
- **Phase 1 (MVP, 4-6 weeks)**: Scaffold mini program + Spring Boot backend, MySQL schema, Amap POI integration for takeout box, 100 recipes, basic box animation
- **Phase 2 (3-4 weeks)**: Explore box, voice ingredient input, user feedback weighting, 500+ recipes
- **Phase 3 (2-3 weeks)**: User preferences, share cards, subscribe messages, perf optimization
- **Phase 4**: Testing, WeChat review submission, iteration
No build/lint/test commands exist yet.
---
## Behavioral Guidelines
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
---
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.

69
backend/pom.xml Normal file
View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
</parent>
<groupId>com.chowbox</groupId>
<artifactId>chowbox-backend</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.5</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,13 @@
package com.chowbox;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan("com.chowbox.mapper")
public class ChowBoxApplication {
public static void main(String[] args) {
SpringApplication.run(ChowBoxApplication.class, args);
}
}

View File

@@ -0,0 +1,17 @@
package com.chowbox.config;
import com.chowbox.model.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ApiResponse<?> handleException(Exception e) {
log.error("Unhandled exception: {}", e.getMessage(), e);
return ApiResponse.fail(500, "服务器出了点小差,请稍后再试");
}
}

View File

@@ -0,0 +1,21 @@
package com.chowbox.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
}

View File

@@ -0,0 +1,16 @@
package com.chowbox.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("*")
.allowedMethods("GET", "POST", "OPTIONS")
.allowedHeaders("*");
}
}

View File

@@ -0,0 +1,31 @@
package com.chowbox.controller;
import com.chowbox.model.ApiResponse;
import com.chowbox.model.Restaurant;
import com.chowbox.service.TakeoutService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/explore")
public class ExploreController {
@Autowired
private TakeoutService takeoutService;
@PostMapping("/roll")
public ApiResponse<Restaurant> roll(@RequestBody Map<String, Object> body) {
double lat = Double.parseDouble(String.valueOf(body.get("latitude")));
double lng = Double.parseDouble(String.valueOf(body.get("longitude")));
String openid = (String) body.getOrDefault("openid", "anonymous");
// Phase 2: 差异化过滤条件(堂食场景)。Phase 1 复用外卖逻辑
Restaurant result = takeoutService.roll(lat, lng, openid, "都可以", "all", "");
if (result == null) {
return ApiResponse.fail(404, "附近暂无推荐好店,换片区域试试?");
}
return ApiResponse.ok(result);
}
}

View File

@@ -0,0 +1,52 @@
package com.chowbox.controller;
import com.chowbox.model.ApiResponse;
import com.chowbox.model.FridgeMatchResult;
import com.chowbox.service.FridgeService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/fridge")
public class FridgeController {
@Autowired
private FridgeService fridgeService;
@PostMapping("/match")
public ApiResponse<List<FridgeMatchResult>> match(@RequestBody Map<String, Object> body) {
Object ingObj = body.get("ingredients");
log.info("Fridge match request: ingredients type={}, value={}", ingObj != null ? ingObj.getClass().getSimpleName() : "null", ingObj);
List<String> ingredients = new ArrayList<>();
if (ingObj instanceof List) {
for (Object item : (List<?>) ingObj) {
if (item != null) ingredients.add(item.toString());
}
}
if (ingredients.isEmpty()) {
return ApiResponse.fail(400, "请输入至少一种食材");
}
// 接收前端自定义常备调料
List<String> staples = new ArrayList<>();
Object stapleObj = body.get("staples");
if (stapleObj instanceof List) {
for (Object item : (List<?>) stapleObj) {
if (item != null) staples.add(item.toString());
}
}
log.info("Fridge match: parsed ingredients={}, staples={}", ingredients, staples);
List<FridgeMatchResult> results = fridgeService.matchRecipes(ingredients, staples);
log.info("Fridge match: {} results", results.size());
return ApiResponse.ok(results);
}
}

View File

@@ -0,0 +1,24 @@
package com.chowbox.controller;
import com.chowbox.model.ApiResponse;
import com.chowbox.model.Recipe;
import com.chowbox.service.FridgeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/recipe")
public class RecipeController {
@Autowired
private FridgeService fridgeService;
@GetMapping("/{id}")
public ApiResponse<Recipe> detail(@PathVariable Long id) {
Recipe recipe = fridgeService.getRecipeDetail(id);
if (recipe == null) {
return ApiResponse.fail(404, "菜谱不存在");
}
return ApiResponse.ok(recipe);
}
}

View File

@@ -0,0 +1,33 @@
package com.chowbox.controller;
import com.chowbox.model.ApiResponse;
import com.chowbox.model.Restaurant;
import com.chowbox.service.TakeoutService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/takeout")
public class TakeoutController {
@Autowired
private TakeoutService takeoutService;
@PostMapping("/roll")
public ApiResponse<Restaurant> roll(@RequestBody Map<String, Object> body) {
double lat = Double.parseDouble(String.valueOf(body.get("latitude")));
double lng = Double.parseDouble(String.valueOf(body.get("longitude")));
String openid = (String) body.getOrDefault("openid", "anonymous");
String taste = (String) body.getOrDefault("taste", "都可以");
String priceRange = (String) body.getOrDefault("priceRange", "all");
String allergies = (String) body.getOrDefault("allergies", "");
Restaurant result = takeoutService.roll(lat, lng, openid, taste, priceRange, allergies);
if (result == null) {
return ApiResponse.fail(404, "附近暂无合适的商家,换个区域试试吧");
}
return ApiResponse.ok(result);
}
}

View File

@@ -0,0 +1,29 @@
package com.chowbox.controller;
import com.chowbox.model.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.Collections;
import java.util.Map;
/**
* 语音识别接口STT stub
* 当前返回空文本,后续可接入阿里云/腾讯云 ASR 服务。
*/
@Slf4j
@RestController
@RequestMapping("/api/voice")
public class VoiceController {
@PostMapping("/recognize")
public ApiResponse<Map<String, String>> recognize(@RequestParam("audio") MultipartFile audio) {
log.info("Voice recognize: file={}, size={}bytes", audio.getOriginalFilename(), audio.getSize());
// TODO: 接入 ASR 服务,对音频做语音识别
// 目前 stub 返回空结果,前端会 fallback 提示用户手动输入
return ApiResponse.ok(Collections.singletonMap("text", ""));
}
}

View File

@@ -0,0 +1,9 @@
package com.chowbox.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.chowbox.model.RecipeIngredient;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface RecipeIngredientMapper extends BaseMapper<RecipeIngredient> {
}

View File

@@ -0,0 +1,9 @@
package com.chowbox.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.chowbox.model.Recipe;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface RecipeMapper extends BaseMapper<Recipe> {
}

View File

@@ -0,0 +1,9 @@
package com.chowbox.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.chowbox.model.RecipeStep;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface RecipeStepMapper extends BaseMapper<RecipeStep> {
}

View File

@@ -0,0 +1,25 @@
package com.chowbox.model;
import lombok.Data;
@Data
public class ApiResponse<T> {
private int code;
private String message;
private T data;
public static <T> ApiResponse<T> ok(T data) {
ApiResponse<T> r = new ApiResponse<>();
r.code = 200;
r.message = "success";
r.data = data;
return r;
}
public static <T> ApiResponse<T> fail(int code, String message) {
ApiResponse<T> r = new ApiResponse<>();
r.code = code;
r.message = message;
return r;
}
}

View File

@@ -0,0 +1,11 @@
package com.chowbox.model;
import lombok.Data;
import java.util.List;
@Data
public class FridgeMatchResult {
private Recipe recipe;
private double matchRate; // 匹配度 0-100
private List<String> missingIngredients; // 缺少的食材
}

View File

@@ -0,0 +1,25 @@
package com.chowbox.model;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.List;
@Data
@TableName("recipes")
public class Recipe {
private Long id;
private String name;
private Integer difficulty;
private Integer cookTime;
private String imageUrl;
private String category;
private String season;
private String mealTime;
@TableField(exist = false)
private List<RecipeIngredient> ingredients;
@TableField(exist = false)
private List<RecipeStep> steps;
}

View File

@@ -0,0 +1,14 @@
package com.chowbox.model;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("recipe_ingredients")
public class RecipeIngredient {
private Long id;
private Long recipeId;
private String ingredientName;
private String amount;
private Integer isStaple;
}

View File

@@ -0,0 +1,14 @@
package com.chowbox.model;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("recipe_steps")
public class RecipeStep {
private Long id;
private Long recipeId;
private Integer stepOrder;
private String content;
private String imageUrl;
}

View File

@@ -0,0 +1,24 @@
package com.chowbox.model;
import lombok.Data;
@Data
public class Restaurant {
private String id;
private String name;
private String address;
private Double rating;
private Integer monthlySales;
private Double avgPrice;
private Double distance;
private Double longitude;
private Double latitude;
private String deliveryTime;
private String deliveryFee;
private String minOrder;
private String phone;
private String imageUrl;
private String category;
private String recommendReason;
private String[] signatureDishes;
}

View File

@@ -0,0 +1,197 @@
package com.chowbox.service;
import com.chowbox.model.Restaurant;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Slf4j
@Service
public class AmapService {
private static final String AMAP_AROUND_URL = "https://restapi.amap.com/v3/place/around";
@Value("${amap.api-key}")
private String apiKey;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private final RestTemplate restTemplate = new RestTemplate();
@SuppressWarnings("unchecked")
public List<Restaurant> searchNearbyPOI(double lat, double lng, int radius) {
String gridKey = String.format("poi:%.3f:%.3f:restaurant",
Math.floor(lat * 10) / 10, Math.floor(lng * 10) / 10);
List<Restaurant> cached = (List<Restaurant>) redisTemplate.opsForValue().get(gridKey);
if (cached != null && !cached.isEmpty()) {
log.info("Amap cache hit: key={}, size={}", gridKey, cached.size());
return cached;
}
String url = String.format(
"%s?key=%s&location=%f,%f&radius=%d&types=050000&offset=50&extensions=all",
AMAP_AROUND_URL, apiKey, lng, lat, radius);
log.info("Amap API call: location={},{} radius={}", lat, lng, radius);
try {
Map<String, Object> response = restTemplate.getForObject(url, Map.class);
List<Restaurant> results = parseResponse(response, lat, lng);
log.info("Amap parsed: {} restaurants", results.size());
if (!results.isEmpty()) {
redisTemplate.opsForValue().set(gridKey, results, 1, TimeUnit.HOURS);
}
return results;
} catch (Exception e) {
log.error("Amap API error: {}", e.getMessage(), e);
return Collections.emptyList();
}
}
@SuppressWarnings("unchecked")
private List<Restaurant> parseResponse(Map<String, Object> response, double centerLat, double centerLng) {
if (response == null) {
log.warn("Amap response is null");
return Collections.emptyList();
}
String status = String.valueOf(response.get("status"));
if (!"1".equals(status)) {
log.warn("Amap response status={}, info={}", status, response.get("info"));
return Collections.emptyList();
}
Object poisObj = response.get("pois");
if (poisObj == null) {
log.warn("Amap response has no 'pois' key");
return Collections.emptyList();
}
List<Map<String, Object>> pois = (List<Map<String, Object>>) poisObj;
log.info("Amap POIs count: {}", pois.size());
return pois.stream().map(poi -> parseRestaurant(poi, centerLat, centerLng))
.collect(Collectors.toList());
}
@SuppressWarnings("unchecked")
private Restaurant parseRestaurant(Map<String, Object> poi, double centerLat, double centerLng) {
Restaurant r = new Restaurant();
r.setId(String.valueOf(poi.get("id")));
r.setName(String.valueOf(poi.get("name")));
r.setAddress(String.valueOf(poi.get("address")));
// 坐标 - 用于后续地图导航
try {
String[] locParts = String.valueOf(poi.get("location")).split(",");
if (locParts.length == 2) {
r.setLongitude(Double.parseDouble(locParts[0]));
r.setLatitude(Double.parseDouble(locParts[1]));
}
} catch (Exception ignored) {
r.setLongitude(centerLng);
r.setLatitude(centerLat);
}
// 距离
Object distObj = poi.get("distance");
if (distObj instanceof String) r.setDistance(Double.parseDouble((String) distObj));
else if (distObj instanceof Number) r.setDistance(((Number) distObj).doubleValue());
// biz_ext: 评分、人均
Object bizExtObj = poi.get("biz_ext");
if (bizExtObj instanceof Map) {
Map<String, Object> bizExt = (Map<String, Object>) bizExtObj;
try { r.setRating(Double.parseDouble(String.valueOf(bizExt.get("rating")))); } catch (Exception ignored) {}
try { r.setAvgPrice(Double.parseDouble(String.valueOf(bizExt.get("cost")))); } catch (Exception ignored) {}
}
// 分类
r.setCategory(detectCategory(poi));
// 标签 → 用作招牌菜来源
Object tagObj = poi.get("tag");
if (tagObj instanceof String && !((String) tagObj).isEmpty()) {
r.setSignatureDishes(extractTags((String) tagObj));
} else if (tagObj instanceof List && !((List<?>) tagObj).isEmpty()) {
List<String> tags = ((List<?>) tagObj).stream()
.map(String::valueOf).collect(Collectors.toList());
r.setSignatureDishes(tags.toArray(new String[0]));
}
// 电话
Object telObj = poi.get("tel");
if (telObj instanceof String) r.setPhone((String) telObj);
else if (telObj instanceof List) {
r.setPhone(String.join(";", ((List<?>) telObj).stream()
.map(String::valueOf).collect(Collectors.toList())));
}
// 照片
Object photosObj = poi.get("photos");
if (photosObj instanceof List) {
List<?> photos = (List<?>) photosObj;
if (!photos.isEmpty()) {
Object firstPhoto = photos.get(0);
if (firstPhoto instanceof Map) {
Map<String, Object> photoMap = (Map<String, Object>) firstPhoto;
Object url = photoMap.get("url");
if (url != null) {
r.setImageUrl(url.toString());
if (url.toString().startsWith("http://")) {
r.setImageUrl(url.toString().replace("http://", "https://"));
}
}
}
}
}
return r;
}
/**
* 从 type 和 tag 字段推断可读的分类名
*/
private String detectCategory(Map<String, Object> poi) {
// type 格式如 "050100|中餐厅|050100"
String type = String.valueOf(poi.getOrDefault("type", ""));
if (type.contains("川菜")) return "川菜";
if (type.contains("粤菜")) return "粤菜";
if (type.contains("湘菜")) return "湘菜";
if (type.contains("火锅")) return "火锅";
if (type.contains("烧烤")) return "烧烤";
if (type.contains("日料") || type.contains("日本")) return "日料";
if (type.contains("韩餐") || type.contains("韩国")) return "韩餐";
if (type.contains("西餐") || type.contains("西式")) return "西餐";
if (type.contains("快餐")) return "快餐";
if (type.contains("小吃")) return "小吃";
if (type.contains("咖啡")) return "咖啡";
if (type.contains("甜品")) return "甜品";
if (type.contains("") || type.contains("")) return "面馆";
String tag = String.valueOf(poi.getOrDefault("tag", ""));
if (!tag.isEmpty() && !"null".equals(tag)) {
return tag;
}
return "餐饮";
}
/**
* 从标签字符串中提取关键词作为招牌菜
*/
private String[] extractTags(String tagStr) {
if (tagStr == null || tagStr.isEmpty()) return null;
return Arrays.stream(tagStr.split("[;,\\s]+"))
.map(String::trim)
.filter(t -> !t.isEmpty())
.limit(4)
.toArray(String[]::new);
}
}

View File

@@ -0,0 +1,183 @@
package com.chowbox.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.chowbox.mapper.RecipeIngredientMapper;
import com.chowbox.mapper.RecipeMapper;
import com.chowbox.mapper.RecipeStepMapper;
import com.chowbox.model.FridgeMatchResult;
import com.chowbox.model.Recipe;
import com.chowbox.model.RecipeIngredient;
import com.chowbox.model.RecipeStep;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
public class FridgeService {
@Autowired
private RecipeMapper recipeMapper;
@Autowired
private RecipeIngredientMapper ingredientMapper;
@Autowired
private RecipeStepMapper stepMapper;
private static final Set<String> DEFAULT_STAPLES = new HashSet<>(Arrays.asList(
"", "", "酱油", "生抽", "老抽", "", "料酒", "蚝油",
"", "白糖", "淀粉", "香油", "", "", "", "干辣椒", "花椒"
));
private static final Random RANDOM = new Random();
public List<FridgeMatchResult> matchRecipes(List<String> userIngredients, List<String> customStaples) {
Set<String> userSet = new HashSet<>();
for (String ing : userIngredients) {
userSet.add(ing.trim());
}
// 合并系统默认 + 用户自定义常备调料
Set<String> allStaples = new HashSet<>(DEFAULT_STAPLES);
if (customStaples != null) {
for (String s : customStaples) {
allStaples.add(s.trim());
}
}
userSet.addAll(allStaples);
List<Recipe> allRecipes = recipeMapper.selectList(null);
log.info("FridgeService: total recipes in DB = {}", allRecipes.size());
List<FridgeMatchResult> results = new ArrayList<>();
int currentHour = LocalTime.now().getHour();
int currentMonth = LocalDate.now().getMonthValue();
String mealTime = getMealTime(currentHour);
String season = getSeason(currentMonth);
log.info("FridgeService: current mealTime={}, season={}", mealTime, season);
for (Recipe recipe : allRecipes) {
List<RecipeIngredient> recipeIngredients = ingredientMapper.selectList(
new QueryWrapper<RecipeIngredient>().eq("recipe_id", recipe.getId())
);
List<String> recipeIngNames = recipeIngredients.stream()
.map(ri -> ri.getIngredientName().trim())
.collect(Collectors.toList());
// 计算交集
long matchCount = recipeIngNames.stream()
.filter(userSet::contains)
.count();
double matchRate = recipeIngNames.isEmpty() ? 0 :
(double) matchCount / recipeIngNames.size() * 100;
// 找出缺失的食材(非调料类)
List<String> missing = recipeIngNames.stream()
.filter(name -> !userSet.contains(name) && !allStaples.contains(name))
.collect(Collectors.toList());
// 时段/季节加权:匹配当前时段或季节 +8%
double timeBoost = 0;
if (mealTime.equals(recipe.getMealTime())) timeBoost += 8;
if (season.equals(recipe.getSeason())) timeBoost += 8;
FridgeMatchResult result = new FridgeMatchResult();
result.setRecipe(recipe);
result.setMatchRate(Math.round(matchRate + timeBoost));
result.setMissingIngredients(missing);
results.add(result);
}
// 按匹配度降序排列,取 Top10 候选池,加权随机选 3-5 个
List<FridgeMatchResult> pool = results.stream()
.sorted((a, b) -> Double.compare(b.getMatchRate(), a.getMatchRate()))
.limit(10)
.collect(Collectors.toList());
return weightedRandomPick(pool);
}
/**
* 从候选池加权随机选 3-5 个。
* 权重 = matchRate²保证高分菜更有机会但不乏惊喜。
*/
private List<FridgeMatchResult> weightedRandomPick(List<FridgeMatchResult> pool) {
if (pool.size() <= 3) return pool;
int pickCount = 3 + RANDOM.nextInt(Math.min(3, pool.size() - 2)); // 3-5
List<FridgeMatchResult> shuffled = new ArrayList<>(pool);
Collections.shuffle(shuffled, RANDOM);
// 加权随机
List<FridgeMatchResult> picked = new ArrayList<>();
List<FridgeMatchResult> remaining = new ArrayList<>(shuffled);
for (int i = 0; i < pickCount && !remaining.isEmpty(); i++) {
double totalWeight = remaining.stream()
.mapToDouble(r -> Math.pow(r.getMatchRate(), 2))
.sum();
if (totalWeight <= 0) {
int idx = RANDOM.nextInt(remaining.size());
picked.add(remaining.remove(idx));
continue;
}
double dart = RANDOM.nextDouble() * totalWeight;
double cumulative = 0;
int selectedIdx = 0;
for (int j = 0; j < remaining.size(); j++) {
cumulative += Math.pow(remaining.get(j).getMatchRate(), 2);
if (dart <= cumulative) {
selectedIdx = j;
break;
}
}
picked.add(remaining.remove(selectedIdx));
}
// 匹配度高的排前面
picked.sort((a, b) -> Double.compare(b.getMatchRate(), a.getMatchRate()));
return picked;
}
private String getMealTime(int hour) {
if (hour >= 5 && hour < 10) return "早餐";
if (hour >= 10 && hour < 14) return "午餐";
if (hour >= 14 && hour < 17) return "下午茶";
if (hour >= 17 && hour < 21) return "晚餐";
return "夜宵";
}
private String getSeason(int month) {
if (month >= 3 && month <= 5) return "春季";
if (month >= 6 && month <= 8) return "夏季";
if (month >= 9 && month <= 11) return "秋季";
return "冬季";
}
public Recipe getRecipeDetail(Long recipeId) {
Recipe recipe = recipeMapper.selectById(recipeId);
if (recipe == null) return null;
List<RecipeIngredient> ingredients = ingredientMapper.selectList(
new QueryWrapper<RecipeIngredient>().eq("recipe_id", recipeId)
);
List<RecipeStep> steps = stepMapper.selectList(
new QueryWrapper<RecipeStep>().eq("recipe_id", recipeId).orderByAsc("step_order")
);
recipe.setIngredients(ingredients);
recipe.setSteps(steps);
return recipe;
}
}

View File

@@ -0,0 +1,320 @@
package com.chowbox.service;
import com.chowbox.model.Restaurant;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
public class TakeoutService {
@Autowired
private AmapService amapService;
private final Map<String, Set<String>> recentlyShown = new HashMap<>();
// 保质期较短的缓存,避免同一会话内重复展示
private static final int MAX_SHOWN_HISTORY = 20;
public Restaurant roll(double lat, double lng, String openid,
String taste, String priceRange, String allergies) {
List<Restaurant> all = amapService.searchNearbyPOI(lat, lng, 3000);
log.info("Takeout roll: total POIs={}", all.size());
if (!all.isEmpty()) {
Restaurant first = all.get(0);
log.info("Takeout sample: name={}, rating={}, category={}, tags={}",
first.getName(), first.getRating(), first.getCategory(),
first.getSignatureDishes() != null ? String.join(",", first.getSignatureDishes()) : "null");
}
if (all.isEmpty()) return null;
// ── 高质量池过滤(分层降级) ──
List<Restaurant> pool = filterByRating(all, 4.3);
if (pool.size() < 3) {
log.info("Takeout pool >=4.3: {}, expanding to >=4.0", pool.size());
pool = filterByRating(all, 4.0);
}
if (pool.size() < 3) {
log.info("Takeout pool >=4.0: {}, expanding to >=3.5", pool.size());
pool = filterByRating(all, 3.5);
}
if (pool.isEmpty()) {
log.info("Takeout fallback: using all POIs");
pool = new ArrayList<>(all);
}
// ── 去除最近已展示的商家 ──
Set<String> shown = recentlyShown.getOrDefault(openid, new LinkedHashSet<>());
List<Restaurant> candidates = pool.stream()
.filter(r -> !shown.contains(r.getId()))
.collect(Collectors.toList());
if (candidates.isEmpty()) {
// 全部展示过了,清空历史重来
recentlyShown.remove(openid);
candidates = new ArrayList<>(pool);
}
// ── 用户偏好过滤 ──
List<Restaurant> prefFiltered = filterByPreferences(candidates, taste, priceRange, allergies);
if (!prefFiltered.isEmpty()) {
candidates = prefFiltered;
} else {
log.info("Takeout pref filter empty, fallback to all candidates");
}
// ── 加权随机(评分权重 + 距离惩罚) ──
Restaurant selected = weightedRandom(candidates);
// 记录已展示
Set<String> userShown = recentlyShown.computeIfAbsent(openid,
k -> new LinkedHashSet<>());
userShown.add(selected.getId());
// 限制历史大小
if (userShown.size() > MAX_SHOWN_HISTORY) {
Iterator<String> it = userShown.iterator();
it.next();
it.remove();
}
// ── 补充合理默认值 ──
enrichRestaurant(selected);
return selected;
}
private List<Restaurant> filterByRating(List<Restaurant> list, double minRating) {
return list.stream()
.filter(r -> r.getRating() != null && r.getRating() >= minRating)
.collect(Collectors.toList());
}
/**
* 根据用户偏好过滤餐厅列表,三层过滤:价格 → 口味 → 忌口
*/
private List<Restaurant> filterByPreferences(List<Restaurant> list,
String taste, String priceRange, String allergies) {
List<Restaurant> result = new ArrayList<>(list);
// 1. 价格区间过滤
if (priceRange != null && !"all".equals(priceRange)) {
result = filterByPriceRange(result, priceRange);
}
// 2. 口味偏好过滤
if (taste != null && !"都可以".equals(taste)) {
result = filterByTaste(result, taste);
}
// 3. 忌口过滤
if (allergies != null && !allergies.isEmpty()) {
result = filterByAllergies(result, allergies);
}
return result;
}
private List<Restaurant> filterByPriceRange(List<Restaurant> list, String priceRange) {
return list.stream().filter(r -> {
Double price = r.getAvgPrice();
if (price == null) return true; // 无价格数据的保留
switch (priceRange) {
case "low": return price < 30;
case "medium": return price >= 30 && price <= 80;
case "high": return price > 80;
default: return true;
}
}).collect(Collectors.toList());
}
private List<Restaurant> filterByTaste(List<Restaurant> list, String taste) {
return list.stream().filter(r -> {
String cat = r.getCategory() != null ? r.getCategory() : "";
String name = r.getName() != null ? r.getName() : "";
switch (taste) {
case "":
return containsAny(cat, "川菜", "湘菜", "火锅", "烧烤", "麻辣", "香锅")
|| name.contains("火锅") || name.contains("烧烤") || name.contains("麻辣");
case "清淡":
return containsAny(cat, "粤菜", "日料", "面馆", "粥粉面", "蒸菜", "轻食")
|| name.contains("") || name.contains("") || name.contains("");
case "酸甜":
return containsAny(cat, "甜品", "咖啡", "饮品", "东南亚", "糖水")
|| name.contains("甜品") || name.contains("咖啡") || name.contains("");
default:
return true;
}
}).collect(Collectors.toList());
}
private List<Restaurant> filterByAllergies(List<Restaurant> list, String allergies) {
String[] items = allergies.split("[,]");
return list.stream().filter(r -> {
String cat = r.getCategory() != null ? r.getCategory() : "";
String name = r.getName() != null ? r.getName() : "";
String[] dishes = r.getSignatureDishes() != null ? r.getSignatureDishes() : new String[0];
String combined = cat + name + String.join("", dishes);
for (String item : items) {
String kw = item.trim();
if (!kw.isEmpty() && combined.contains(kw)) {
return false;
}
}
return true;
}).collect(Collectors.toList());
}
private boolean containsAny(String text, String... keywords) {
for (String kw : keywords) {
if (text.contains(kw)) return true;
}
return false;
}
private Restaurant weightedRandom(List<Restaurant> candidates) {
// 综合权重 = 评分² × e^(-distance/2000)
double totalWeight = 0;
double[] weights = new double[candidates.size()];
for (int i = 0; i < candidates.size(); i++) {
Restaurant r = candidates.get(i);
double rating = r.getRating() != null ? r.getRating() : 3.5;
double dist = r.getDistance() != null ? r.getDistance() : 1500;
// 评分权重为主,距离越近权重越高
weights[i] = rating * rating * Math.exp(-dist / 2000.0);
totalWeight += weights[i];
}
double random = Math.random() * totalWeight;
double cumulative = 0;
for (int i = 0; i < candidates.size(); i++) {
cumulative += weights[i];
if (random <= cumulative) {
return candidates.get(i);
}
}
return candidates.get(candidates.size() - 1);
}
/**
* 用合理数据填充缺失字段,不再硬编码假数据
*/
private void enrichRestaurant(Restaurant r) {
Double rating = r.getRating();
Double avgPrice = r.getAvgPrice();
Double distance = r.getDistance();
// ── 推荐语:基于真实数据生成 ──
if (r.getRecommendReason() == null || r.getRecommendReason().isEmpty()) {
r.setRecommendReason(buildRecommendReason(rating, avgPrice, r.getCategory()));
}
// ── 招牌菜:如果 POI 没有 tag 数据 → 从菜系推断 ──
if (r.getSignatureDishes() == null || r.getSignatureDishes().length == 0) {
r.setSignatureDishes(guessSignatureDishes(r.getCategory(), r.getName()));
}
// ── 配送/起送信息:基于品类和人均估算 ──
if (r.getDeliveryTime() == null) {
r.setDeliveryTime(estimateDeliveryTime(distance));
}
if (r.getMinOrder() == null) {
r.setMinOrder(estimateMinOrder(avgPrice, r.getCategory()));
}
if (r.getDeliveryFee() == null) {
r.setDeliveryFee(estimateDeliveryFee(distance));
}
}
private String buildRecommendReason(Double rating, Double avgPrice, String category) {
List<String> points = new ArrayList<>();
if (rating != null && rating >= 4.5) {
points.add("评分" + rating + ",口碑极佳");
} else if (rating != null && rating >= 4.0) {
points.add("评分" + rating + ",广受好评");
} else if (rating != null) {
points.add("评分" + rating);
}
if (avgPrice != null && avgPrice <= 30) {
points.add("人均¥" + avgPrice.intValue() + ",超高性价比");
} else if (avgPrice != null && avgPrice <= 60) {
points.add("人均¥" + avgPrice.intValue());
}
if (category != null && !"餐饮".equals(category)) {
points.add("热门" + category);
}
if (points.isEmpty()) {
points.add("附近人气商家");
}
return String.join(" · ", points);
}
private String[] guessSignatureDishes(String category, String name) {
if (category == null) category = "";
if (name == null) name = "";
List<String> guesses = new ArrayList<>();
// 从店名提取线索
if (name.contains("火锅")) guesses.add("鸳鸯锅底");
if (name.contains("烧烤")) guesses.add("羊肉串");
if (name.contains("") || category.equals("面馆")) guesses.add("招牌汤面");
if (name.contains("饺子") || name.contains("水饺")) guesses.add("手工水饺");
if (name.contains("米线") || name.contains("米粉")) guesses.add("过桥米线");
if (name.contains("汉堡") || name.contains("炸鸡")) guesses.add("招牌汉堡");
// 从菜系推断
if (category.contains("川菜")) { guesses.add("水煮鱼"); guesses.add("麻婆豆腐"); }
if (category.contains("粤菜")) { guesses.add("白切鸡"); guesses.add("叉烧"); }
if (category.contains("湘菜")) { guesses.add("剁椒鱼头"); guesses.add("小炒肉"); }
if (category.contains("日料")) { guesses.add("三文鱼刺身"); guesses.add("豚骨拉面"); }
if (category.contains("韩餐")) { guesses.add("石锅拌饭"); guesses.add("韩式炸鸡"); }
if (category.contains("火锅")) { guesses.add("毛肚"); guesses.add("虾滑"); }
if (category.contains("烧烤")) { guesses.add("烤串套餐"); guesses.add("烤茄子"); }
if (category.contains("快餐")) { guesses.add("超值套餐"); }
if (category.contains("甜品") || category.contains("咖啡")) { guesses.add("招牌拿铁"); guesses.add("提拉米苏"); }
// 不够3个用通用项补齐
if (guesses.size() < 3) guesses.add("招牌套餐");
return guesses.subList(0, Math.min(guesses.size(), 4)).toArray(new String[0]);
}
private String estimateDeliveryTime(Double distanceMeters) {
if (distanceMeters == null) return "约30分钟";
double km = distanceMeters / 1000.0;
if (km <= 1) return "约25分钟";
if (km <= 2) return "约30分钟";
if (km <= 3) return "约35分钟";
return "约40分钟";
}
private String estimateMinOrder(Double avgPrice, String category) {
// 快餐/小吃类起送低,正餐类高
if (category != null && (category.contains("快餐") || category.contains("小吃"))) {
return "15元起送";
}
if (avgPrice != null && avgPrice >= 80) {
return "30元起送";
}
return "20元起送";
}
private String estimateDeliveryFee(Double distanceMeters) {
if (distanceMeters == null) return "约3元";
double km = distanceMeters / 1000.0;
if (km <= 1.5) return "免配送费";
if (km <= 2.5) return "约3元";
return "约5元";
}
}

View File

@@ -0,0 +1,24 @@
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/chowbox?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
redis:
host: localhost
port: 6379
password:
timeout: 3000ms
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
global-config:
db-config:
id-type: auto
amap:
api-key: e35f0d06a77ab57ba1e6806120b46c2a

View File

@@ -0,0 +1,382 @@
-- ChowBox 菜谱种子数据100道
USE chowbox;
-- ============================================================
-- 家常快手菜20道- 难度低、时间短
-- ============================================================
INSERT INTO recipes (id, name, difficulty, cook_time, category, season, meal_time) VALUES
(1, '西红柿炒鸡蛋', 1, 10, '家常菜', 'all', 'all'),
(2, '酸辣土豆丝', 1, 10, '家常菜', 'all', 'lunch,dinner'),
(3, '青椒肉丝', 1, 15, '家常菜', 'all', 'lunch,dinner'),
(4, '蒜蓉西兰花', 1, 8, '家常菜', 'all', 'lunch,dinner'),
(5, '糖醋里脊', 2, 25, '家常菜', 'all', 'lunch,dinner'),
(6, '干煸四季豆', 1, 12, '家常菜', 'all', 'lunch,dinner'),
(7, '可乐鸡翅', 1, 20, '家常菜', 'all', 'lunch,dinner'),
(8, '韭黄炒蛋', 1, 8, '家常菜', 'all', 'all'),
(9, '红烧豆腐', 1, 15, '家常菜', 'all', 'lunch,dinner'),
(10, '蚝油生菜', 1, 5, '家常菜', 'all', 'lunch,dinner'),
(11, '孜然土豆片', 1, 12, '家常菜', 'all', 'lunch,dinner'),
(12, '洋葱炒肉', 1, 10, '家常菜', 'all', 'lunch,dinner'),
(13, '香菇油菜', 1, 10, '家常菜', 'all', 'lunch,dinner'),
(14, '家常豆腐', 2, 18, '家常菜', 'all', 'lunch,dinner'),
(15, '雪菜肉丝', 1, 12, '家常菜', 'all', 'lunch,dinner'),
(16, '素炒豆芽', 1, 6, '家常菜', 'all', 'lunch,dinner'),
(17, '芹菜香干', 1, 10, '家常菜', 'all', 'lunch,dinner'),
(18, '番茄菜花', 1, 10, '家常菜', 'all', 'lunch,dinner'),
(19, '肉末茄子', 2, 20, '家常菜', 'all', 'lunch,dinner'),
(20, '葱爆羊肉', 2, 15, '家常菜', 'winter', 'lunch,dinner');
-- ============================================================
-- 川菜15道
-- ============================================================
INSERT INTO recipes (id, name, difficulty, cook_time, category, season, meal_time) VALUES
(21, '麻婆豆腐', 2, 15, '川菜', 'all', 'lunch,dinner'),
(22, '回锅肉', 2, 25, '川菜', 'all', 'lunch,dinner'),
(23, '宫保鸡丁', 2, 20, '川菜', 'all', 'lunch,dinner'),
(24, '水煮肉片', 3, 30, '川菜', 'winter', 'lunch,dinner'),
(25, '鱼香肉丝', 2, 20, '川菜', 'all', 'lunch,dinner'),
(26, '辣子鸡丁', 3, 30, '川菜', 'all', 'lunch,dinner'),
(27, '夫妻肺片', 2, 20, '川菜', 'summer', 'lunch,dinner'),
(28, '口水鸡', 2, 30, '川菜', 'summer', 'lunch,dinner'),
(29, '干煸牛肉丝', 2, 20, '川菜', 'all', 'lunch,dinner'),
(30, '蒜泥白肉', 1, 20, '川菜', 'summer', 'lunch,dinner'),
(31, '酸菜鱼', 3, 35, '川菜', 'winter', 'lunch,dinner'),
(32, '毛血旺', 3, 30, '川菜', 'winter', 'lunch,dinner'),
(33, '麻辣香锅', 2, 25, '川菜', 'all', 'lunch,dinner'),
(34, '泡椒凤爪', 2, 40, '川菜', 'summer', 'all'),
(35, '担担面', 2, 25, '川菜', 'all', 'lunch,dinner');
-- ============================================================
-- 粤菜15道
-- ============================================================
INSERT INTO recipes (id, name, difficulty, cook_time, category, season, meal_time) VALUES
(36, '白切鸡', 3, 40, '粤菜', 'all', 'lunch,dinner'),
(37, '清蒸鲈鱼', 2, 20, '粤菜', 'all', 'lunch,dinner'),
(38, '白灼虾', 1, 10, '粤菜', 'all', 'lunch,dinner'),
(39, '广式叉烧', 4, 60, '粤菜', 'all', 'lunch,dinner'),
(40, '菠萝咕咾肉', 2, 25, '粤菜', 'summer', 'lunch,dinner'),
(41, '干炒牛河', 3, 15, '粤菜', 'all', 'lunch,dinner'),
(42, '蒜蓉粉丝蒸扇贝', 2, 15, '粤菜', 'all', 'dinner'),
(43, '豉汁蒸排骨', 2, 25, '粤菜', 'all', 'lunch,dinner'),
(44, '煲仔饭', 3, 35, '粤菜', 'winter', 'lunch,dinner'),
(45, '冬瓜排骨汤', 1, 50, '粤菜', 'summer', 'dinner'),
(46, '蚝油牛肉', 2, 15, '粤菜', 'all', 'lunch,dinner'),
(47, '虾饺', 4, 40, '粤菜', 'all', 'breakfast,lunch'),
(48, '肠粉', 2, 20, '粤菜', 'all', 'breakfast'),
(49, '叉烧包', 3, 45, '粤菜', 'all', 'breakfast'),
(50, '蛋挞', 2, 30, '粤菜', 'all', 'all');
-- ============================================================
-- 湘菜8道
-- ============================================================
INSERT INTO recipes (id, name, difficulty, cook_time, category, season, meal_time) VALUES
(51, '剁椒鱼头', 3, 30, '湘菜', 'winter', 'lunch,dinner'),
(52, '农家小炒肉', 2, 15, '湘菜', 'all', 'lunch,dinner'),
(53, '酸豆角肉末', 1, 12, '湘菜', 'summer', 'lunch,dinner'),
(54, '腊味合蒸', 2, 30, '湘菜', 'winter', 'lunch,dinner'),
(55, '干锅花菜', 2, 18, '湘菜', 'all', 'lunch,dinner'),
(56, '口味虾', 3, 35, '湘菜', 'summer', 'dinner'),
(57, '擂辣椒皮蛋', 1, 10, '湘菜', 'summer', 'all'),
(58, '湘西外婆菜', 2, 15, '湘菜', 'all', 'lunch,dinner');
-- ============================================================
-- 东北菜8道
-- ============================================================
INSERT INTO recipes (id, name, difficulty, cook_time, category, season, meal_time) VALUES
(59, '锅包肉', 3, 30, '东北菜', 'all', 'lunch,dinner'),
(60, '地三鲜', 2, 20, '东北菜', 'all', 'lunch,dinner'),
(61, '猪肉炖粉条', 2, 40, '东北菜', 'winter', 'lunch,dinner'),
(62, '东北乱炖', 1, 35, '东北菜', 'winter', 'lunch,dinner'),
(63, '京酱肉丝', 2, 20, '东北菜', 'all', 'lunch,dinner'),
(64, '拔丝地瓜', 3, 25, '东北菜', 'winter', 'dinner'),
(65, '酸菜白肉', 2, 45, '东北菜', 'winter', 'lunch,dinner'),
(66, '溜肉段', 2, 20, '东北菜', 'all', 'lunch,dinner');
-- ============================================================
-- 早餐类12道
-- ============================================================
INSERT INTO recipes (id, name, difficulty, cook_time, category, season, meal_time) VALUES
(67, '鸡蛋灌饼', 2, 15, '早餐', 'all', 'breakfast'),
(68, '皮蛋瘦肉粥', 1, 30, '早餐', 'all', 'breakfast'),
(69, '葱花鸡蛋饼', 1, 8, '早餐', 'all', 'breakfast'),
(70, '小米南瓜粥', 1, 25, '早餐', 'autumn,winter', 'breakfast'),
(71, '煎饺', 2, 15, '早餐', 'all', 'breakfast'),
(72, '三明治', 1, 5, '早餐', 'all', 'breakfast'),
(73, '豆浆油条', 3, 40, '早餐', 'all', 'breakfast'),
(74, '馄饨', 3, 30, '早餐', 'all', 'breakfast,lunch'),
(75, '肉夹馍', 3, 60, '早餐', 'all', 'all'),
(76, '糯米鸡', 2, 40, '早餐', 'all', 'breakfast'),
(77, '煎饼果子', 2, 15, '早餐', 'all', 'breakfast'),
(78, '豆沙包', 2, 40, '早餐', 'all', 'breakfast');
-- ============================================================
-- 汤羹类8道
-- ============================================================
INSERT INTO recipes (id, name, difficulty, cook_time, category, season, meal_time) VALUES
(79, '番茄蛋花汤', 1, 10, '汤羹', 'all', 'lunch,dinner'),
(80, '紫菜蛋花汤', 1, 5, '汤羹', 'all', 'lunch,dinner'),
(81, '玉米排骨汤', 1, 60, '汤羹', 'all', 'dinner'),
(82, '西湖牛肉羹', 2, 25, '汤羹', 'winter', 'dinner'),
(83, '酸辣汤', 2, 15, '汤羹', 'winter', 'lunch,dinner'),
(84, '鲫鱼豆腐汤', 2, 30, '汤羹', 'all', 'dinner'),
(85, '莲藕排骨汤', 1, 60, '汤羹', 'autumn,winter', 'dinner'),
(86, '银耳莲子羹', 1, 40, '汤羹', 'summer,autumn', 'all');
-- ============================================================
-- 凉菜类8道
-- ============================================================
INSERT INTO recipes (id, name, difficulty, cook_time, category, season, meal_time) VALUES
(87, '凉拌黄瓜', 1, 5, '凉菜', 'summer', 'lunch,dinner'),
(88, '皮蛋豆腐', 1, 5, '凉菜', 'summer', 'lunch,dinner'),
(89, '凉拌木耳', 1, 8, '凉菜', 'summer', 'lunch,dinner'),
(90, '凉拌三丝', 1, 10, '凉菜', 'summer', 'lunch,dinner'),
(91, '糖拌番茄', 1, 3, '凉菜', 'summer', 'all'),
(92, '凉拌鸡丝', 1, 15, '凉菜', 'summer', 'lunch,dinner'),
(93, '拍黄瓜', 1, 3, '凉菜', 'summer', 'lunch,dinner'),
(94, '凉拌海带丝', 1, 8, '凉菜', 'summer', 'lunch,dinner');
-- ============================================================
-- 主食类6道
-- ============================================================
INSERT INTO recipes (id, name, difficulty, cook_time, category, season, meal_time) VALUES
(95, '蛋炒饭', 1, 8, '主食', 'all', 'all'),
(96, '番茄鸡蛋面', 1, 12, '主食', 'all', 'all'),
(97, '炸酱面', 2, 25, '主食', 'all', 'lunch,dinner'),
(98, '葱油拌面', 1, 10, '主食', 'all', 'all'),
(99, '炒米粉', 1, 10, '主食', 'all', 'all'),
(100, '阳春面', 1, 8, '主食', 'all', 'breakfast,lunch');
-- ============================================================
-- 菜谱食材数据
-- ============================================================
-- 家常快手菜 (1-20)
INSERT INTO recipe_ingredients (recipe_id, ingredient_name, amount, is_staple) VALUES
(1, '西红柿', '2个', 1),(1, '鸡蛋', '3个', 1),(1, '', '1根', 0),(1, '', '少许', 0),(1, '', '适量', 0),
(2, '土豆', '2个', 1),(2, '干辣椒', '3个', 0),(2, '花椒', '少许', 0),(2, '', '1勺', 0),(2, '', '适量', 0),
(3, '猪肉', '150克', 1),(3, '青椒', '2个', 1),(3, '', '2片', 0),(3, '', '2瓣', 0),(3, '生抽', '1勺', 0),(3, '料酒', '1勺', 0),(3, '', '适量', 0),(3, '淀粉', '少许', 0),
(4, '西兰花', '1颗', 1),(4, '', '5瓣', 0),(4, '', '适量', 0),(4, '蚝油', '1勺', 0),
(5, '猪里脊', '200克', 1),(5, '鸡蛋', '1个', 0),(5, '淀粉', '适量', 0),(5, '番茄酱', '3勺', 0),(5, '', '2勺', 0),(5, '', '1勺', 0),(5, '', '适量', 0),
(6, '四季豆', '300克', 1),(6, '干辣椒', '5个', 0),(6, '花椒', '少许', 0),(6, '', '3瓣', 0),(6, '', '适量', 0),
(7, '鸡翅', '10个', 1),(7, '可乐', '1罐', 0),(7, '', '3片', 0),(7, '生抽', '2勺', 0),(7, '料酒', '1勺', 0),(7, '', '适量', 0),
(8, '韭黄', '200克', 1),(8, '鸡蛋', '3个', 1),(8, '', '适量', 0),
(9, '豆腐', '1块', 1),(9, '', '3瓣', 0),(9, '生抽', '2勺', 0),(9, '蚝油', '1勺', 0),(9, '', '少许', 0),(9, '', '适量', 0),
(10, '生菜', '300克', 1),(10, '', '5瓣', 0),(10, '蚝油', '2勺', 0),(10, '', '适量', 0),
(11, '土豆', '2个', 1),(11, '孜然粉', '1勺', 0),(11, '辣椒粉', '适量', 0),(11, '', '适量', 0),
(12, '洋葱', '1个', 1),(12, '猪肉', '150克', 1),(12, '生抽', '1勺', 0),(12, '料酒', '1勺', 0),(12, '', '适量', 0),
(13, '香菇', '8朵', 1),(13, '油菜', '200克', 1),(13, '', '3瓣', 0),(13, '蚝油', '1勺', 0),(13, '', '适量', 0),
(14, '豆腐', '1块', 1),(14, '青椒', '1个', 0),(14, '木耳', '适量', 0),(14, '生抽', '2勺', 0),(14, '蚝油', '1勺', 0),(14, '', '适量', 0),
(15, '雪菜', '100克', 1),(15, '猪肉', '150克', 1),(15, '', '2片', 0),(15, '料酒', '1勺', 0),(15, '', '适量', 0),
(16, '豆芽', '300克', 1),(16, '', '3瓣', 0),(16, '', '1勺', 0),(16, '干辣椒', '3个', 0),(16, '', '适量', 0),
(17, '芹菜', '200克', 1),(17, '香干', '150克', 1),(17, '', '2瓣', 0),(17, '生抽', '1勺', 0),(17, '', '适量', 0),
(18, '番茄', '2个', 1),(18, '菜花', '300克', 1),(18, '', '3瓣', 0),(18, '', '适量', 0),(18, '', '少许', 0),
(19, '茄子', '2根', 1),(19, '猪肉末', '100克', 1),(19, '', '5瓣', 0),(19, '', '2片', 0),(19, '生抽', '2勺', 0),(19, '蚝油', '1勺', 0),(19, '', '少许', 0),(19, '', '适量', 0),
(20, '羊肉', '200克', 1),(20, '大葱', '2根', 1),(20, '', '2片', 0),(20, '料酒', '1勺', 0),(20, '生抽', '1勺', 0),(20, '', '适量', 0),(20, '孜然粉', '少许', 0);
-- 川菜 (21-35)
INSERT INTO recipe_ingredients (recipe_id, ingredient_name, amount, is_staple) VALUES
(21, '豆腐', '1块', 1),(21, '猪肉末', '80克', 0),(21, '豆瓣酱', '1勺', 0),(21, '花椒粉', '少许', 0),(21, '', '2片', 0),(21, '', '3瓣', 0),(21, '生抽', '1勺', 0),
(22, '五花肉', '300克', 1),(22, '蒜苗', '3根', 1),(22, '豆瓣酱', '1勺', 0),(22, '', '2片', 0),(22, '', '3瓣', 0),(22, '生抽', '1勺', 0),(22, '料酒', '1勺', 0),
(23, '鸡胸肉', '300克', 1),(23, '花生米', '50克', 1),(23, '干辣椒', '5个', 0),(23, '花椒', '少许', 0),(23, '', '2片', 0),(23, '', '3瓣', 0),(23, '生抽', '1勺', 0),(23, '', '1勺', 0),(23, '', '1勺', 0),(23, '料酒', '1勺', 0),(23, '淀粉', '少许', 0),
(24, '猪肉', '200克', 1),(24, '白菜', '200克', 1),(24, '豆芽', '100克', 0),(24, '干辣椒', '10个', 0),(24, '花椒', '1勺', 0),(24, '豆瓣酱', '1勺', 0),(24, '', '3片', 0),(24, '', '5瓣', 0),
(25, '猪肉', '200克', 1),(25, '木耳', '适量', 0),(25, '胡萝卜', '半根', 0),(25, '青椒', '1个', 0),(25, '豆瓣酱', '1勺', 0),(25, '', '2片', 0),(25, '', '3瓣', 0),(25, '生抽', '1勺', 0),(25, '', '1勺', 0),(25, '', '1勺', 0),(25, '淀粉', '少许', 0),
(26, '鸡腿肉', '300克', 1),(26, '干辣椒', '15个', 0),(26, '花椒', '1勺', 0),(26, '', '3片', 0),(26, '', '5瓣', 0),(26, '料酒', '1勺', 0),(26, '生抽', '1勺', 0),(26, '淀粉', '少许', 0),(26, '', '适量', 0),
(27, '牛腱子', '300克', 1),(27, '牛肚', '150克', 1),(27, '花生米', '50克', 0),(27, '香菜', '适量', 0),(27, '辣椒油', '2勺', 0),(27, '花椒粉', '少许', 0),(27, '', '5瓣', 0),(27, '生抽', '2勺', 0),(27, '', '1勺', 0),(27, '', '少许', 0),
(28, '鸡腿', '2只', 1),(28, '', '5片', 0),(28, '', '2根', 0),(28, '辣椒油', '2勺', 0),(28, '花椒粉', '少许', 0),(28, '生抽', '2勺', 0),(28, '', '1勺', 0),(28, '', '少许', 0),(28, '', '5瓣', 0),(28, '花生碎', '适量', 0),
(29, '牛肉', '200克', 1),(29, '芹菜', '100克', 1),(29, '干辣椒', '5个', 0),(29, '', '2片', 0),(29, '', '3瓣', 0),(29, '生抽', '1勺', 0),(29, '料酒', '1勺', 0),(29, '', '适量', 0),
(30, '五花肉', '300克', 1),(30, '', '10瓣', 0),(30, '', '3片', 0),(30, '生抽', '2勺', 0),(30, '', '1勺', 0),(30, '辣椒油', '2勺', 0),(30, '', '少许', 0),(30, '料酒', '1勺', 0),
(31, '草鱼', '1条', 1),(31, '酸菜', '200克', 1),(31, '干辣椒', '5个', 0),(31, '花椒', '少许', 0),(31, '', '5片', 0),(31, '', '5瓣', 0),(31, '料酒', '2勺', 0),(31, '淀粉', '适量', 0),(31, '', '适量', 0),
(32, '鸭血', '200克', 1),(32, '毛肚', '150克', 1),(32, '午餐肉', '100克', 1),(32, '豆芽', '100克', 0),(32, '干辣椒', '10个', 0),(32, '花椒', '1勺', 0),(32, '豆瓣酱', '1勺', 0),(32, '', '5片', 0),(32, '', '5瓣', 0),
(33, '', '150克', 1),(33, '', '100克', 1),(33, '土豆', '1个', 1),(33, '花菜', '100克', 0),(33, '干辣椒', '10个', 0),(33, '花椒', '1勺', 0),(33, '豆瓣酱', '1勺', 0),(33, '', '5瓣', 0),(33, '', '3片', 0),(33, '生抽', '1勺', 0),
(34, '鸡爪', '500克', 1),(34, '泡椒', '10个', 0),(34, '', '5片', 0),(34, '', '5瓣', 0),(34, '料酒', '2勺', 0),(34, '白醋', '2勺', 0),(34, '', '适量', 0),
(35, '面条', '200克', 1),(35, '猪肉末', '100克', 0),(35, '芝麻酱', '2勺', 0),(35, '辣椒油', '1勺', 0),(35, '花椒粉', '少许', 0),(35, '生抽', '1勺', 0),(35, '', '少许', 0),(35, '', '3瓣', 0),(35, '', '1根', 0);
-- 粤菜 (36-50)
INSERT INTO recipe_ingredients (recipe_id, ingredient_name, amount, is_staple) VALUES
(36, '', '1只', 1),(36, '', '5片', 0),(36, '', '3根', 0),(36, '料酒', '2勺', 0),(36, '', '适量', 0),
(37, '鲈鱼', '1条', 1),(37, '', '5片', 0),(37, '', '2根', 0),(37, '蒸鱼豉油', '2勺', 0),(37, '料酒', '1勺', 0),(37, '', '适量', 0),
(38, '', '500克', 1),(38, '', '3片', 0),(38, '料酒', '1勺', 0),(38, '', '适量', 0),
(39, '梅花肉', '500克', 1),(39, '叉烧酱', '3勺', 0),(39, '蜂蜜', '2勺', 0),(39, '生抽', '2勺', 0),(39, '料酒', '1勺', 0),(39, '', '3片', 0),(39, '', '3瓣', 0),
(40, '猪里脊', '200克', 1),(40, '菠萝', '150克', 1),(40, '青椒', '1个', 0),(40, '番茄酱', '3勺', 0),(40, '', '2勺', 0),(40, '', '1勺', 0),(40, '淀粉', '适量', 0),(40, '', '适量', 0),(40, '鸡蛋', '1个', 0),
(41, '河粉', '300克', 1),(41, '牛肉', '150克', 1),(41, '豆芽', '100克', 0),(41, '', '2根', 0),(41, '生抽', '2勺', 0),(41, '老抽', '1勺', 0),(41, '料酒', '1勺', 0),(41, '淀粉', '少许', 0),
(42, '扇贝', '6个', 1),(42, '粉丝', '1把', 0),(42, '', '10瓣', 0),(42, '生抽', '1勺', 0),(42, '蚝油', '1勺', 0),(42, '', '适量', 0),
(43, '排骨', '300克', 1),(43, '豆豉', '1勺', 0),(43, '', '5瓣', 0),(43, '', '2片', 0),(43, '生抽', '1勺', 0),(43, '料酒', '1勺', 0),(43, '淀粉', '少许', 0),(43, '', '少许', 0),
(44, '大米', '200克', 1),(44, '腊肠', '1根', 1),(44, '排骨', '150克', 1),(44, '油菜', '3棵', 0),(44, '生抽', '2勺', 0),(44, '蚝油', '1勺', 0),(44, '', '2片', 0),(44, '', '适量', 0),
(45, '排骨', '300克', 1),(45, '冬瓜', '500克', 1),(45, '', '3片', 0),(45, '', '适量', 0),(45, '料酒', '1勺', 0),
(46, '牛肉', '200克', 1),(46, '青椒', '1个', 0),(46, '蚝油', '2勺', 0),(46, '', '2片', 0),(46, '', '3瓣', 0),(46, '生抽', '1勺', 0),(46, '料酒', '1勺', 0),(46, '淀粉', '少许', 0),
(47, '虾仁', '200克', 1),(47, '', '50克', 1),(47, '澄粉', '150克', 1),(47, '淀粉', '50克', 0),(47, '猪油', '少许', 0),(47, '', '适量', 0),(47, '', '少许', 0),
(48, '大米', '200克', 1),(48, '虾仁', '50克', 1),(48, '牛肉', '50克', 1),(48, '鸡蛋', '1个', 0),(48, '生抽', '1勺', 0),(48, '', '适量', 0),
(49, '面粉', '250克', 1),(49, '叉烧肉', '150克', 1),(49, '酵母', '3克', 0),(49, '', '30克', 0),(49, '蚝油', '1勺', 0),(49, '生抽', '1勺', 0),
(50, '鸡蛋', '2个', 1),(50, '牛奶', '100毫升', 1),(50, '', '30克', 0),(50, '面粉', '80克', 0),(50, '黄油', '10克', 0);
-- 湘菜 (51-58)
INSERT INTO recipe_ingredients (recipe_id, ingredient_name, amount, is_staple) VALUES
(51, '鱼头', '1个', 1),(51, '剁椒', '100克', 0),(51, '', '5片', 0),(51, '', '5瓣', 0),(51, '', '2根', 0),(51, '料酒', '2勺', 0),(51, '蒸鱼豉油', '2勺', 0),(51, '', '适量', 0),
(52, '五花肉', '200克', 1),(52, '青椒', '5个', 1),(52, '', '5瓣', 0),(52, '', '2片', 0),(52, '生抽', '1勺', 0),(52, '', '适量', 0),
(53, '酸豆角', '200克', 1),(53, '猪肉末', '150克', 1),(53, '干辣椒', '3个', 0),(53, '', '3瓣', 0),(53, '', '2片', 0),(53, '生抽', '1勺', 0),(53, '', '适量', 0),
(54, '腊肉', '200克', 1),(54, '腊鱼', '150克', 1),(54, '', '3片', 0),(54, '', '3瓣', 0),(54, '干辣椒', '3个', 0),(54, '料酒', '1勺', 0),
(55, '花菜', '300克', 1),(55, '五花肉', '80克', 0),(55, '干辣椒', '5个', 0),(55, '', '5瓣', 0),(55, '生抽', '1勺', 0),(55, '', '适量', 0),
(56, '小龙虾', '1000克', 1),(56, '干辣椒', '15个', 0),(56, '花椒', '1勺', 0),(56, '豆瓣酱', '1勺', 0),(56, '', '5片', 0),(56, '', '10瓣', 0),(56, '生抽', '2勺', 0),(56, '料酒', '2勺', 0),(56, '', '少许', 0),(56, '', '适量', 0),
(57, '青椒', '4个', 1),(57, '皮蛋', '2个', 1),(57, '', '5瓣', 0),(57, '', '1勺', 0),(57, '生抽', '1勺', 0),(57, '', '适量', 0),
(58, '猪肉末', '150克', 1),(58, '萝卜干', '50克', 1),(58, '干辣椒', '3个', 0),(58, '', '3瓣', 0),(58, '生抽', '1勺', 0),(58, '', '适量', 0);
-- 东北菜 (59-66)
INSERT INTO recipe_ingredients (recipe_id, ingredient_name, amount, is_staple) VALUES
(59, '猪里脊', '250克', 1),(59, '淀粉', '适量', 0),(59, '鸡蛋', '1个', 0),(59, '胡萝卜', '半根', 0),(59, '', '3片', 0),(59, '', '3勺', 0),(59, '白醋', '3勺', 0),(59, '', '适量', 0),
(60, '土豆', '1个', 1),(60, '茄子', '1根', 1),(60, '青椒', '1个', 1),(60, '', '5瓣', 0),(60, '生抽', '2勺', 0),(60, '', '少许', 0),(60, '', '适量', 0),(60, '淀粉', '少许', 0),
(61, '五花肉', '200克', 1),(61, '粉条', '100克', 1),(61, '白菜', '200克', 0),(61, '', '3片', 0),(61, '', '3瓣', 0),(61, '生抽', '2勺', 0),(61, '老抽', '1勺', 0),(61, '料酒', '1勺', 0),(61, '', '适量', 0),
(62, '排骨', '200克', 1),(62, '土豆', '1个', 1),(62, '豆角', '200克', 1),(62, '玉米', '1根', 0),(62, '', '3片', 0),(62, '生抽', '2勺', 0),(62, '', '适量', 0),
(63, '猪里脊', '200克', 1),(63, '大葱', '2根', 1),(63, '豆腐皮', '适量', 0),(63, '', '2片', 0),(63, '甜面酱', '2勺', 0),(63, '', '少许', 0),(63, '生抽', '1勺', 0),(63, '淀粉', '少许', 0),
(64, '地瓜', '1个', 1),(64, '', '150克', 0),(64, '', '适量', 0),
(65, '五花肉', '300克', 1),(65, '酸菜', '200克', 1),(65, '', '3片', 0),(65, '', '3瓣', 0),(65, '料酒', '1勺', 0),(65, '', '适量', 0),
(66, '猪里脊', '200克', 1),(66, '青椒', '1个', 0),(66, '胡萝卜', '半根', 0),(66, '淀粉', '适量', 0),(66, '', '2片', 0),(66, '', '3瓣', 0),(66, '生抽', '2勺', 0),(66, '', '1勺', 0),(66, '', '1勺', 0),(66, '', '适量', 0);
-- 早餐类 (67-78)
INSERT INTO recipe_ingredients (recipe_id, ingredient_name, amount, is_staple) VALUES
(67, '面粉', '200克', 1),(67, '鸡蛋', '2个', 1),(67, '', '2根', 0),(67, '', '适量', 0),
(68, '大米', '100克', 1),(68, '皮蛋', '2个', 1),(68, '猪肉末', '50克', 0),(68, '', '2片', 0),(68, '', '适量', 0),
(69, '鸡蛋', '3个', 1),(69, '面粉', '100克', 1),(69, '', '2根', 0),(69, '', '适量', 0),
(70, '小米', '100克', 1),(70, '南瓜', '200克', 1),(70, '', '少许', 0),
(71, '饺子', '15个', 1),(71, '', '适量', 0),(71, '', '蘸料', 0),
(72, '吐司', '4片', 1),(72, '鸡蛋', '1个', 1),(72, '火腿', '2片', 1),(72, '生菜', '2片', 0),(72, '番茄', '1个', 0),(72, '沙拉酱', '适量', 0),
(73, '面粉', '200克', 1),(73, '黄豆', '100克', 1),(73, '酵母', '2克', 0),(73, '泡打粉', '1克', 0),(73, '', '适量', 0),(73, '', '适量', 0),
(74, '猪肉末', '200克', 1),(74, '馄饨皮', '30张', 1),(74, '虾皮', '适量', 0),(74, '紫菜', '适量', 0),(74, '', '2片', 0),(74, '生抽', '1勺', 0),(74, '料酒', '1勺', 0),(74, '', '适量', 0),(74, '香油', '少许', 0),
(75, '五花肉', '500克', 1),(75, '面粉', '300克', 1),(75, '酵母', '3克', 0),(75, '', '3片', 0),(75, '生抽', '2勺', 0),(75, '老抽', '1勺', 0),(75, '料酒', '1勺', 0),(75, '', '适量', 0),
(76, '糯米', '200克', 1),(76, '鸡腿肉', '150克', 1),(76, '干香菇', '5朵', 0),(76, '生抽', '1勺', 0),(76, '蚝油', '1勺', 0),(76, '', '适量', 0),
(77, '面粉', '150克', 1),(77, '鸡蛋', '2个', 1),(77, '薄脆', '2片', 0),(77, '生菜', '2片', 0),(77, '', '1根', 0),(77, '甜面酱', '适量', 0),(77, '辣椒酱', '适量', 0),(77, '', '适量', 0),
(78, '面粉', '200克', 1),(78, '红豆沙', '150克', 1),(78, '酵母', '2克', 0),(78, '', '20克', 0);
-- 汤羹类 (79-86)
INSERT INTO recipe_ingredients (recipe_id, ingredient_name, amount, is_staple) VALUES
(79, '番茄', '2个', 1),(79, '鸡蛋', '2个', 1),(79, '', '适量', 0),(79, '香油', '少许', 0),
(80, '紫菜', '适量', 1),(80, '鸡蛋', '2个', 1),(80, '虾皮', '少许', 0),(80, '', '适量', 0),(80, '香油', '少许', 0),
(81, '排骨', '300克', 1),(81, '玉米', '2根', 1),(81, '胡萝卜', '1根', 0),(81, '', '3片', 0),(81, '', '适量', 0),(81, '料酒', '1勺', 0),
(82, '牛肉末', '100克', 1),(82, '豆腐', '1块', 0),(82, '鸡蛋清', '1个', 0),(82, '香菜', '适量', 0),(82, '', '2片', 0),(82, '淀粉', '适量', 0),(82, '', '适量', 0),(82, '香油', '少许', 0),
(83, '豆腐', '1块', 1),(83, '木耳', '适量', 0),(83, '鸡蛋', '1个', 0),(83, '', '2勺', 0),(83, '白胡椒粉', '1勺', 0),(83, '淀粉', '适量', 0),(83, '', '适量', 0),(83, '香油', '少许', 0),
(84, '鲫鱼', '1条', 1),(84, '豆腐', '1块', 1),(84, '', '5片', 0),(84, '', '2根', 0),(84, '料酒', '1勺', 0),(84, '', '适量', 0),
(85, '排骨', '300克', 1),(85, '莲藕', '1节', 1),(85, '', '3片', 0),(85, '', '适量', 0),(85, '料酒', '1勺', 0),
(86, '银耳', '1朵', 1),(86, '莲子', '50克', 1),(86, '冰糖', '适量', 0);
-- 凉菜类 (87-94)
INSERT INTO recipe_ingredients (recipe_id, ingredient_name, amount, is_staple) VALUES
(87, '黄瓜', '2根', 1),(87, '', '5瓣', 0),(87, '', '2勺', 0),(87, '生抽', '1勺', 0),(87, '', '适量', 0),(87, '香油', '少许', 0),
(88, '皮蛋', '2个', 1),(88, '豆腐', '1块', 1),(88, '', '3瓣', 0),(88, '生抽', '1勺', 0),(88, '', '1勺', 0),(88, '香油', '少许', 0),(88, '', '适量', 0),
(89, '木耳', '适量', 1),(89, '', '5瓣', 0),(89, '', '1勺', 0),(89, '生抽', '1勺', 0),(89, '辣椒油', '适量', 0),(89, '', '适量', 0),(89, '香油', '少许', 0),
(90, '黄瓜', '1根', 1),(90, '胡萝卜', '半根', 1),(90, '粉丝', '1把', 1),(90, '', '5瓣', 0),(90, '', '2勺', 0),(90, '生抽', '1勺', 0),(90, '', '适量', 0),(90, '香油', '少许', 0),
(91, '番茄', '2个', 1),(91, '', '2勺', 0),
(92, '鸡胸肉', '200克', 1),(92, '黄瓜', '半根', 0),(92, '', '5瓣', 0),(92, '', '1勺', 0),(92, '生抽', '1勺', 0),(92, '辣椒油', '适量', 0),(92, '', '适量', 0),(92, '香油', '少许', 0),
(93, '黄瓜', '2根', 1),(93, '', '5瓣', 0),(93, '', '2勺', 0),(93, '生抽', '1勺', 0),(93, '', '适量', 0),(93, '辣椒油', '适量', 0),
(94, '海带丝', '200克', 1),(94, '', '5瓣', 0),(94, '', '1勺', 0),(94, '生抽', '1勺', 0),(94, '辣椒油', '适量', 0),(94, '', '适量', 0),(94, '香油', '少许', 0);
-- 主食类 (95-100)
INSERT INTO recipe_ingredients (recipe_id, ingredient_name, amount, is_staple) VALUES
(95, '米饭', '300克', 1),(95, '鸡蛋', '2个', 1),(95, '', '1根', 0),(95, '', '适量', 0),
(96, '面条', '200克', 1),(96, '番茄', '2个', 1),(96, '鸡蛋', '2个', 1),(96, '', '1根', 0),(96, '', '适量', 0),(96, '生抽', '1勺', 0),
(97, '面条', '200克', 1),(97, '猪肉末', '150克', 1),(97, '黄豆酱', '1勺', 0),(97, '甜面酱', '1勺', 0),(97, '黄瓜', '半根', 0),(97, '', '2片', 0),(97, '', '3瓣', 0),(97, '料酒', '1勺', 0),
(98, '面条', '200克', 1),(98, '', '5根', 1),(98, '生抽', '2勺', 0),(98, '老抽', '1勺', 0),(98, '', '少许', 0),
(99, '米粉', '200克', 1),(99, '鸡蛋', '2个', 1),(99, '豆芽', '100克', 0),(99, '生抽', '1勺', 0),(99, '', '适量', 0),
(100, '面条', '200克', 1),(100, '', '2根', 0),(100, '生抽', '1勺', 0),(100, '香油', '少许', 0),(100, '', '适量', 0);
-- ============================================================
-- 菜谱步骤数据只列关键步骤每道菜3-4步
-- ============================================================
INSERT INTO recipe_steps (recipe_id, step_order, content) VALUES
(1,1,'西红柿洗净切块,鸡蛋打散加少许盐搅匀'),(1,2,'热锅凉油,倒入蛋液划散盛出'),(1,3,'锅留底油爆香葱,下西红柿翻炒出汁'),(1,4,'倒入鸡蛋,加糖和盐调味,翻炒均匀出锅'),
(2,1,'土豆去皮切细丝,冷水浸泡去淀粉后沥干'),(2,2,'热锅凉油爆香干辣椒和花椒'),(2,3,'下土豆丝大火翻炒,加醋和盐调味'),(2,4,'炒至土豆丝断生即可出锅'),
(3,1,'猪肉切丝加料酒、生抽、淀粉腌制10分钟'),(3,2,'青椒切丝,姜蒜切末'),(3,3,'热油滑炒肉丝至变色盛出'),(3,4,'爆香姜蒜下青椒翻炒,倒回肉丝加调料炒匀'),
(4,1,'西兰花掰小朵焯水1分钟捞出'),(4,2,'蒜切末热油爆香'),(4,3,'下西兰花大火快炒,加蚝油和盐调味'),
(5,1,'里脊肉切条加蛋液、盐、淀粉腌制'),(5,2,'六成油温炸至金黄捞出,再复炸至酥脆'),(5,3,'番茄酱、糖、醋、水调成糖醋汁'),(5,4,'锅留底油倒入糖醋汁熬至浓稠,倒肉条裹匀'),
(6,1,'四季豆去筋掰段焯水沥干'),(6,2,'热油爆香干辣椒、花椒和蒜末'),(6,3,'下四季豆大火煸炒至表面起皱'),(6,4,'加盐调味炒匀出锅'),
(7,1,'鸡翅表面划刀焯水去血沫'),(7,2,'热油煎鸡翅至两面金黄'),(7,3,'加姜片、料酒、生抽翻炒'),(7,4,'倒入可乐没过鸡翅,中火炖至汤汁浓稠'),
(8,1,'韭黄洗净切段,鸡蛋打散'),(8,2,'热油炒鸡蛋至凝固盛出'),(8,3,'锅中炒韭黄至变软,倒回鸡蛋加盐炒匀'),
(9,1,'豆腐切块焯水去豆腥味'),(9,2,'热油爆香蒜末加生抽蚝油'),(9,3,'加少量水放入豆腐中火炖5分钟'),(9,4,'加糖和盐调味大火收汁出锅'),
(10,1,'生菜洗净掰开焯水5秒捞出摆盘'),(10,2,'蒜切末热油爆香加蚝油略炒'),(10,3,'浇在生菜上即可'),
(11,1,'土豆去皮切片,热油煎至两面金黄'),(11,2,'撒孜然粉、辣椒粉和盐'),(11,3,'翻匀出锅'),
(12,1,'洋葱切丝,猪肉切片加料酒生抽腌制'),(12,2,'热油炒肉片至变色盛出'),(12,3,'炒洋葱至变软倒回肉片加盐炒匀'),
(13,1,'香菇泡发切片,油菜焯水备用'),(13,2,'热油爆香蒜末下香菇翻炒'),(13,3,'加蚝油和盐调味'),(13,4,'放入油菜翻匀出锅'),
(14,1,'豆腐切块煎至两面金黄'),(14,2,'青椒和木耳炒香'),(14,3,'加生抽蚝油和少量水放入豆腐炖3分钟'),(14,4,'加盐调味收汁出锅'),
(15,1,'猪肉切丝加料酒腌制,雪菜切碎'),(15,2,'热油下姜片炒肉丝至变色'),(15,3,'加雪菜翻炒加盐调味出锅'),
(16,1,'豆芽洗净沥干'),(16,2,'热油爆香蒜末和干辣椒'),(16,3,'下豆芽大火快炒加醋和盐,断生即出锅'),
(17,1,'芹菜切段焯水,香干切片'),(17,2,'热油爆香蒜末下香干煸炒'),(17,3,'加芹菜翻炒加生抽和盐调味'),
(18,1,'番茄切块,菜花掰小朵焯水'),(18,2,'热油下番茄翻炒出汁'),(18,3,'加菜花翻炒加盐和糖调味'),
(19,1,'茄子切滚刀块加盐腌制挤水'),(19,2,'热油煸炒茄条至软盛出'),(19,3,'爆香姜蒜加肉末炒至变色'),(19,4,'加生抽蚝油倒回茄子翻炒收汁'),
(20,1,'羊肉切片加料酒生抽腌制'),(20,2,'大葱斜刀切片'),(20,3,'热油爆香姜片下羊肉大火快炒'),(20,4,'羊肉变色后加葱片、孜然粉和盐快速翻炒出锅'),
(21,1,'豆腐切块焯水备用'),(21,2,'热油炒肉末至变色加豆瓣酱炒出红油'),(21,3,'加姜蒜末和适量水放入豆腐中火炖5分钟'),(21,4,'撒花椒粉出锅'),
(22,1,'五花肉冷水下锅加姜料酒煮至八成熟切片'),(22,2,'热锅少油煸炒肉片至卷曲出油'),(22,3,'加豆瓣酱姜蒜炒出红油'),(22,4,'加蒜苗段和生抽翻炒至蒜苗断生'),
(23,1,'鸡胸肉切丁加料酒淀粉腌制,花生米油炸备用'),(23,2,'调酱汁:生抽+醋+糖+淀粉+水'),(23,3,'热油爆香干辣椒花椒姜蒜下鸡丁滑炒'),(23,4,'倒酱汁翻炒至浓稠加花生米翻匀'),
(24,1,'猪肉切片加淀粉料酒腌制'),(24,2,'热油爆香豆瓣酱姜蒜干辣椒花椒'),(24,3,'加水煮沸下白菜豆芽煮熟捞出铺碗底'),(24,4,'肉片下锅滑熟连汤倒入碗中,表面撒干辣椒花椒淋热油'),
(25,1,'猪肉切丝加料酒淀粉腌制'),(25,2,'调鱼香汁:生抽+醋+糖+淀粉+水'),(25,3,'木耳胡萝卜青椒切丝,热油爆香豆瓣酱姜蒜'),(25,4,'下肉丝滑炒加配菜倒鱼香汁炒匀'),
(26,1,'鸡腿肉切丁加料酒生抽淀粉腌制'),(26,2,'干辣椒剪段,热油炸鸡丁至金黄捞出'),(26,3,'锅留底油爆香干辣椒花椒姜蒜'),(26,4,'倒回鸡丁快速翻炒加盐出锅'),
(27,1,'牛腱子和牛肚加姜料酒卤煮至熟烂切片'),(27,2,'调汁:辣椒油+花椒粉+蒜泥+生抽+醋+糖'),(27,3,'浇在肉片上撒花生碎和香菜'),
(28,1,'鸡腿加姜葱料酒煮熟捞出冰水过凉'),(28,2,'斩块摆盘'),(28,3,'调汁:辣椒油+花椒粉+蒜泥+生抽+醋+糖'),(28,4,'浇汁撒花生碎和葱花'),
(29,1,'牛肉切丝加料酒生抽腌制'),(29,2,'芹菜切段,干辣椒剪段'),(29,3,'热油爆香干辣椒姜蒜下牛肉丝煸炒'),(29,4,'加芹菜翻炒加盐出锅'),
(30,1,'五花肉加姜片料酒冷水煮熟切片摆盘'),(30,2,'调汁:蒜泥+生抽+醋+辣椒油+糖'),(30,3,'浇在肉片上即可'),
(31,1,'草鱼片加料酒盐淀粉腌制,酸菜切丝'),(31,2,'热油炒酸菜加姜蒜干辣椒花椒'),(31,3,'加水煮沸下鱼片滑熟'),(31,4,'表面撒干辣椒花椒淋热油'),
(32,1,'鸭血毛肚午餐肉切片焯水'),(32,2,'热油爆香豆瓣酱姜蒜干辣椒花椒'),(32,3,'加水煮沸下所有食材煮5分钟'),(32,4,'撒蒜末淋热油'),
(33,1,'虾去虾线藕土豆切片焯水'),(33,2,'热油爆香豆瓣酱姜蒜干辣椒花椒'),(33,3,'下所有食材大火翻炒加生抽'),(33,4,'炒至入味撒芝麻出锅'),
(34,1,'鸡爪剪指甲对半切开焯水'),(34,2,'加姜蒜料酒煮熟捞出'),(34,3,'泡椒连水加白醋和盐调成泡汁'),(34,4,'鸡爪浸入泡汁冷藏4小时以上'),
(35,1,'面条煮熟过凉水沥干'),(35,2,'芝麻酱调稀加生抽醋蒜泥辣椒油花椒粉'),(35,3,'猪肉末炒香加调料做臊子'),(35,4,'面条拌酱撒臊子和葱花'),
(36,1,'整鸡处理干净加姜葱料酒冷水煮开'),(36,2,'转小火浸煮20分钟至熟'),(36,3,'捞出冰水浸泡使皮爽滑'),(36,4,'斩块摆盘配姜葱油蘸料'),
(37,1,'鲈鱼处理干净两侧划刀抹盐和料酒'),(37,2,'鱼身铺姜片葱段'),(37,3,'水开上锅大火蒸8-10分钟'),(37,4,'倒掉蒸汁淋蒸鱼豉油,热油浇在鱼身上'),
(38,1,'虾洗净剪去虾须挑虾线'),(38,2,'水加姜片料酒烧开下虾'),(38,3,'煮至虾变红弯曲即可捞出摆盘'),
(39,1,'梅花肉加叉烧酱生抽料酒姜蒜腌制4小时'),(39,2,'烤箱200度烤15分钟取出刷蜂蜜'),(39,3,'翻面再烤15分钟至表面焦香'),(39,4,'切片装盘'),
(40,1,'里脊肉切丁加蛋液盐淀粉腌,菠萝青椒切块'),(40,2,'六成油温炸肉至金黄捞出'),(40,3,'番茄酱糖醋水调汁熬浓'),(40,4,'倒肉和菠萝青椒翻炒裹匀'),
(41,1,'牛肉切片加生抽料酒淀粉腌制'),(41,2,'河粉抖散热油炒至微焦盛出'),(41,3,'热油爆炒牛肉至变色加豆芽'),(41,4,'倒回河粉加生抽老抽大火翻炒均匀撒葱'),
(42,1,'粉丝温水泡软扇贝洗净'),(42,2,'蒜切末热油炸至金黄加生抽蚝油'),(42,3,'扇贝上铺粉丝和蒜蓉'),(42,4,'水开上锅大火蒸5分钟'),
(43,1,'排骨斩小块加所有调料腌制30分钟'),(43,2,'码入盘中'),(43,3,'水开上锅大火蒸15-20分钟'),
(44,1,'大米泡30分钟排骨腌制腊肠切片'),(44,2,'砂锅抹油放米加水大火煮开'),(44,3,'水快干时铺排骨腊肠转小火焖15分钟'),(44,4,'沿锅边淋油续焖3分钟加焯水油菜淋生抽蚝油'),
(45,1,'排骨焯水去血沫'),(45,2,'冬瓜去皮切大块'),(45,3,'排骨加姜片料酒足量水大火煮开转小火炖40分钟'),(45,4,'加冬瓜续炖15分钟加盐调味'),
(46,1,'牛肉切片加料酒生抽淀粉腌制'),(46,2,'青椒切块,热油滑牛肉至变色盛出'),(46,3,'爆香姜蒜下青椒翻炒'),(46,4,'倒回牛肉加蚝油翻炒均匀出锅'),
(47,1,'虾仁切粒加调料拌匀做馅'),(47,2,'澄粉加淀粉开水烫面揉匀'),(47,3,'擀皮包入虾馅捏成饺子形'),(47,4,'水开上锅大火蒸8分钟'),
(48,1,'大米提前泡好磨成米浆'),(48,2,'平盘刷油倒薄薄一层米浆'),(48,3,'撒虾仁或牛肉碎上锅蒸2分钟'),(48,4,'刮起装盘淋生抽'),
(49,1,'面粉加酵母糖温水和面发酵至2倍大'),(49,2,'叉烧肉切小丁加蚝油生抽调馅'),(49,3,'面团分剂子包入叉烧馅'),(49,4,'二次发酵后上锅大火蒸12分钟'),
(50,1,'鸡蛋打散加糖牛奶搅匀过筛'),(50,2,'倒入蛋挞皮八分满'),(50,3,'烤箱200度烤15-20分钟至表面微焦'),
(51,1,'鱼头处理干净抹盐料酒腌制'),(51,2,'盘中铺姜葱放鱼头'),(51,3,'铺满剁椒上锅大火蒸10-12分钟'),(51,4,'倒掉蒸汁淋蒸鱼豉油,热油浇在剁椒上'),
(52,1,'五花肉切薄片青椒斜刀切圈'),(52,2,'热锅少油煸五花肉至出油卷曲'),(52,3,'加姜蒜爆香加青椒大火翻炒'),(52,4,'加生抽和盐炒至青椒断生'),
(53,1,'酸豆角切粒猪肉剁末'),(53,2,'热油炒肉末至变色加姜蒜干辣椒'),(53,3,'加酸豆角翻炒加生抽和盐'),
(54,1,'腊肉腊鱼温水洗净切片'),(54,2,'码入盘中加姜蒜干辣椒和料酒'),(54,3,'上锅大火蒸20分钟'),
(55,1,'花菜掰小朵焯水,五花肉切薄片'),(55,2,'热锅少油煸五花肉出油'),(55,3,'加干辣椒和蒜爆香下花菜翻炒'),(55,4,'加生抽和盐翻炒至花菜入味'),
(56,1,'小龙虾刷净去虾线焯水'),(56,2,'热油爆香豆瓣酱姜蒜干辣椒花椒'),(56,3,'下小龙虾翻炒加生抽料酒糖和适量水'),(56,4,'中火焖煮15分钟收汁'),
(57,1,'青椒直接放火上烤至表皮焦黑'),(57,2,'去皮撕成条'),(57,3,'皮蛋剥壳切瓣摆盘'),(57,4,'加蒜泥生抽醋盐拌匀'),
(58,1,'猪肉剁末萝卜干泡发切碎'),(58,2,'热油炒肉末至变色加姜蒜干辣椒'),(58,3,'加萝卜干翻炒加生抽和盐'),
(59,1,'里脊肉切厚片用刀背拍松加盐料酒腌制'),(59,2,'淀粉加水调糊肉片裹糊六成油温炸至金黄'),(59,3,'复炸至酥脆捞出'),(59,4,'糖醋水调汁熬浓倒肉片快速翻匀'),
(60,1,'土豆茄子青椒切滚刀块'),(60,2,'分别油炸:土豆炸金黄茄子炸软青椒过油'),(60,3,'爆香蒜末加生抽糖盐和少许水'),(60,4,'倒入所有食材翻炒均匀勾薄芡'),
(61,1,'五花肉切块焯水粉条泡软'),(61,2,'热锅少油煸五花肉至出油加姜蒜生抽老抽料酒'),(61,3,'加足量水大火煮开转小火炖30分钟'),(61,4,'加粉条和白菜续炖10分钟加盐调味'),
(62,1,'排骨焯水土豆切块豆角掰段玉米切段'),(62,2,'热油爆香姜片下排骨翻炒'),(62,3,'加生抽和足量水大火煮沸转小火炖20分钟'),(62,4,'加土豆豆角玉米续炖20分钟加盐'),
(63,1,'里脊肉切丝加生抽料酒淀粉腌制'),(63,2,'热油滑炒肉丝至变色盛出'),(63,3,'锅中炒甜面酱加糖和少许水'),(63,4,'倒回肉丝翻炒裹匀酱汁配葱丝和豆腐皮'),
(64,1,'地瓜去皮切滚刀块'),(64,2,'六成油温炸地瓜至金黄熟透捞出'),(64,3,'另起锅加糖和少量水小火熬至琥珀色'),(64,4,'迅速倒入地瓜块翻匀拉丝装盘'),
(65,1,'五花肉切大块焯水酸菜切丝'),(65,2,'热锅少油煸五花肉至出油加姜蒜料酒'),(65,3,'加足量水大火煮开转小火炖30分钟'),(65,4,'加酸菜续炖20分钟加盐'),
(66,1,'里脊肉切条加料酒盐腌制,淀粉加水调糊'),(66,2,'肉条裹糊六成油温炸至金黄捞出复炸'),(66,3,'调汁:生抽+糖+醋+淀粉+水'),(66,4,'爆香姜蒜下青椒胡萝卜倒汁熬浓加肉条翻匀'),
(67,1,'面粉加水和少许盐揉成光滑面团醒30分钟'),(67,2,'擀成薄饼刷油撒葱花'),(67,3,'卷起盘成圆饼再擀薄'),(67,4,'平底锅刷油中小火烙至两面金黄打入鸡蛋摊匀'),
(68,1,'大米泡30分钟加水大火煮开转小火'),(68,2,'皮蛋切丁猪肉末加料酒腌制'),(68,3,'粥熬至米粒开花加肉末皮蛋姜丝搅匀'),(68,4,'续煮5分钟加盐调味'),
(69,1,'鸡蛋打散加面粉盐和水搅成面糊'),(69,2,'平底锅刷油倒入面糊摊平'),(69,3,'中小火煎至底面金黄翻面'),(69,4,'煎至两面金黄撒葱花出锅切块'),
(70,1,'小米洗净南瓜去皮切小块'),(70,2,'加水大火煮开转小火'),(70,3,'熬25分钟至小米和南瓜软烂'),(70,4,'根据口味加少许糖'),
(71,1,'饺子提前包好或冷冻饺子'),(71,2,'平底锅刷油摆入饺子'),(71,3,'加没过饺子三分之一的水加盖中火煎'),(71,4,'水干后改小火煎至底部金黄酥脆'),
(72,1,'吐司去边鸡蛋打散煎成蛋皮'),(72,2,'吐司上依次铺生菜火腿番茄蛋皮'),(72,3,'挤沙拉酱盖上另一片吐司'),(72,4,'对角切开成三角形'),
(73,1,'黄豆泡发打豆浆过滤煮沸'),(73,2,'面粉加酵母泡打粉盐糖和面发酵'),(73,3,'面团分剂子擀成长条入油锅炸至金黄蓬松'),(73,4,'配豆浆食用'),
(74,1,'猪肉末加生抽料酒姜末盐和少许水调馅'),(74,2,'馄饨皮包入馅料捏紧'),(74,3,'水开下馄饨煮至浮起'),(74,4,'碗底放虾皮紫菜盐香油加汤盛入馄饨'),
(75,1,'五花肉切大块焯水加生抽老抽料酒姜炖烂'),(75,2,'面粉加酵母温水和面发酵至2倍大'),(75,3,'面团分剂子擀饼烙至两面微黄'),(75,4,'肉剁碎夹入饼中浇少许肉汁'),
(76,1,'糯米泡4小时鸡腿肉切丁加生抽蚝油腌制'),(76,2,'香菇泡发切丁热油爆香'),(76,3,'加鸡肉炒变色倒入糯米翻炒'),(76,4,'加盐调味包入荷叶上锅蒸40分钟'),
(77,1,'面粉加盐和水调成面糊醒20分钟'),(77,2,'平底锅抹薄薄一层面糊打入鸡蛋摊匀撒葱'),(77,3,'翻面刷甜面酱和辣椒酱'),(77,4,'放薄脆和生菜卷起即可'),
(78,1,'面粉加酵母糖温水和面发酵至2倍大'),(78,2,'面团分剂子擀皮包入红豆沙'),(78,3,'收口朝下二次发酵15分钟'),(78,4,'上锅大火蒸12分钟焖3分钟开盖'),
(79,1,'番茄切块鸡蛋打散'),(79,2,'热油下番茄翻炒出汁加适量水煮开'),(79,3,'淋入蛋液搅成蛋花'),(79,4,'加盐和香油调味出锅'),
(80,1,'紫菜撕碎鸡蛋打散'),(80,2,'水烧开下紫菜和虾皮'),(80,3,'淋入蛋液搅成蛋花'),(80,4,'加盐和香油出锅'),
(81,1,'排骨焯水玉米切段胡萝卜切滚刀块'),(81,2,'排骨加姜片料酒足量水大火煮开转小火炖40分钟'),(81,3,'加玉米和胡萝卜续炖20分钟'),(81,4,'加盐调味出锅'),
(82,1,'牛肉末加蛋清淀粉少许盐拌匀'),(82,2,'豆腐切小丁焯水'),(82,3,'水加姜片煮开下牛肉滑散'),(82,4,'加豆腐煮3分钟勾薄芡加盐香油撒香菜'),
(83,1,'豆腐切丝木耳切丝鸡蛋打散'),(83,2,'水加姜片煮开下豆腐木耳'),(83,3,'加醋白胡椒粉和盐调味'),(83,4,'淋蛋液勾芡加香油出锅'),
(84,1,'鲫鱼处理干净两面划刀抹盐料酒'),(84,2,'热油煎鲫鱼至两面微黄'),(84,3,'加姜片和足量开水大火煮10分钟至汤变白'),(84,4,'加豆腐块续煮5分钟加盐撒葱'),
(85,1,'排骨焯水莲藕去皮切滚刀块'),(85,2,'排骨加姜片料酒足量水大火煮开转小火炖40分钟'),(85,3,'加莲藕续炖20分钟'),(85,4,'加盐调味出锅'),
(86,1,'银耳泡发撕小朵莲子去芯泡发'),(86,2,'银耳和莲子放入锅中加足量水'),(86,3,'大火煮开转小火熬40分钟至银耳出胶'),(86,4,'加冰糖搅拌融化即可'),
(87,1,'黄瓜洗净用刀拍裂切段'),(87,2,'蒜切末加生抽醋盐调汁'),(87,3,'浇在黄瓜上拌匀淋香油'),
(88,1,'皮蛋剥壳切瓣豆腐切块'),(88,2,'豆腐垫底皮蛋摆上面'),(88,3,'调汁:蒜末+生抽+醋+香油+盐'),(88,4,'浇汁即可'),
(89,1,'木耳泡发洗净焯水2分钟过凉'),(89,2,'调汁:蒜末+生抽+醋+辣椒油+盐+香油'),(89,3,'浇在木耳上拌匀'),
(90,1,'黄瓜胡萝卜切丝粉丝泡软焯水'),(90,2,'调汁:蒜末+生抽+醋+盐+香油'),(90,3,'所有食材混合浇汁拌匀'),
(91,1,'番茄洗净切块'),(91,2,'撒白糖拌匀即可'),
(92,1,'鸡胸肉加姜片料酒煮熟撕成丝'),(92,2,'黄瓜切丝'),(92,3,'调汁:蒜末+生抽+醋+辣椒油+盐+香油'),(92,4,'鸡丝和黄瓜混合浇汁拌匀'),
(93,1,'黄瓜洗净用刀拍裂切段'),(93,2,'蒜切末加生抽醋盐辣椒油调汁'),(93,3,'浇在黄瓜上拌匀'),
(94,1,'海带丝焯水过凉'),(94,2,'调汁:蒜末+生抽+醋+辣椒油+盐+香油'),(94,3,'浇在海带丝上拌匀'),
(95,1,'鸡蛋打散,葱切末'),(95,2,'热油炒鸡蛋划散盛出'),(95,3,'锅中放米饭炒散加鸡蛋翻炒'),(95,4,'加盐和葱末翻炒均匀出锅'),
(96,1,'番茄切块鸡蛋打散'),(96,2,'热油炒鸡蛋盛出,炒番茄出汁加水煮开'),(96,3,'下面条煮熟'),(96,4,'倒回鸡蛋加盐生抽调味撒葱花'),
(97,1,'猪肉剁末,热油炒肉末至变色加姜蒜'),(97,2,'加黄豆酱和甜面酱翻炒加料酒和少许水熬煮'),(97,3,'面条煮熟过凉沥干盛碗'),(97,4,'码上肉酱和黄瓜丝拌匀'),
(98,1,'葱切段热油炸至金黄焦香得葱油'),(98,2,'面碗底加生抽老抽糖'),(98,3,'面条煮熟捞入碗中'),(98,4,'淋上葱油拌匀'),
(99,1,'米粉泡软沥干鸡蛋打散'),(99,2,'热油炒鸡蛋盛出,炒豆芽'),(99,3,'下米粉翻炒加生抽和盐'),(99,4,'倒回鸡蛋翻炒均匀出锅'),
(100,1,'面条煮熟捞入碗中'),(100,2,'加生抽盐香油调味'),(100,3,'撒上葱花浇上面汤即可');

View File

@@ -0,0 +1,49 @@
-- ============================================================
-- 100道菜谱食材数据
-- ============================================================
USE chowbox;
-- 家常快手菜食材
INSERT INTO recipe_ingredients (recipe_id, ingredient_name, amount, is_staple) VALUES
-- 1. 西红柿炒鸡蛋
(1, '西红柿', '2个', 1),(1, '鸡蛋', '3个', 1),(1, '', '1根', 0),(1, '', '适量', 0),(1, '', '少许', 0),(1, '', '适量', 0),
-- 2. 酸辣土豆丝
(2, '土豆', '2个', 1),(2, '干辣椒', '3个', 0),(2, '花椒', '少许', 0),(2, '', '1勺', 0),(2, '', '适量', 0),(2, '', '1根', 0),(2, '', '适量', 0),
-- 3. 青椒肉丝
(3, '猪里脊', '200克', 1),(3, '青椒', '3个', 1),(3, '', '3片', 0),(3, '', '2瓣', 0),(3, '生抽', '1勺', 0),(3, '料酒', '1勺', 0),(3, '淀粉', '1勺', 0),(3, '', '适量', 0),(3, '', '适量', 0),
-- 4. 蒜蓉西兰花
(4, '西兰花', '1颗', 1),(4, '', '5瓣', 0),(4, '', '适量', 0),(4, '蚝油', '1勺', 0),(4, '', '适量', 0),
-- 5. 糖醋里脊
(5, '猪里脊', '300克', 1),(5, '鸡蛋', '1个', 0),(5, '面粉', '50克', 0),(5, '番茄酱', '2勺', 0),(5, '白糖', '2勺', 0),(5, '白醋', '1勺', 0),(5, '', '适量', 0),(5, '', '适量', 0),
-- 6. 干煸四季豆
(6, '四季豆', '300克', 1),(6, '猪肉末', '50克', 0),(6, '干辣椒', '3个', 0),(6, '花椒', '少许', 0),(6, '', '3瓣', 0),(6, '生抽', '1勺', 0),(6, '', '适量', 0),(6, '', '适量', 0),
-- 7. 可乐鸡翅
(7, '鸡翅中', '8个', 1),(7, '可乐', '1罐', 0),(7, '生抽', '2勺', 0),(7, '老抽', '半勺', 0),(7, '', '3片', 0),(7, '料酒', '1勺', 0),(7, '', '少许', 0),(7, '', '适量', 0),
-- 8. 韭黄炒蛋
(8, '韭黄', '200克', 1),(8, '鸡蛋', '3个', 1),(8, '', '适量', 0),(8, '', '适量', 0),
-- 9. 红烧豆腐
(9, '老豆腐', '1块', 1),(9, '', '2根', 0),(9, '', '3片', 0),(9, '', '2瓣', 0),(9, '生抽', '2勺', 0),(9, '老抽', '半勺', 0),(9, '豆瓣酱', '1勺', 0),(9, '', '少许', 0),(9, '淀粉', '1勺', 0),(9, '', '适量', 0),
-- 10. 蚝油生菜
(10, '生菜', '1颗', 1),(10, '', '3瓣', 0),(10, '蚝油', '2勺', 0),(10, '生抽', '1勺', 0),(10, '', '适量', 0);
INSERT INTO recipe_ingredients (recipe_id, ingredient_name, amount, is_staple) VALUES
-- 11. 孜然土豆片
(11, '土豆', '2个', 1),(11, '孜然粉', '1勺', 0),(11, '辣椒粉', '半勺', 0),(11, '', '适量', 0),(11, '', '1根', 0),(11, '', '适量', 0),
-- 12. 洋葱炒肉
(12, '洋葱', '1个', 1),(12, '猪肉片', '150克', 1),(12, '生抽', '1勺', 0),(12, '料酒', '1勺', 0),(12, '', '适量', 0),(12, '', '适量', 0),
-- 13. 香菇油菜
(13, '油菜', '5棵', 1),(13, '香菇', '6朵', 1),(13, '', '3瓣', 0),(13, '蚝油', '1勺', 0),(13, '', '适量', 0),(13, '', '适量', 0),
-- 14. 家常豆腐
(14, '老豆腐', '1块', 1),(14, '木耳', '10朵', 0),(14, '青椒', '1个', 0),(14, '胡萝卜', '半根', 0),(14, '豆瓣酱', '1勺', 0),(14, '生抽', '1勺', 0),(14, '', '少许', 0),(14, '', '适量', 0),
-- 15. 雪菜肉丝
(15, '雪里蕻', '200克', 1),(15, '猪肉丝', '100克', 1),(15, '干辣椒', '2个', 0),(15, '生抽', '1勺', 0),(15, '料酒', '1勺', 0),(15, '', '适量', 0),(15, '', '适量', 0),
-- 16. 素炒豆芽
(16, '绿豆芽', '300克', 1),(16, '', '1根', 0),(16, '', '适量', 0),(16, '', '少许', 0),(16, '', '适量', 0),
-- 17. 芹菜香干
(17, '芹菜', '200克', 1),(17, '豆干', '3块', 1),(17, '生抽', '1勺', 0),(17, '', '适量', 0),(17, '', '适量', 0),
-- 18. 番茄菜花
(18, '菜花', '半颗', 1),(18, '西红柿', '1个', 1),(18, '番茄酱', '1勺', 0),(18, '', '适量', 0),(18, '', '少许', 0),(18, '', '适量', 0),
-- 19. 肉末茄子
(19, '长茄子', '2根', 1),(19, '猪肉末', '100克', 1),(19, '', '3瓣', 0),(19, '', '2片', 0),(19, '生抽', '2勺', 0),(19, '老抽', '半勺', 0),(19, '郫县豆瓣酱', '1勺', 0),(19, '', '少许', 0),(19, '料酒', '1勺', 0),(19, '', '适量', 0),
-- 20. 葱爆羊肉
(20, '羊肉片', '250克', 1),(20, '大葱', '2根', 1),(20, '生抽', '2勺', 0),(20, '料酒', '1勺', 0),(20, '孜然粉', '半勺', 0),(20, '', '适量', 0),(20, '', '适量', 0);

View File

@@ -0,0 +1,46 @@
-- 川菜 + 粤菜食材数据
USE chowbox;
INSERT INTO recipe_ingredients (recipe_id, ingredient_name, amount, is_staple) VALUES
-- 21. 麻婆豆腐
(21, '嫩豆腐', '1块', 1),(21, '猪肉末', '80克', 0),(21, '郫县豆瓣酱', '1.5勺', 0),(21, '花椒粉', '半勺', 0),(21, '辣椒面', '半勺', 0),(21, '豆豉', '少许', 0),(21, '', '2瓣', 0),(21, '淀粉', '1勺', 0),(21, '', '1根', 0),(21, '', '适量', 0),
-- 22. 回锅肉
(22, '五花肉', '300克', 1),(22, '蒜苗', '3根', 1),(22, '郫县豆瓣酱', '1勺', 0),(22, '生抽', '1勺', 0),(22, '', '3片', 0),(22, '', '2瓣', 0),(22, '料酒', '1勺', 0),(22, '', '少许', 0),(22, '', '适量', 0),
-- 23. 宫保鸡丁
(23, '鸡胸肉', '250克', 1),(23, '花生米', '50克', 0),(23, '黄瓜', '半根', 0),(23, '干辣椒', '5个', 0),(23, '花椒', '少许', 0),(23, '生抽', '1勺', 0),(23, '', '1勺', 0),(23, '', '1勺', 0),(23, '淀粉', '1勺', 0),(23, '', '2瓣', 0),(23, '', '适量', 0),
-- 24. 水煮肉片
(24, '猪里脊', '250克', 1),(24, '豆芽', '100克', 0),(24, '白菜叶', '3片', 0),(24, '郫县豆瓣酱', '2勺', 0),(24, '干辣椒', '10个', 0),(24, '花椒', '适量', 0),(24, '', '5瓣', 0),(24, '料酒', '1勺', 0),(24, '淀粉', '1勺', 0),(24, '', '适量', 0),
-- 25. 鱼香肉丝
(25, '猪里脊', '200克', 1),(25, '木耳', '10朵', 0),(25, '胡萝卜', '半根', 0),(25, '青椒', '1个', 0),(25, '泡椒', '4个', 0),(25, '', '3瓣', 0),(25, '生抽', '1勺', 0),(25, '', '1勺', 0),(25, '', '1勺', 0),(25, '淀粉', '1勺', 0),(25, '郫县豆瓣酱', '半勺', 0),(25, '', '适量', 0),
-- 26. 辣子鸡丁
(26, '鸡腿肉', '300克', 1),(26, '干辣椒', '20个', 0),(26, '花椒', '适量', 0),(26, '', '5片', 0),(26, '', '3瓣', 0),(26, '料酒', '1勺', 0),(26, '生抽', '1勺', 0),(26, '白芝麻', '少许', 0),(26, '', '适量', 0),
-- 27. 夫妻肺片
(27, '牛腱子', '150克', 1),(27, '牛肚', '150克', 1),(27, '花生碎', '30克', 0),(27, '香菜', '2根', 0),(27, '红油', '3勺', 0),(27, '花椒粉', '半勺', 0),(27, '生抽', '2勺', 0),(27, '', '1勺', 0),
-- 28. 口水鸡
(28, '鸡腿', '2个', 1),(28, '花生碎', '30克', 0),(28, '芝麻酱', '1勺', 0),(28, '红油', '2勺', 0),(28, '花椒粉', '半勺', 0),(28, '生抽', '2勺', 0),(28, '', '1勺', 0),(28, '', '3瓣', 0),(28, '', '1根', 0),(28, '香菜', '1根', 0),
-- 29. 干煸牛肉丝
(29, '牛肉', '250克', 1),(29, '芹菜', '100克', 0),(29, '干辣椒', '5个', 0),(29, '花椒', '少许', 0),(29, '', '3片', 0),(29, '郫县豆瓣酱', '1勺', 0),(29, '生抽', '1勺', 0),(29, '料酒', '1勺', 0),(29, '', '适量', 0),
-- 30. 蒜泥白肉
(30, '五花肉', '250克', 1),(30, '黄瓜', '1根', 0),(30, '', '6瓣', 0),(30, '生抽', '2勺', 0),(30, '红油', '1勺', 0),(30, '', '半勺', 0),(30, '', '少许', 0);
INSERT INTO recipe_ingredients (recipe_id, ingredient_name, amount, is_staple) VALUES
-- 31. 酸菜鱼
(31, '草鱼片', '300克', 1),(31, '酸菜', '200克', 1),(31, '泡椒', '5个', 0),(31, '干辣椒', '5个', 0),(31, '花椒', '少许', 0),(31, '', '5片', 0),(31, '', '3瓣', 0),(31, '料酒', '1勺', 0),(31, '淀粉', '1勺', 0),(31, '', '适量', 0),(31, '', '适量', 0),
-- 32. 毛血旺
(32, '鸭血', '200克', 1),(32, '午餐肉', '100克', 1),(32, '毛肚', '100克', 0),(32, '豆芽', '100克', 0),(32, '郫县豆瓣酱', '2勺', 0),(32, '干辣椒', '10个', 0),(32, '花椒', '适量', 0),(32, '', '3瓣', 0),(32, '料酒', '1勺', 0),(32, '', '适量', 0),
-- 33. 麻辣香锅
(33, '', '10只', 1),(33, '土豆', '1个', 0),(33, '', '半节', 0),(33, '西兰花', '半颗', 0),(33, '香菇', '5朵', 0),(33, '火锅底料', '50克', 0),(33, '干辣椒', '8个', 0),(33, '花椒', '适量', 0),(33, '', '3瓣', 0),(33, '', '适量', 0),
-- 34. 泡椒凤爪
(34, '鸡爪', '500克', 1),(34, '泡椒', '200克', 0),(34, '白醋', '2勺', 0),(34, '', '适量', 0),(34, '料酒', '1勺', 0),(34, '', '5片', 0),
-- 35. 担担面
(35, '面条', '200克', 1),(35, '猪肉末', '80克', 0),(35, '芽菜', '30克', 0),(35, '花生碎', '20克', 0),(35, '芝麻酱', '1勺', 0),(35, '红油', '2勺', 0),(35, '生抽', '1勺', 0),(35, '', '少许', 0),(35, '', '1根', 0),(35, '', '适量', 0),
-- 36. 白切鸡
(36, '三黄鸡', '1只', 1),(36, '', '5片', 0),(36, '', '3根', 0),(36, '料酒', '2勺', 0),(36, '生抽', '3勺', 0),(36, '花生油', '2勺', 0),(36, '', '3瓣', 0),
-- 37. 清蒸鲈鱼
(37, '鲈鱼', '1条', 1),(37, '', '5片', 0),(37, '', '2根', 0),(37, '蒸鱼豉油', '2勺', 0),(37, '料酒', '1勺', 0),(37, '', '适量', 0),
-- 38. 白灼虾
(38, '基围虾', '300克', 1),(38, '', '3片', 0),(38, '料酒', '1勺', 0),(38, '生抽', '2勺', 0),(38, '姜末', '少许', 0),(38, '香油', '少许', 0),
-- 39. 广式叉烧
(39, '猪梅花肉', '500克', 1),(39, '叉烧酱', '4勺', 0),(39, '蜂蜜', '2勺', 0),(39, '生抽', '1勺', 0),(39, '老抽', '半勺', 0),
-- 40. 菠萝咕咾肉
(40, '猪里脊', '250克', 1),(40, '菠萝', '半个', 1),(40, '青椒', '1个', 0),(40, '红椒', '1个', 0),(40, '番茄酱', '3勺', 0),(40, '白醋', '1勺', 0),(40, '', '2勺', 0),(40, '淀粉', '适量', 0),(40, '', '适量', 0);

View File

@@ -0,0 +1,44 @@
-- 粤菜 + 湘菜 + 东北菜 + 早餐 + 汤羹 + 凉菜 + 主食 食材数据
USE chowbox;
INSERT INTO recipe_ingredients (recipe_id, ingredient_name, amount, is_staple) VALUES
-- 41. 干炒牛河
(41, '河粉', '300克', 1),(41, '牛肉片', '150克', 1),(41, '豆芽', '50克', 0),(41, '韭黄', '30克', 0),(41, '生抽', '2勺', 0),(41, '老抽', '半勺', 0),(41, '', '适量', 0),
-- 42. 蒜蓉粉丝蒸扇贝
(42, '扇贝', '6只', 1),(42, '粉丝', '1把', 0),(42, '', '6瓣', 0),(42, '蒸鱼豉油', '1勺', 0),(42, '', '1根', 0),(42, '', '适量', 0),
-- 43. 豉汁蒸排骨
(43, '排骨', '300克', 1),(43, '豆豉', '1勺', 0),(43, '', '3瓣', 0),(43, '生抽', '1勺', 0),(43, '料酒', '1勺', 0),(43, '淀粉', '1勺', 0),(43, '', '少许', 0),
-- 44. 煲仔饭
(44, '大米', '200克', 1),(44, '腊肠', '1根', 0),(44, '腊肉', '50克', 0),(44, '油菜', '3棵', 0),(44, '生抽', '1勺', 0),(44, '', '少许', 0),
-- 45. 冬瓜排骨汤
(45, '排骨', '300克', 1),(45, '冬瓜', '300克', 1),(45, '', '3片', 0),(45, '', '适量', 0),
-- 46. 蚝油牛肉
(46, '牛肉', '250克', 1),(46, '青椒', '1个', 0),(46, '洋葱', '半个', 0),(46, '蚝油', '2勺', 0),(46, '生抽', '1勺', 0),(46, '料酒', '1勺', 0),(46, '淀粉', '1勺', 0),(46, '', '适量', 0),
-- 47. 虾饺
(47, '虾仁', '200克', 1),(47, '猪肥膘', '30克', 0),(47, '澄面', '150克', 0),(47, '生粉', '50克', 0),(47, '竹笋', '30克', 0),(47, '', '适量', 0),
-- 48. 肠粉
(48, '粘米粉', '150克', 1),(48, '鸡蛋', '1个', 0),(48, '猪肉末', '50克', 0),(48, '生菜', '2片', 0),(48, '生抽', '2勺', 0),(48, '', '少许', 0),
-- 49. 叉烧包
(49, '中筋面粉', '300克', 1),(49, '叉烧肉', '200克', 1),(49, '酵母', '3克', 0),(49, '', '20克', 0),(49, '蚝油', '1勺', 0),
-- 50. 蛋挞
(50, '蛋挞皮', '8个', 1),(50, '鸡蛋', '2个', 1),(50, '淡奶油', '100毫升', 0),(50, '牛奶', '100毫升', 0),(50, '', '30克', 0),
-- 51. 剁椒鱼头
(51, '胖头鱼头', '1个', 1),(51, '剁椒', '100克', 0),(51, '', '5片', 0),(51, '', '3瓣', 0),(51, '料酒', '2勺', 0),(51, '蒸鱼豉油', '1勺', 0),(51, '', '1根', 0),(51, '', '适量', 0),
-- 52. 农家小炒肉
(52, '五花肉', '200克', 1),(52, '青椒', '4个', 1),(52, '', '3瓣', 0),(52, '生抽', '1勺', 0),(52, '老抽', '少许', 0),(52, '豆豉', '半勺', 0),(52, '', '适量', 0),
-- 53. 酸豆角肉末
(53, '酸豆角', '200克', 1),(53, '猪肉末', '100克', 1),(53, '干辣椒', '3个', 0),(53, '', '2瓣', 0),(53, '生抽', '1勺', 0),(53, '', '适量', 0),
-- 54. 腊味合蒸
(54, '腊肉', '150克', 1),(54, '腊肠', '1根', 1),(54, '腊鱼', '100克', 0),(54, '豆豉', '1勺', 0),(54, '干辣椒', '2个', 0),(54, '料酒', '1勺', 0),
-- 55. 干锅花菜
(55, '花菜', '半颗', 1),(55, '五花肉片', '80克', 0),(55, '干辣椒', '5个', 0),(55, '', '3瓣', 0),(55, '生抽', '1勺', 0),(55, '郫县豆瓣酱', '半勺', 0),(55, '', '适量', 0),
-- 56. 口味虾
(56, '小龙虾', '500克', 1),(56, '干辣椒', '10个', 0),(56, '花椒', '适量', 0),(56, '', '5瓣', 0),(56, '', '5片', 0),(56, '郫县豆瓣酱', '1勺', 0),(56, '啤酒', '半瓶', 0),(56, '紫苏', '少许', 0),(56, '', '适量', 0),
-- 57. 擂辣椒皮蛋
(57, '青椒', '4个', 1),(57, '皮蛋', '2个', 1),(57, '', '3瓣', 0),(57, '', '1勺', 0),(57, '生抽', '1勺', 0),(57, '香油', '少许', 0),
-- 58. 湘西外婆菜
(58, '酸菜', '200克', 1),(58, '猪肉末', '80克', 0),(58, '干辣椒', '3个', 0),(58, '', '2瓣', 0),(58, '生抽', '1勺', 0),(58, '', '适量', 0),
-- 59. 锅包肉
(59, '猪里脊', '300克', 1),(59, '胡萝卜', '半根', 0),(59, '大葱', '1段', 0),(59, '', '3片', 0),(59, '白醋', '3勺', 0),(59, '白糖', '3勺', 0),(59, '淀粉', '100克', 0),(59, '', '适量', 0),
-- 60. 地三鲜
(60, '茄子', '1根', 1),(60, '土豆', '1个', 1),(60, '青椒', '2个', 1),(60, '', '3瓣', 0),(60, '生抽', '2勺', 0),(60, '老抽', '半勺', 0),(60, '', '少许', 0),(60, '', '适量', 0),(60, '', '适量', 0);

View File

@@ -0,0 +1,43 @@
-- 61-100 剩余食材
USE chowbox;
INSERT INTO recipe_ingredients (recipe_id, ingredient_name, amount, is_staple) VALUES
-- 61. 猪肉炖粉条
(61, '五花肉', '250克', 1),(61, '粉条', '100克', 1),(61, '白菜', '3片', 0),(61, '生抽', '2勺', 0),(61, '老抽', '半勺', 0),(61, '八角', '1个', 0),(61, '', '3片', 0),(61, '', '适量', 0),
-- 62. 东北乱炖
(62, '排骨', '200克', 1),(62, '土豆', '2个', 0),(62, '豆角', '100克', 0),(62, '茄子', '1根', 0),(62, '玉米', '1根', 0),(62, '生抽', '2勺', 0),(62, '老抽', '半勺', 0),(62, '', '适量', 0),(62, '', '适量', 0),
-- 63. 京酱肉丝
(63, '猪里脊', '250克', 1),(63, '大葱', '2根', 0),(63, '豆腐皮', '3张', 0),(63, '甜面酱', '2勺', 0),(63, '生抽', '1勺', 0),(63, '', '少许', 0),(63, '料酒', '1勺', 0),(63, '淀粉', '1勺', 0),(63, '', '适量', 0),
-- 64. 拔丝地瓜
(64, '红薯', '2个', 1),(64, '白糖', '100克', 0),(64, '', '适量', 0),
-- 65. 酸菜白肉
(65, '五花肉', '300克', 1),(65, '东北酸菜', '200克', 1),(65, '粉条', '50克', 0),(65, '', '3片', 0),(65, '八角', '1个', 0),(65, '', '适量', 0),
-- 66. 溜肉段
(66, '猪瘦肉', '250克', 1),(66, '青椒', '1个', 0),(66, '胡萝卜', '半根', 0),(66, '生抽', '2勺', 0),(66, '', '1勺', 0),(66, '', '1勺', 0),(66, '淀粉', '适量', 0),(66, '', '适量', 0),
-- 67. 鸡蛋灌饼
(67, '面粉', '200克', 1),(67, '鸡蛋', '2个', 1),(67, '生菜', '2片', 0),(67, '甜面酱', '1勺', 0),(67, '', '适量', 0),
-- 68. 皮蛋瘦肉粥
(68, '大米', '100克', 1),(68, '皮蛋', '1个', 0),(68, '瘦肉丝', '50克', 0),(68, '', '2片', 0),(68, '', '适量', 0),
-- 69. 葱花鸡蛋饼
(69, '面粉', '150克', 1),(69, '鸡蛋', '2个', 1),(69, '', '2根', 0),(69, '', '适量', 0),(69, '', '适量', 0),
-- 70. 小米南瓜粥
(70, '小米', '100克', 1),(70, '南瓜', '200克', 1),(70, '', '适量', 0),
-- 71. 煎饺
(71, '饺子皮', '20张', 1),(71, '猪肉馅', '200克', 1),(71, '白菜', '100克', 0),(71, '生抽', '1勺', 0),(71, '料酒', '1勺', 0),(71, '', '适量', 0),(71, '', '适量', 0),
-- 72. 三明治
(72, '面包片', '2片', 1),(72, '鸡蛋', '1个', 0),(72, '火腿', '2片', 0),(72, '生菜', '1片', 0),(72, '沙拉酱', '适量', 0),
-- 73. 豆浆油条
(73, '黄豆', '150克', 1),(73, '面粉', '300克', 1),(73, '酵母', '3克', 0),(73, '泡打粉', '3克', 0),(73, '', '适量', 0),
-- 74. 馄饨
(74, '馄饨皮', '30张', 1),(74, '猪肉馅', '200克', 1),(74, '虾仁', '50克', 0),(74, '紫菜', '少许', 0),(74, '虾皮', '少许', 0),(74, '', '1根', 0),(74, '生抽', '1勺', 0),(74, '', '适量', 0),
-- 75. 肉夹馍
(75, '面粉', '300克', 1),(75, '五花肉', '400克', 1),(75, '酵母', '3克', 0),(75, '生抽', '3勺', 0),(75, '老抽', '1勺', 0),(75, '八角', '2个', 0),(75, '桂皮', '1片', 0),(75, '', '3片', 0),
-- 76. 糯米鸡
(76, '糯米', '300克', 1),(76, '鸡腿肉', '200克', 1),(76, '香菇', '5朵', 0),(76, '腊肠', '半根', 0),(76, '生抽', '2勺', 0),(76, '蚝油', '1勺', 0),(76, '荷叶', '2张', 0),
-- 77. 煎饼果子
(77, '面粉', '100克', 1),(77, '绿豆面', '50克', 0),(77, '鸡蛋', '1个', 0),(77, '薄脆', '1片', 0),(77, '甜面酱', '1勺', 0),(77, '辣酱', '适量', 0),(77, '', '少许', 0),(77, '', '少许', 0),
-- 78. 豆沙包
(78, '面粉', '300克', 1),(78, '红豆', '200克', 1),(78, '酵母', '3克', 0),(78, '白糖', '50克', 0),
-- 79. 番茄蛋花汤
(79, '西红柿', '2个', 1),(79, '鸡蛋', '2个', 1),(79, '', '适量', 0),(79, '', '少许', 0),(79, '香油', '几滴', 0),
-- 80. 紫菜蛋花汤
(80, '紫菜', '1片', 1),(80, '鸡蛋', '1个', 1),(80, '', '适量', 0),(80, '香油', '几滴', 0),(80, '虾皮', '少许', 0);

View File

@@ -0,0 +1,23 @@
-- 81-100 + 所有步骤
USE chowbox;
INSERT INTO recipe_ingredients (recipe_id, ingredient_name, amount, is_staple) VALUES
(81, '排骨', '300克', 1),(81, '玉米', '1根', 1),(81, '', '3片', 0),(81, '', '适量', 0),
(82, '牛肉末', '100克', 1),(82, '嫩豆腐', '半块', 0),(82, '鸡蛋清', '1个', 0),(82, '香菜', '少许', 0),(82, '', '适量', 0),(82, '白胡椒粉', '少许', 0),(82, '淀粉', '1勺', 0),
(83, '豆腐', '半块', 0),(83, '木耳', '5朵', 0),(83, '鸡蛋', '1个', 0),(83, '', '2勺', 0),(83, '白胡椒粉', '1勺', 0),(83, '生抽', '1勺', 0),(83, '淀粉', '1勺', 0),(83, '', '适量', 0),(83, '香油', '少许', 0),
(84, '鲫鱼', '1条', 1),(84, '嫩豆腐', '1块', 1),(84, '', '3片', 0),(84, '', '1根', 0),(84, '料酒', '1勺', 0),(84, '', '适量', 0),(84, '', '适量', 0),
(85, '排骨', '300克', 1),(85, '莲藕', '1节', 1),(85, '', '3片', 0),(85, '红枣', '3颗', 0),(85, '', '适量', 0),
(86, '银耳', '1朵', 1),(86, '莲子', '30克', 0),(86, '红枣', '5颗', 0),(86, '冰糖', '适量', 0),
(87, '黄瓜', '2根', 1),(87, '', '3瓣', 0),(87, '', '1勺', 0),(87, '生抽', '1勺', 0),(87, '', '适量', 0),(87, '香油', '少许', 0),(87, '辣椒油', '适量', 0),
(88, '内酯豆腐', '1盒', 1),(88, '皮蛋', '1个', 0),(88, '生抽', '2勺', 0),(88, '', '1勺', 0),(88, '', '少许', 0),(88, '香油', '少许', 0),(88, '', '1根', 0),
(89, '木耳', '20朵', 1),(89, '', '3瓣', 0),(89, '', '1勺', 0),(89, '生抽', '1勺', 0),(89, '', '适量', 0),(89, '辣椒油', '适量', 0),(89, '香油', '少许', 0),
(90, '粉丝', '1把', 1),(90, '黄瓜', '1根', 0),(90, '胡萝卜', '半根', 0),(90, '生抽', '1勺', 0),(90, '', '1勺', 0),(90, '', '2瓣', 0),(90, '', '适量', 0),(90, '辣椒油', '适量', 0),(90, '香油', '少许', 0),
(91, '番茄', '2个', 1),(91, '白糖', '2勺', 0),
(92, '鸡胸肉', '1块', 1),(92, '黄瓜', '半根', 0),(92, '生抽', '1勺', 0),(92, '', '1勺', 0),(92, '', '2瓣', 0),(92, '辣椒油', '1勺', 0),(92, '芝麻酱', '半勺', 0),(92, '', '适量', 0),
(93, '黄瓜', '2根', 1),(93, '', '3瓣', 0),(93, '', '1勺', 0),(93, '生抽', '半勺', 0),(93, '', '适量', 0),(93, '辣椒油', '适量', 0),(93, '香油', '少许', 0),
(94, '海带丝', '200克', 1),(94, '', '2瓣', 0),(94, '', '1勺', 0),(94, '生抽', '1勺', 0),(94, '', '适量', 0),(94, '辣椒油', '适量', 0),(94, '香油', '少许', 0),
(95, '米饭', '1碗', 1),(95, '鸡蛋', '2个', 1),(95, '', '1根', 0),(95, '', '适量', 0),(95, '', '适量', 0),
(96, '面条', '150克', 1),(96, '番茄', '1个', 1),(96, '鸡蛋', '1个', 1),(96, '', '适量', 0),(96, '', '少许', 0),(96, '', '适量', 0),
(97, '面条', '200克', 1),(97, '猪肉末', '100克', 1),(97, '甜面酱', '2勺', 0),(97, '黄豆酱', '1勺', 0),(97, '黄瓜', '1根', 0),(97, '', '1根', 0),(97, '', '适量', 0),
(98, '面条', '150克', 1),(98, '', '4根', 0),(98, '生抽', '2勺', 0),(98, '老抽', '半勺', 0),(98, '', '半勺', 0),(98, '', '适量', 0),
(99, '米粉', '200克', 1),(99, '鸡蛋', '1个', 0),(99, '豆芽', '50克', 0),(99, '火腿', '1根', 0),(99, '生抽', '1勺', 0),(99, '', '适量', 0),
(100, '面条', '100克', 1),(100, '酱油', '1勺', 0),(100, '猪油', '半勺', 0),(100, '', '1根', 0),(100, '', '适量', 0);

View File

@@ -0,0 +1,124 @@
USE chowbox;
-- ============================================================
-- 100道菜谱步骤数据
-- ============================================================
INSERT INTO recipe_steps (recipe_id, step_order, content) VALUES
-- 家常快手菜
(1,1,'鸡蛋打散加少许盐搅匀;西红柿切块备用'),(1,2,'热锅倒油,倒入蛋液炒熟盛出'),(1,3,'锅中再倒少许油,下西红柿炒出汁,加少许糖'),(1,4,'倒回鸡蛋翻炒均匀,加盐调味,撒葱花出锅'),
(2,1,'土豆削皮切细丝,放凉水中浸泡去淀粉'),(2,2,'热锅倒油,下花椒、干辣椒爆香'),(2,3,'大火下土豆丝快速翻炒,加盐、醋'),(2,4,'翻炒2分钟至断生撒葱花出锅'),
(3,1,'猪里脊切丝加料酒、淀粉、生抽腌制10分钟'),(3,2,'青椒切丝,姜蒜切末'),(3,3,'热锅倒油,下肉丝滑炒至变色盛出'),(3,4,'锅中下姜蒜爆香,下青椒翻炒,倒回肉丝,加盐调味出锅'),
(4,1,'西兰花掰小朵焯水1分钟捞出'),(4,2,'蒜切末'),(4,3,'热锅倒油,下蒜末爆香'),(4,4,'下西兰花大火翻炒,加蚝油、盐调味出锅'),
(5,1,'猪里脊切条,加盐、料酒、蛋液腌制'),(5,2,'裹上面粉,油温六成热下锅炸至金黄'),(5,3,'调糖醋汁:番茄酱+糖+白醋+水'),(5,4,'锅中留底油,倒入糖醋汁煮沸,下里脊翻炒裹汁出锅'),
(6,1,'四季豆去筋掰段,焯水后沥干'),(6,2,'热锅多倒油,下四季豆煸至表面起皱盛出'),(6,3,'锅中留底油,下肉末、干辣椒、花椒、蒜炒香'),(6,4,'倒回四季豆,加生抽、盐翻炒均匀出锅'),
(7,1,'鸡翅洗净划两刀,冷水下锅焯水去血沫'),(7,2,'热锅倒少许油,煎鸡翅至两面金黄'),(7,3,'倒入可乐没过鸡翅,加生抽、老抽、姜、料酒'),(7,4,'中火炖15分钟至汤汁收浓加少许盐出锅'),
(8,1,'韭黄洗净切段,鸡蛋打散加盐'),(8,2,'热锅倒油,倒入蛋液炒熟盛出'),(8,3,'锅中再倒油大火下韭黄翻炒1分钟'),(8,4,'倒回鸡蛋炒匀,加盐出锅'),
(9,1,'豆腐切厚片,两面煎至金黄盛出'),(9,2,'锅中下葱姜蒜、豆瓣酱炒出红油'),(9,3,'加入生抽、老抽、糖和半碗水,放入豆腐'),(9,4,'中小火炖5分钟入味水淀粉勾芡撒葱花出锅'),
(10,1,'生菜洗净掰散,蒜切末'),(10,2,'锅中烧水水开焯生菜10秒捞出摆盘'),(10,3,'小碗调汁:蚝油+生抽+少许水搅匀'),(10,4,'热锅少油爆香蒜末,倒入料汁煮开,淋在生菜上'),
-- 11-20
(11,1,'土豆削皮切厚片焯水1分钟捞出沥干'),(11,2,'热锅多倒油,下土豆片煎至两面金黄'),(11,3,'撒入孜然粉、辣椒粉、盐翻炒均匀'),(11,4,'撒葱花出锅'),
(12,1,'洋葱切丝,猪肉切片加料酒、生抽腌制'),(12,2,'热锅倒油,下肉片炒至变色盛出'),(12,3,'锅中下洋葱大火翻炒至半透明'),(12,4,'倒回肉片,加盐调味出锅'),
(13,1,'油菜洗净对半切,香菇去蒂切片'),(13,2,'热锅倒油,下蒜末爆香'),(13,3,'下香菇炒软,再下油菜大火翻炒'),(13,4,'加蚝油、盐调味出锅'),
(14,1,'豆腐切三角片,煎至两面金黄盛出'),(14,2,'木耳泡发洗净,青椒、胡萝卜切片'),(14,3,'锅中下豆瓣酱炒出红油,加生抽和糖'),(14,4,'放入所有食材翻炒加少许水焖2分钟出锅'),
(15,1,'雪里蕻洗净切段,猪肉切丝加料酒腌制'),(15,2,'热锅倒油,下肉丝煸炒至变色'),(15,3,'下干辣椒和雪里蕻大火翻炒'),(15,4,'加生抽、盐调味出锅'),
(16,1,'豆芽洗净沥干,葱切段'),(16,2,'热锅倒油,大火下豆芽快炒'),(16,3,'加盐、少许醋提鲜'),(16,4,'翻炒30秒撒葱段出锅'),
(17,1,'芹菜去叶切段焯水,豆干切条'),(17,2,'热锅倒油,下豆干煎至微黄'),(17,3,'下芹菜大火翻炒'),(17,4,'加生抽、盐调味出锅'),
(18,1,'菜花掰小朵焯水,西红柿切块'),(18,2,'热锅倒油,下西红柿炒出汁'),(18,3,'加入番茄酱和少许糖'),(18,4,'下菜花翻炒均匀,加盐出锅'),
(19,1,'茄子切条撒盐腌制10分钟后挤去水分'),(19,2,'热锅多倒油,下茄子煎软盛出'),(19,3,'锅中下肉末炒香,加姜蒜豆瓣酱炒出红油'),(19,4,'加生抽老抽糖料酒倒回茄子翻炒焖2分钟出锅'),
(20,1,'羊肉片加生抽料酒腌制,大葱切斜段'),(20,2,'热锅多倒油,大火下羊肉快速滑炒至变色盛出'),(20,3,'锅中留油下大葱炒香'),(20,4,'倒回羊肉,加孜然粉、盐翻炒均匀出锅');
INSERT INTO recipe_steps (recipe_id, step_order, content) VALUES
-- 川菜 21-30
(21,1,'豆腐切小块焯水'),(21,2,'锅中倒油,下肉末炒香,加豆瓣酱炒出红油'),(21,3,'加水和豆腐煮3分钟加花椒粉、辣椒面'),(21,4,'水淀粉勾芡,撒葱花出锅'),
(22,1,'五花肉整块冷水下锅加姜、料酒煮20分钟至断生'),(22,2,'捞出切薄片,蒜苗切段'),(22,3,'热锅少油,下肉片煸至卷曲出油'),(22,4,'加豆瓣酱、生抽、糖,下蒜苗翻炒出锅'),
(23,1,'鸡胸肉切丁,加料酒、淀粉、盐腌制'),(23,2,'黄瓜切丁,调酱汁:生抽+醋+糖+淀粉+水'),(23,3,'小火炒香花生米盛出'),(23,4,'锅中爆香干辣椒花椒蒜,下鸡丁炒变色,加黄瓜丁和酱汁翻炒,最后加花生米出锅'),
(24,1,'猪里脊切薄片,加料酒、淀粉、盐腌制'),(24,2,'豆芽和白菜叶焯水铺碗底'),(24,3,'锅中炒香豆瓣酱,加水煮开,下肉片滑熟'),(24,4,'连汤倒入碗中,撒上蒜末、干辣椒、花椒,浇热油激发香味'),
(25,1,'猪里脊切丝,加料酒、淀粉腌制'),(25,2,'木耳泡发切丝,胡萝卜、青椒切丝'),(25,3,'调鱼香汁:生抽+醋+糖+淀粉+水'),(25,4,'锅中爆香泡椒蒜末豆瓣酱,下肉丝炒至变色,加三丝翻炒,淋入鱼香汁收汁出锅'),
(26,1,'鸡腿肉切小丁加生抽、料酒腌制15分钟'),(26,2,'干辣椒剪段,姜蒜切片'),(26,3,'热锅多油,下鸡丁炸至金黄捞出'),(26,4,'锅中留底油,下干辣椒花椒姜蒜爆香,倒回鸡丁翻炒,撒芝麻出锅'),
(27,1,'牛腱子和牛肚分别煮熟,切薄片'),(27,2,'调拌汁:红油+花椒粉+生抽+醋搅匀'),(27,3,'将牛肉片、牛肚片摆盘'),(27,4,'淋上拌汁,撒花生碎和香菜'),
(28,1,'鸡腿冷水下锅加姜、料酒煮15分钟至熟'),(28,2,'捞出浸冰水5分钟使皮脆肉嫩斩块摆盘'),(28,3,'调酱汁:芝麻酱+红油+生抽+醋+花椒粉+蒜末+糖搅匀'),(28,4,'酱汁淋在鸡块上,撒花生碎、葱花和香菜'),
(29,1,'牛肉切丝,加生抽料酒腌制'),(29,2,'芹菜切段,干辣椒剪段'),(29,3,'热锅多油,下牛肉丝煸至水分蒸发'),(29,4,'加豆瓣酱、干辣椒、花椒、芹菜翻炒,生抽调味出锅'),
(30,1,'五花肉整块冷水下锅煮20分钟至熟'),(30,2,'捞出放凉切薄片,黄瓜切丝铺底'),(30,3,'将肉片整齐码放在黄瓜上'),(30,4,'调蒜泥汁:蒜末+生抽+红油+醋+糖,淋在肉片上'),
-- 粤菜 31-40
(31,1,'草鱼片加料酒、淀粉、盐腌制'),(31,2,'酸菜切段焯水,泡椒切段'),(31,3,'锅中炒香姜蒜泡椒,下酸菜翻炒,加水煮开'),(31,4,'滑入鱼片煮至变白,连汤倒入碗中,撒干辣椒花椒浇热油'),
(32,1,'鸭血切块,午餐肉切片,毛肚洗净'),(32,2,'豆芽焯水铺碗底'),(32,3,'锅中炒香豆瓣酱,加水煮开,下鸭血午餐肉毛肚煮熟'),(32,4,'倒入碗中,撒蒜末干辣椒花椒,浇热油'),
(33,1,'虾去虾线,土豆藕切片,西兰花掰小朵'),(33,2,'所有蔬菜焯水断生'),(33,3,'锅中少油炒化火锅底料,加干辣椒花椒蒜爆香'),(33,4,'下所有食材翻炒均匀出锅'),
(34,1,'鸡爪剪去指甲,冷水下锅加姜料酒焯水'),(34,2,'捞出洗净,放入泡椒和泡椒水、白醋、盐'),(34,3,'加入凉白开没过鸡爪'),(34,4,'密封冷藏腌制4小时以上即可'),
(35,1,'面条煮熟捞出,过凉水'),(35,2,'肉末下锅炒香,加芽菜炒匀'),(35,3,'调碗底:芝麻酱+红油+生抽+醋+花生碎'),(35,4,'面条放入碗中,浇上肉末芽菜和调料,撒葱花拌匀'),
(36,1,'三黄鸡处理干净,锅中水加姜葱料酒烧开'),(36,2,'提着鸡腿浸入沸水中三提三放,使鸡皮定型'),(36,3,'小火煮15分钟关火焖10分钟'),(36,4,'捞出浸冰水,斩件摆盘,蘸料:生抽+花生油+蒜末+姜末'),
(37,1,'鲈鱼处理干净,两面划斜刀,抹盐料酒腌制'),(37,2,'盘中铺姜片,放上鲈鱼,上面再放姜片葱段'),(37,3,'水开后上锅大火蒸8分钟'),(37,4,'倒掉盘中蒸出的水,撒葱丝,淋蒸鱼豉油,浇热油'),
(38,1,'基围虾洗净去虾线'),(38,2,'锅中烧水,加姜片料酒煮开'),(38,3,'下虾煮至变色弯曲(约2分钟)立即捞出'),(38,4,'摆盘,蘸料:生抽+姜末+香油'),
(39,1,'猪梅花肉切长条用叉烧酱、生抽、老抽腌制4小时'),(39,2,'烤箱预热200度烤盘垫锡纸'),(39,3,'烤20分钟后取出刷蜂蜜翻面再烤15分钟'),(39,4,'取出放凉切片'),
(40,1,'猪里脊切块,加盐、料酒、淀粉腌制'),(40,2,'菠萝切块盐水浸泡,青红椒切片'),(40,3,'肉块裹干淀粉炸至金黄,糖醋汁:番茄酱+糖+白醋+水'),(40,4,'锅中炒香糖醋汁,下肉块菠萝青红椒翻炒裹汁出锅');
INSERT INTO recipe_steps (recipe_id, step_order, content) VALUES
-- 粤菜 41-50
(41,1,'牛肉切片加生抽、料酒、淀粉腌制'),(41,2,'河粉抖散,热锅多油滑锅'),(41,3,'大火下牛肉炒至变色盛出'),(41,4,'同锅下河粉翻炒,加生抽老抽,加豆芽韭黄牛肉炒匀出锅'),
(42,1,'粉丝温水泡软,扇贝去泥沙洗净'),(42,2,'蒜切末,一半炸至金黄做成金银蒜'),(42,3,'扇贝壳上铺粉丝和扇贝,淋金银蒜和蒸鱼豉油'),(42,4,'上锅蒸6分钟出锅撒葱花淋热油'),
(43,1,'排骨斩小块加生抽、料酒、豆豉、蒜末、淀粉、糖腌制20分钟'),(43,2,'盘中平铺排骨'),(43,3,'水开后上锅大火蒸15-20分钟'),(43,4,'出锅即可'),
(44,1,'大米提前浸泡30分钟腊肠腊肉切薄片'),(44,2,'砂锅刷油,放入大米和水(1:1.2),大火煮开转小火'),(44,3,'米饭快熟时铺上腊肠腊肉盖上焖5分钟'),(44,4,'沿锅边淋少许油形成锅巴,焯好的油菜摆旁边,淋生抽即可'),
(45,1,'排骨焯水去血沫,冬瓜去皮切块'),(45,2,'锅中放排骨、姜片,加足量水'),(45,3,'大火煮开转小火炖40分钟'),(45,4,'加入冬瓜再炖10分钟加盐出锅'),
(46,1,'牛肉切片加生抽、料酒、淀粉腌制'),(46,2,'青椒切块、洋葱切片'),(46,3,'热锅多油滑炒牛肉至变色盛出'),(46,4,'锅中炒香洋葱青椒,倒回牛肉,加蚝油翻炒出锅'),
(47,1,'虾仁切粒,加猪肥膘碎、笋丁、盐搅匀做馅'),(47,2,'澄面加生粉,用滚水烫熟揉成面团'),(47,3,'面团分小剂子擀成薄皮,包入虾馅捏成饺子'),(47,4,'上锅蒸6分钟即可'),
(48,1,'粘米粉加水调成米浆'),(48,2,'平底蒸盘刷油,倒入一勺米浆晃匀'),(48,3,'打上鸡蛋、撒肉末和生菜'),(48,4,'上锅大火蒸3分钟刮起卷成肠粉淋生抽即可'),
(49,1,'面粉加酵母、糖、水揉成面团发酵1小时'),(49,2,'叉烧肉切小丁,加蚝油调味'),(49,3,'面团分小剂子擀皮,包入叉烧馅'),(49,4,'上锅蒸12分钟关火焖2分钟即可'),
(50,1,'蛋挞皮提前解冻'),(50,2,'鸡蛋打散,加淡奶油、牛奶、糖搅拌均匀过滤'),(50,3,'蛋液倒入蛋挞皮中,八分满'),(50,4,'烤箱200度烤20分钟至表面焦黄'),
-- 湘菜 51-58
(51,1,'鱼头处理干净,抹盐和料酒腌制'),(51,2,'盘中垫姜片,放上鱼头'),(51,3,'铺满剁椒水开后大火蒸12分钟'),(51,4,'倒掉盘中水,撒葱花,淋蒸鱼豉油,浇热油'),
(52,1,'五花肉切薄片,青椒切块'),(52,2,'热锅少油,下五花肉煸至卷曲出油'),(52,3,'加蒜片、豆豉炒香'),(52,4,'下青椒大火快炒,加生抽老抽调色出锅'),
(53,1,'酸豆角切小段,猪肉切末'),(53,2,'热锅倒油,下肉末炒香'),(53,3,'加干辣椒、蒜末爆香'),(53,4,'下酸豆角大火翻炒,加生抽调味出锅'),
(54,1,'腊肉腊肠腊鱼用温水洗净切片'),(54,2,'盘中依次码放腊味'),(54,3,'撒上豆豉和干辣椒,淋少许料酒'),(54,4,'水开后上锅大火蒸20分钟即可'),
(55,1,'花菜掰小朵焯水半分钟'),(55,2,'五花肉切薄片'),(55,3,'热锅少油下五花肉煸出油,加蒜干辣椒豆瓣酱炒香'),(55,4,'下花菜大火翻炒,加生抽调味出锅'),
(56,1,'小龙虾刷洗干净去虾线'),(56,2,'热锅多油,下小龙虾炸至变红捞出'),(56,3,'锅中留油,下姜蒜豆瓣酱干辣椒花椒炒香'),(56,4,'倒回小龙虾加啤酒焖煮8分钟大火收汁撒紫苏出锅'),
(57,1,'青椒洗净去蒂,直接在干锅上烙至表皮焦黑'),(57,2,'趁热剥去焦皮,撕成条'),(57,3,'皮蛋切瓣摆盘'),(57,4,'将辣椒条放入,加蒜末、生抽、醋、香油,用擂钵擂拌均匀'),
(58,1,'酸菜切碎焯水挤干'),(58,2,'热锅倒油下肉末炒香'),(58,3,'加干辣椒蒜末爆香'),(58,4,'下酸菜大火翻炒,加生抽调味出锅'),
-- 东北菜 59-66
(59,1,'猪里脊切厚片,加盐、料酒腌制'),(59,2,'淀粉+水调成糊,肉片裹糊'),(59,3,'油温六成热逐片下锅炸至定型捞出,油温升高复炸至金黄酥脆'),(59,4,'另起锅熬糖醋汁:白醋+糖煮至粘稠,加胡萝卜丝葱丝,下肉片快速翻炒裹汁出锅'),
(60,1,'茄子土豆青椒切滚刀块'),(60,2,'锅中多油,分别炸土豆和茄子至金黄'),(60,3,'调碗汁:生抽+老抽+糖+盐+水+淀粉'),(60,4,'锅中留油爆香蒜末,下所有食材翻炒,倒入碗汁收汁出锅');
INSERT INTO recipe_steps (recipe_id, step_order, content) VALUES
-- 东北菜继续 + 早餐
(61,1,'五花肉切块焯水,粉条泡软'),(61,2,'锅中少许油下五花肉煸至微黄'),(61,3,'加生抽、老抽、八角、姜片,加水没过肉'),(61,4,'炖30分钟后加粉条和白菜再炖10分钟收汁出锅'),
(62,1,'排骨焯水,土豆茄子切块,豆角掰段,玉米切段'),(62,2,'锅中炒香排骨,加生抽老抽上色'),(62,3,'加水没过排骨先炖20分钟'),(62,4,'加入所有蔬菜再炖15分钟加盐调味出锅'),
(63,1,'猪里脊切丝,加料酒、淀粉、蛋清腌制'),(63,2,'大葱白切丝摆盘,甜面酱+生抽+糖+水调酱汁'),(63,3,'锅中滑炒肉丝至变色盛出'),(63,4,'锅中炒香酱汁,倒回肉丝翻炒裹酱,盛入葱丝盘中,配豆腐皮卷食'),
(64,1,'红薯去皮切滚刀块'),(64,2,'油温六成热,下红薯块炸至金黄熟透捞出'),(64,3,'另起锅加白糖和少量水,小火熬至焦黄色'),(64,4,'快速下入炸好的红薯翻滚裹糖,趁热装盘(配一碗凉水拔丝)'),
(65,1,'五花肉冷水下锅煮20分钟捞出切薄片'),(65,2,'酸菜切丝洗两遍挤干'),(65,3,'锅中放肉片和姜片八角加水炖20分钟'),(65,4,'加入酸菜和粉条再炖10分钟加盐出锅'),
(66,1,'猪瘦肉切块,加盐料酒腌制,裹淀粉糊'),(66,2,'油温六成热下锅炸至金黄捞出'),(66,3,'调酱汁:生抽+醋+糖+水+淀粉'),(66,4,'锅中留底油,下青椒胡萝卜翻炒,倒回肉段,淋酱汁翻炒均匀出锅'),
-- 67-72
(67,1,'面粉加水揉成软面团醒30分钟'),(67,2,'擀成薄饼,刷油叠层'),(67,3,'平底锅少油,放饼,待鼓起时戳洞灌入蛋液'),(67,4,'两面煎至金黄,刷甜面酱包入生菜即可'),
(68,1,'大米提前浸泡30分钟'),(68,2,'皮蛋切丁,瘦肉切丝加姜腌制'),(68,3,'锅中水开下大米中小火煮25分钟至粥粘稠'),(68,4,'加入皮蛋和肉丝再煮5分钟加盐调味出锅'),
(69,1,'面粉加水调成面糊,打入鸡蛋搅匀'),(69,2,'加入葱花、盐搅拌均匀'),(69,3,'平底锅刷油,倒入一勺面糊摊平'),(69,4,'小火两面煎至金黄即可'),
(70,1,'小米洗净,南瓜去皮切小块'),(70,2,'锅中加水和小米,大火煮开转小火'),(70,3,'煮15分钟后加入南瓜'),(70,4,'再煮10分钟至南瓜软烂小米粘稠即可'),
(71,1,'猪肉馅加白菜碎、生抽、料酒、盐拌匀'),(71,2,'饺子皮包入馅料'),(71,3,'平底锅倒油摆入饺子,小火煎到底部金黄'),(71,4,'加半碗水盖盖焖5分钟至水收干即可'),
(72,1,'鸡蛋煎熟,火腿片略煎'),(72,2,'面包片烤至微黄'),(72,3,'面包片上放生菜、煎蛋、火腿片'),(72,4,'挤上沙拉酱,盖上另一片面包对切即可'),
-- 73-78
(73,1,'黄豆提前泡8小时打成豆浆过滤煮沸'),(73,2,'面粉+酵母+泡打粉+水揉成面团醒2小时'),(73,3,'面团擀长条切段,两段叠起用筷子压中线'),(73,4,'油温180度下锅炸至金黄膨胀捞出'),
(74,1,'猪肉馅加生抽、盐、料酒、葱花拌匀'),(74,2,'馄饨皮包入馅料捏紧'),(74,3,'锅中水开下馄饨煮至浮起再煮1分钟'),(74,4,'碗中放紫菜、虾皮、盐、葱花和开水做汤底,盛入馄饨'),
(75,1,'面粉+酵母+水揉成面团醒发1小时'),(75,2,'五花肉加生抽老抽八角桂皮姜炖40分钟至软烂切碎'),(75,3,'面团分小剂子擀成饼,平底锅烙至两面金黄'),(75,4,'饼从中间划开夹入肉碎,浇一勺肉汤'),
(76,1,'糯米提前泡4小时荷叶泡软'),(76,2,'鸡腿肉切块加生抽蚝油腌制'),(76,3,'香菇腊肠切片,与鸡肉和糯米拌匀'),(76,4,'荷叶包入糯米鸡上锅蒸40分钟'),
(77,1,'面粉和绿豆面加水调成稀面糊'),(77,2,'平底铛刷油,倒一勺面糊摊成薄圆'),(77,3,'打上鸡蛋摊开,翻面'),(77,4,'刷甜面酱和辣酱,放薄脆,撒葱花卷起'),
(78,1,'红豆煮烂加糖压成豆沙'),(78,2,'面粉加酵母水揉成面团醒发1小时'),(78,3,'面团分小剂子擀皮包入豆沙'),(78,4,'上锅蒸15分钟关火焖3分钟');
INSERT INTO recipe_steps (recipe_id, step_order, content) VALUES
-- 汤羹 79-86 + 凉菜 87-94 + 主食 95-100
(79,1,'西红柿切块,鸡蛋打散'),(79,2,'锅中倒油下西红柿炒出汁'),(79,3,'加水煮开'),(79,4,'淋入蛋液搅散,加盐调味,撒葱花淋香油'),
(80,1,'紫菜撕碎放碗中'),(80,2,'锅中水烧开'),(80,3,'淋入蛋液形成蛋花'),(80,4,'倒入碗中,加盐、虾皮、香油'),
(81,1,'排骨焯水备用'),(81,2,'锅中放入排骨、姜片加足水'),(81,3,'大火煮开转小火炖40分钟'),(81,4,'加入玉米段再炖15分钟加盐出锅'),
(82,1,'牛肉末加蛋清淀粉拌匀'),(82,2,'锅中加水烧开,下豆腐丁'),(82,3,'滑入牛肉末搅散煮2分钟'),(82,4,'加盐白胡椒调味,淀粉勾薄芡,撒香菜出锅'),
(83,1,'木耳泡发切丝,豆腐切丝'),(83,2,'锅中水开下豆腐丝木耳丝煮2分钟'),(83,3,'加生抽、盐、白胡椒粉'),(83,4,'淋入蛋液和淀粉水,加醋和香油出锅'),
(84,1,'鲫鱼处理干净,两面划刀'),(84,2,'热锅倒油煎鱼至两面金黄'),(84,3,'加开水没过鱼,加姜葱料酒'),(84,4,'大火煮10分钟汤变奶白加豆腐块再煮5分钟加盐调味'),
(85,1,'排骨焯水,莲藕去皮切厚块'),(85,2,'排骨、莲藕、姜片、红枣一起入砂锅'),(85,3,'加水大火煮开转小火炖1小时'),(85,4,'加盐调味出锅'),
(86,1,'银耳提前泡发撕小朵,莲子泡发去芯'),(86,2,'银耳、莲子、红枣入锅加水'),(86,3,'大火煮开转小火炖30分钟'),(86,4,'加冰糖再炖10分钟至银耳出胶'),
(87,1,'黄瓜洗净拍碎切段'),(87,2,'蒜切末'),(87,3,'调拌汁:蒜末+生抽+醋+盐+辣椒油+香油'),(87,4,'将拌汁倒入黄瓜中拌匀即可'),
(88,1,'内酯豆腐倒扣入盘中'),(88,2,'皮蛋切丁放在豆腐上'),(88,3,'葱切末撒上'),(88,4,'淋生抽、醋、香油、盐调成的汁'),
(89,1,'木耳泡发后焯水1分钟'),(89,2,'捞出过凉水沥干'),(89,3,'大蒜切末'),(89,4,'加蒜末、生抽、醋、盐、辣椒油、香油拌匀'),
(90,1,'粉丝泡软焯水,黄瓜胡萝卜切丝'),(90,2,'粉丝沥干和蔬菜丝混合'),(90,3,'蒜切末'),(90,4,'加蒜末、生抽、醋、盐、辣椒油、香油拌匀'),
(91,1,'番茄洗净切片'),(91,2,'番茄片摆盘'),(91,3,'均匀撒上白糖'),(91,4,'冷藏10分钟更佳'),
(92,1,'鸡胸肉冷水下锅煮熟,撕成细丝'),(92,2,'黄瓜切丝'),(92,3,'调酱汁:芝麻酱+生抽+醋+蒜末+辣椒油+盐'),(92,4,'鸡丝黄瓜放入碗中,淋酱汁拌匀'),
(93,1,'黄瓜洗净用刀面拍裂切段'),(93,2,'蒜切末'),(93,3,'加蒜末、生抽、醋、盐、辣椒油、香油'),(93,4,'拌匀即可'),
(94,1,'海带丝洗净焯水2分钟'),(94,2,'捞出过凉水沥干'),(94,3,'蒜切末'),(94,4,'加蒜末、生抽、醋、盐、辣椒油、香油拌匀'),
(95,1,'鸡蛋打散加少许盐,葱切粒'),(95,2,'热锅多油,倒入蛋液快速搅散'),(95,3,'加入米饭大火翻炒压散'),(95,4,'加盐调味,撒大量葱花翻炒出锅'),
(96,1,'番茄切块,鸡蛋打散'),(96,2,'锅中少油炒香番茄出汁,加水煮开'),(96,3,'下入面条煮至八分熟'),(96,4,'淋入蛋液成蛋花,加盐调味,撒葱花出锅'),
(97,1,'肉末炒香,加甜面酱和黄豆酱小火炸出酱香'),(97,2,'面条煮熟捞出过凉水'),(97,3,'黄瓜切丝'),(97,4,'面条盛碗,码上肉酱和黄瓜丝,撒葱花拌匀'),
(98,1,'葱切段,锅中多油小火炸葱至焦黄出香'),(98,2,'葱油过滤备用'),(98,3,'调碗底:生抽+老抽+糖混合'),(98,4,'面条煮熟捞出,浇葱油和酱油汁拌匀'),
(99,1,'米粉提前泡软'),(99,2,'鸡蛋炒散,豆芽焯水,火腿切片'),(99,3,'热锅多油下米粉大火快炒'),(99,4,'加入鸡蛋豆芽火腿,生抽调味翻炒出锅'),
(100,1,'面条放入碗中'),(100,2,'加酱油、猪油、盐、葱花'),(100,3,'开水冲入碗中搅匀'),(100,4,'煮好的面条捞入碗中即可');

View File

@@ -0,0 +1,59 @@
-- ChowBox 数据库表结构
-- MySQL 5.7+
CREATE DATABASE IF NOT EXISTS chowbox DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE chowbox;
-- 菜谱表
CREATE TABLE IF NOT EXISTS recipes (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL COMMENT '菜名',
difficulty TINYINT NOT NULL DEFAULT 2 COMMENT '难度 1-5 星',
cook_time INT NOT NULL COMMENT '烹饪时长(分钟)',
image_url VARCHAR(255) DEFAULT '' COMMENT '成品图URL',
category VARCHAR(50) NOT NULL COMMENT '菜系分类(川菜/粤菜/家常菜等)',
season VARCHAR(20) DEFAULT 'all' COMMENT '适宜季节(spring/summer/autumn/winter/all)',
meal_time VARCHAR(20) DEFAULT 'all' COMMENT '餐时(breakfast/lunch/dinner/all)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜谱';
-- 菜谱食材表
CREATE TABLE IF NOT EXISTS recipe_ingredients (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
recipe_id BIGINT NOT NULL COMMENT '菜谱ID',
ingredient_name VARCHAR(50) NOT NULL COMMENT '食材名称',
amount VARCHAR(50) DEFAULT '' COMMENT '用量(如2个/200克)',
is_staple TINYINT DEFAULT 1 COMMENT '是否主料(1主料/0辅料)',
FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜谱食材';
-- 菜谱步骤表
CREATE TABLE IF NOT EXISTS recipe_steps (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
recipe_id BIGINT NOT NULL COMMENT '菜谱ID',
step_order INT NOT NULL COMMENT '步骤序号',
content VARCHAR(500) NOT NULL COMMENT '步骤描述',
image_url VARCHAR(255) DEFAULT '' COMMENT '步骤图URL',
FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜谱步骤';
-- 用户偏好表
CREATE TABLE IF NOT EXISTS user_preferences (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
openid VARCHAR(64) NOT NULL UNIQUE COMMENT '微信openid',
taste VARCHAR(50) DEFAULT '' COMMENT '口味偏好(辣/清淡/酸甜等)',
allergies VARCHAR(200) DEFAULT '' COMMENT '过敏/忌口(逗号分隔)',
price_range VARCHAR(20) DEFAULT 'all' COMMENT '外卖价格区间(low/medium/high/all)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户偏好';
-- 开盒记录表
CREATE TABLE IF NOT EXISTS box_history (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
openid VARCHAR(64) NOT NULL COMMENT '微信openid',
box_type VARCHAR(20) NOT NULL COMMENT '盲盒类型(takeout/fridge/explore)',
result_data JSON COMMENT '结果数据',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_openid_time (openid, created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开盒记录';

83
doc/box.md Normal file
View File

@@ -0,0 +1,83 @@
以下是“吃啥盲盒”开盒动效的详细分镜描述覆盖从点击到展示结果的完整过程可直接交付UI动效设计师和前端开发。
---
## 开盒动效分镜 · 吃啥盲盒
**整体时长**约1.8-2.2秒
**触发动作**:用户点击任一模式入口按钮(外卖盒/冰箱盒/附近盒)
**核心情感曲线**:期待→悬念→惊喜→清晰
**实现方式建议**优先使用Lottie + CSS关键帧动画兼容低端机型可降级为CSS动画序列
---
### 第一幕 · 震动蓄力0ms 400ms
**目标**:传达“盒子里有东西在动”,制造期待。
| 时间 | 元素 | 变化描述 | 缓动 |
|------|------|----------|------|
| 0ms | 点击的按钮/卡片 | 轻微缩放scale 1.0 → 1.05 → 1.0 | ease-out |
| 0ms | 整个盲盒入口 | 出现一圈橙色光晕从中心向外扩散后消失 | ease-out, opacity 1→0 |
| 100ms | 盲盒图标(如🎁) | 开始高频微动:左右摇摆 ±3deg频率先快后慢 | ease-in-out |
| 0-400ms | 屏幕背景(盲盒大厅) | 逐渐变暗至亮度0.6,其余元素后退感 | 线性 |
**配套反馈**:手机轻微震动(调用`wx.vibrateShort` 15ms
---
### 第二幕 · 盒盖开启400ms 1000ms
**目标**:视觉上呈现“盒子被打开”的结构变化。
| 时间 | 元素 | 变化描述 | 缓动 |
|------|------|----------|------|
| 400ms | 盲盒容器(方形盒子) | 盒盖部分分离向上位移动画Y轴偏移 -60rpx同时opacity 1→0 | ease-in |
| 450ms | 盒体内部 | 从缝隙中透出金色光芒,逐渐增强 | ease-out |
| 500ms | 盒体本身 | 向下轻微压缩scaleY 1→0.9→1模拟受力反弹 | ease-out |
| 600ms | 金色光芒 | 蔓延至整个屏幕形成短暂的白屏过渡白色遮罩从中心放射opacity 0→0.8→0 | ease-in-out持续400ms |
| 700ms | 结果承载层(卡片/空状态) | 在光芒最亮时完成挂载,但此时仍被白色遮罩覆盖 | - |
**配套反馈**第二次短震10ms强化“打开了”的触感。
---
### 第三幕 · 展示与定型1000ms 1800ms
**目标**:结果内容华丽登场,信息清晰呈现。
| 时间 | 元素 | 变化描述 | 缓动 |
|------|------|----------|------|
| 1000ms | 白色遮罩 | 开始淡出opacity 0.8→0持续300ms | ease-in |
| 1000ms | 结果卡片(外卖/菜谱/店铺) | 从缩放0.85、Y轴+30rpx开始弹出 | ease-out-back弹性动画 |
| 1100ms | 卡片内商家图片 | 单独从模糊blur 8px变清晰0px持续200ms | ease-out |
| 1200ms | 标题文字(“你抽到的外卖是…”) | 逐字或整行从上方飘入opacity 0→1Y -20→0 | ease-out |
| 1300ms | 评分/距离等次要信息 | 阶梯式出现每组延迟50ms | ease-out |
| 1500ms | 底部操作按钮 | 从下方滑入Y +40→0opacity 0→1 | ease-out |
| 1600ms | 彩带动效(可选) | 短暂彩色纸屑从卡片背后喷射3-5片 | 物理抛物线,随机旋转 |
**配套反馈**:手机长震一次(`wx.vibrateLong`)或播放轻快“叮”音效(需音频资源)
---
### 各模式动效差异点
| 模式 | 第二幕盒内光芒颜色 | 第二幕溅射粒子 | 第四幕彩带 |
|------|-------------------|---------------|-----------|
| 外卖盲盒 | 橙色→暖黄 | 迷你外卖车图标飘出 | 外卖袋emoji纸屑 |
| 冰箱盲盒 | 绿色→浅绿 | 蔬菜粒子(🥬🍅) | 餐具emoji纸屑 |
| 探店盲盒 | 紫色→粉金 | 地图标记气泡冒起 | 星星emoji纸屑 |
---
### 降级策略(性能受限时)
- 移除粒子与彩带,仅保留盒盖位移+光芒过渡+卡片弹性动画。
- 所有动画时长缩短至1.2秒弹性缓动改为普通ease-out。
- 关闭震动序列通过API判断手机性能或用户设置
---
### 开发关键参数
- **Lottie JSON 尺寸**控制在80KB以内帧率30fps。
- **CSS 动画**:使用`transform``opacity`开启GPU加速`will-change`)。
- **遮罩过渡**:使用`page-mask`绝对定位覆盖z-index分层。
- **依赖资源**:音频文件(.mp3, 5KB以内震动序列图片预加载结果卡片图提前加载
---

169
doc/plan.md Normal file
View File

@@ -0,0 +1,169 @@
以下为“吃啥盲盒ChowBox”小程序的完整开发方案整合了此前所有讨论并细化至可落地执行的程度。
---
# 吃啥盲盒ChowBox小程序 · 产品开发方案 v1.0
## 一、产品概况
**产品名称**:吃啥盲盒
**英文名**ChowBox
**Slogan**:今天吃啥?开个盲盒。
**产品定位**:一站式解决“今天吃什么”的轻决策工具,融合外卖盲盒、冰箱食材做菜、附近探店三种场景,用随机惊喜打破选择困难。
**目标用户**:都市白领、学生、独居人群、年轻家庭,经常面临吃饭决策困难的群体。
**平台形态**:微信小程序(便于传播及跳转外部平台)
---
## 二、核心功能模块
### 模式一外卖盲盒Takeout Box
- **一句话描述**:自动获取附近高质量外卖商家,随机推荐一家,一键跳转下单。
- **用户流程**
1. 授权地理位置。
2. 系统以定位为中心搜索1.53公里内外卖商家。
3. 按评分≥4.5、月销量商圈前30%、配送≤40分钟、无严重差评等规则过滤高质量池。
4. 加权随机选出1家展示店名、评分、人均、距离、起送价/配送费、热销Top3、好评亮点。
5. 用户点击“去下单”跳转美团/饿了么小程序对应商家页。
6. 可点“换一家”重新随机(保证不重复上一次结果)。
- **交互细节**:开盒动效(震动→发光→展开卡片),上方提示“你抽到的外卖是…”
### 模式二冰箱盲盒Fridge Box
- **一句话描述**:录入现有食材,智能推荐能做的菜并给出步骤,缺的调料自动提醒。
- **用户流程**
1. 输入食材(手动/语音/勾选常用),可自定义常备调料(油盐酱醋默认已有)。
2. 系统从菜谱库中匹配完全匹配所有食材都具备优先其次为缺12种辅料的菜标注“还差XX”。
3. 随机展示35道菜显示匹配度百分比及缺少食材。
4. 点开详情:用料清单、步骤图文/视频、烹饪时长、难度星级。
5. 支持一键添加缺少食材至购物清单,或分享菜谱。
6. 结合时段推荐(早餐推快手菜,冬季推炖煮)。
- **菜谱数据来源**:初始购买/爬取公开菜谱合规授权后期支持UGC上传+审核。
### 模式三探店盲盒Explore Box
- **一句话描述**:随机推荐附近一家高评价堂食好店,一键导航前往。
- **用户流程**
1. 授权定位。
2. 搜索13公里内餐饮POI地图API
3. 按评分≥4.3、评论数≥50、结合用户口味偏好与价格设定过滤。
4. 随机推荐1家店名、菜系、人均、距离、推荐语提炼用户评价、招牌菜和环境图。
5. 可“导航过去”(调起腾讯/高德地图)或查看详情页(跳转大众点评小程序)。
6. 同样支持“换一家”。
---
## 三、品牌与UI风格
- **主色调**:活力橙(#FF6A3D)搭配暖黄,体现美食与惊喜感。
- **辅助元素**:盲盒机、问号盒、食材图标等插画风;三个模式用统一“盒子”视觉容器,入口设计成盲盒机样式。
- **动效**开盒过程1.5秒震动→开盖→弹出结果卡片,增强仪式感。
- **字体**:圆体或粗圆体,传达亲切年轻气质。
- **底部导航**:首页(盲盒大厅)、记录、我的,极简结构。
---
## 四、技术架构
### 整体选型
- **前端**原生微信小程序WXML + WXSS + JavaScript使用微信官方开发工具保证最佳性能和兼容性。
- **后端**Java 8 + Spring Boot 2.x采用经典 MVC 架构,部署于云服务器或容器化环境。
- **数据库**
- 主库MySQL 5.7+(商家、菜谱、用户数据)
- 缓存Redis热点POI数据、高频菜谱匹配结果、会话缓存
- **外部服务**
- 高德地图Web APIPOI搜索、逆地理、路径规划
- 腾讯位置服务(备选)
- 菜谱数据API自建或合作方
- 微信开放能力(跳转外卖平台小程序、订阅消息、地理授权)
### 数据流
```
用户授权定位 → 后端获取经纬度 → 调用地图POI并缓存按网格+时间)→ 业务规则过滤/加权→ 随机抽取 → 返回结果
```
### 核心算法
- **高质量池筛选**SQL或内存运算完成评分、销量、配送时长过滤。
- **随机推荐**
1. 确定当日候选集(离线定时刷新或实时计算)。
2. 客户端请求时服务端从候选集中用加权随机如评分权重、用户偏好协同因子抽取1条同时记录已展示避免短时重复。
- **菜谱匹配**:将用户食材列表与菜谱食材表求交集,计算匹配度 = 交集食材数 / 菜谱总食材数降序排列随机取Top3。
---
## 五、数据策略与合规
| 数据需求 | 获取方式 | 更新频率 |
|----------|----------|----------|
| 附近餐饮POI名称、坐标、评分、人均、电话、图片 | 高德/腾讯地图POI搜索API含扩展信息 | 用户实时请求缓存1小时后台热门区域每6小时预缓存 |
| 外卖配送信息(起送价、配送费、时长) | 外卖开放平台(如有)或地图API中部分连锁商户信息 | 随POI一起 |
| 用户评价提炼(推荐语) | 使用公开点评文本摘要(通过合法数据供应商)或高德评分+标签 | 每日更新 |
| 菜谱数据 | 自建库+公开菜谱API合作 | 每周迭代 |
| 用户偏好 | 本地存储+后台用户画像(可选) | 实时 |
**合规要点**
- 不使用网页爬虫抓取外卖/点评平台商业数据所有商家信息源自地图开放API。
- 用户定位需明确授权,可拒绝或手动选择位置。
- 跳转外卖平台仅通过小程序URL Scheme合法跳转。
---
## 六、功能页面结构
1. **启动页**Logo+“今天吃啥?开个盲盒”。
2. **盲盒大厅(首页)**:三大模式入口(卡片+动效),每日一言。
3. **外卖/探店员详情页**:商家大图、信息卡片、招牌菜横滑、吸底操作按钮。
4. **冰箱盲盒-食材录入页**:输入框+标签+语音,我的常备调料编辑。
5. **菜谱推荐列表页**:菜谱卡片+匹配度+缺少食材高亮。
6. **菜谱详情页**:用料、步骤、计时器、购物车按钮。
7. **记录页**:最近开盒历史、收藏的店/菜谱。
8. **个人中心**:偏好设置(口味、忌口、价格区间)、关于我们。
---
## 七、开发路线图MVP → 全功能)
**Phase 1核心MVP46周**
- 完成原生微信小程序项目搭建,实现定位授权。
- 搭建 Java 8 + Spring Boot 后端项目,配置 MySQL 数据库连接。
- 接入高德POI搜索实现外卖盲盒基础推荐过滤+随机)。
- 设计 MySQL 数据库表结构离线导入示例菜谱100道。
- 完成冰箱盲盒基本匹配逻辑与详情展示。
- UI盲盒开启动效1.0。
**Phase 2双模式补全34周**
- 探店盲盒功能上线。
- 优化推荐权重,加入用户反馈(喜欢/踩)。
- 菜谱库扩充至500+,支持语音录入食材。
**Phase 3体验与运营23周**
- 记忆用户偏好,协同过滤简单推荐。
- 分享卡片、订阅消息(每日推荐通知)。
- 性能优化,误触防护,无结果兜底。
**Phase 4上线审核与迭代**
- 小程序全功能测试,提交审核。
- 数据监控与迭代,探索拍照识食材、多人开盒决策等创新功能。
---
## 八、预算与资源预估(最小可行)
- **研发人员**1名全栈或前后端各1兼职
- **设计**1名UI可外包关键页面
- **服务器**初期微信云托管低配约200500元/月)
- **API费用**:高德地图免费配额(日调用量<30万次足够菜谱API或数据购买约10003000元一次性
- **时间**MVP约2个月可上线测试
---
## 九、核心文案与传播素材
- **小程序简介**外卖盲盒冰箱做菜附近探店一键解决今天吃啥
- **分享文案**
我又开出了宝藏店铺你也来测测今天吃啥吧
冰箱剩菜变盛宴这个盲盒有点厉害
- **功能引导**
外卖盲盒页不知道点什么让我帮你决定
冰箱盲盒页拍下你的食材魔法就出现了
探店盲盒页好店就在转角敢不敢盲开一个
---

163
doc/ui.md Normal file
View File

@@ -0,0 +1,163 @@
下面以线框描述+关键标注的方式呈现“吃啥盲盒”核心页面的UI草图。整体采用**活力橙+暖黄**主色调,圆角卡片、盲盒机质感、轻拟物图标,营造“开盒惊喜”的氛围。
---
### 1. 盲盒大厅(首页)
```
┌──────────────────────────┐
│ 上午 11:30 📍 定位 │ <-- 状态栏/定位按钮
├──────────────────────────┤
│ │
│ 🎁 今天吃啥? │ <-- 大标题,圆体
│ 开个盲盒 │
│ │
│ ┌─ 外卖盲盒 ────────┐ │
│ │ 🛵 │ │
│ │ 开个外卖盒 │ │ <-- 主按钮,带微动效
│ │ 随机高质量外卖 │ │
│ └──────────────────┘ │
│ ┌─ 冰箱盲盒 ────────┐ │
│ │ 🥬 │ │
│ │ 开个冰箱盒 │ │
│ │ 有啥食材做啥菜 │ │
│ └──────────────────┘ │
│ ┌─ 探店盲盒 ────────┐ │
│ │ 🍜 │ │
│ │ 开个附近盒 │ │
│ │ 发现隐藏好店 │ │
│ └──────────────────┘ │
│ │
│ “今天宜尝试酸辣口味” │ <-- 每日一言
└──────────────────────────┘
```
**说明**:三个入口设计成盲盒机器视觉卡片,点击后触发开盒动效。上角定位按钮可手动切换位置。
---
### 2. 外卖盲盒结果页
```
┌──────────────────────────┐
│ ← 返回 📤 分享 │
├──────────────────────────┤
│ 🎉 你抽到的外卖是… │
│ │
│ ┌────────────────────┐ │
│ │ [商家头图] │ │
│ │ 🌟 4.8 月销999+ │ │
│ │ 川味小厨 │ │
│ │ ¥32/人 · 1.2km │ │
│ │ 30-40分钟送达 │ │
│ │ “水煮鱼绝了!” │ │
│ │ [招牌菜1][2][3] │ │
│ └────────────────────┘ │
│ │
│ ┌─ 去美团下单 ──────┐ │
│ └────────────────────┘ │
│ 🔄 再开一个 │ <-- 换一家
└──────────────────────────┘
```
**说明**:商家信息卡片居中,招牌菜可横滑。吸底按钮为主操作,弱化“换一家”防止无限循环。
---
### 3. 冰箱盲盒-食材录入页
```
┌──────────────────────────┐
│ 冰箱里有什么? │
│ │
│ ┌──────────────────┐ │
│ │ 🔍 输入食材名称 │ │ <-- 输入框带语音icon
│ └──────────────────┘ │
│ 常用:🥚鸡蛋 🍅西红柿 🥩鸡胸 🧅洋葱 │
自定义 │
│ │
│ 📦 我的常备调料 │
│ ✔️油 ✔️盐 ✔️酱油 ✔️醋 │
│ ✔️料酒 ✔️生抽 ➕添加 │
│ │
│ ┌─── 开盒做饭!───┐ │
│ └──────────────────┘ │
└──────────────────────────┘
```
**说明**:标签化快速输入,可语音录入。常备调料默认勾选,减少重复输入。按钮文案呼应“开盒”。
---
### 4. 冰箱盲盒-菜谱推荐列表
```
┌──────────────────────────┐
│ 为你找到3个菜谱 │
│ │
│ ┌─ 菜谱卡片 ────────┐ │
│ │ 西红柿炒蛋 匹配度95%│ │
│ │ 🥬 缺:小葱 │ │
│ └──────────────────┘ │
│ ┌─ 菜谱卡片 ────────┐ │
│ │ 鸡肉沙拉 匹配度80%│ │
│ │ 🥬 缺:生菜、沙拉酱 │ │
│ └──────────────────┘ │
│ ┌─ 菜谱卡片 ────────┐ │
│ │ 煎鸡胸 匹配度90%│ │
│ │ 🥬 缺:黑胡椒 │ │
│ └──────────────────┘ │
│ │
│ 🔄 换一批菜谱 │
└──────────────────────────┘
```
**说明**:卡片突出匹配度,用绿色高亮匹配,黄色标注缺失食材。优先展示全匹配菜谱。
---
### 5. 菜谱详情页
```
┌──────────────────────────┐
│ [成品图] ⭐难度2 │
│ 西红柿炒蛋 ⏱ 10分钟 │
├──────────────────────────┤
│ 🥚 用料 │
│ 鸡蛋2个 西红柿2个 盐 糖 │
│ 小葱(家里缺) → 加入购物单│
│ │
│ 📝 步骤 │
│ 1. 鸡蛋打散,加盐搅匀 │
│ 2. 西红柿切块备用 │
│ 3. 热油炒蛋,盛出 │
│ 4. 炒西红柿出汁… │
│ │
│ 🔖 计时器 📋 购物清单 │
└──────────────────────────┘
```
**说明**:用料缺货高亮并支持一键加入购物清单。步骤清晰,可内置简单计时器。
---
### 6. 探店盲盒结果页
```
┌──────────────────────────┐
│ 🎊 附近发现一家宝藏店 │
│ │
│ ┌────────────────────┐ │
│ │ [店铺头图] │ │
│ │ 🏮 藏巷·居酒屋 │ │
│ │ ⭐4.6 · 2.3km │ │
│ │ 人均 ¥120 │ │
│ │ “三文鱼厚切超满足”│ │
│ │ 🥘 招牌:鹅肝寿司 │ │
│ └────────────────────┘ │
│ │
│ ┌── 导航过去 ───────┐ │
│ └────────────────────┘ │
│ 🗺 查看详细点评 │
│ 🔄 再开一个 │
└──────────────────────────┘
```
**说明**:强调推荐语和招牌菜,导航为主要操作,跳转外部地图;可查看详情跳大众点评。
---
### 全局元素
- **颜色**:背景米白(#FFFBF4),主色橙(#FF6A3D),文字深灰(#333),高亮绿(#4CAF50)
- **圆角**卡片16px按钮24px
- **字体**:微信默认中文字体(苹方/雅黑)标题加粗大小16-20px
- **图标**:线性+面性结合,盲盒/食材使用自定义插画风
- **错误兜底**:定位失败时显示“手动选择位置”,无商家时显示“附近喵星人占领了,换片区域试试?”

30
miniapp/app.js Normal file
View File

@@ -0,0 +1,30 @@
App({
globalData: {
userInfo: null,
location: null,
baseUrl: 'http://localhost:8080'
},
onLaunch() {
// 不在 onLaunch 中直接调定位,等用户操作时再申请,符合新版隐私规范
},
getLocation(callback) {
const that = this;
wx.getLocation({
type: 'gcj02',
success: (res) => {
that.globalData.location = {
latitude: res.latitude,
longitude: res.longitude
};
if (callback) callback(null, that.globalData.location);
},
fail: (err) => {
console.log('定位失败:', err);
// 引导用户手动选择位置或开启定位
if (callback) callback(err);
}
});
}
});

55
miniapp/app.json Normal file
View File

@@ -0,0 +1,55 @@
{
"pages": [
"pages/index/index",
"pages/takeout-result/takeout-result",
"pages/fridge-input/fridge-input",
"pages/recipe-list/recipe-list",
"pages/recipe-detail/recipe-detail",
"pages/explore-result/explore-result",
"pages/records/records",
"pages/mine/mine"
],
"window": {
"backgroundTextStyle": "light",
"navigationBarBackgroundColor": "#E8693B",
"navigationBarTitleText": "吃啥盲盒",
"navigationBarTextStyle": "white",
"backgroundColor": "#FFFBF7"
},
"tabBar": {
"color": "#999999",
"selectedColor": "#E8693B",
"backgroundColor": "#FFFFFF",
"borderStyle": "white",
"list": [
{
"pagePath": "pages/index/index",
"text": "盲盒大厅",
"iconPath": "static/tab-home.png",
"selectedIconPath": "static/tab-home-active.png"
},
{
"pagePath": "pages/records/records",
"text": "记录",
"iconPath": "static/tab-records.png",
"selectedIconPath": "static/tab-records-active.png"
},
{
"pagePath": "pages/mine/mine",
"text": "我的",
"iconPath": "static/tab-mine.png",
"selectedIconPath": "static/tab-mine-active.png"
}
]
},
"permission": {
"scope.userLocation": {
"desc": "需要获取你的位置来推荐附近的美食"
}
},
"requiredPrivateInfos": [
"getLocation"
],
"style": "v2",
"sitemapLocation": "sitemap.json"
}

135
miniapp/app.wxss Normal file
View File

@@ -0,0 +1,135 @@
/*
* ChowBox 全局设计系统 · 高级简约
* 统一 8rpx 间距 · 3 级圆角 · 5 级字号 · 3 级阴影
*/
/* ═══ 设计令牌 ═══ */
page {
/* 主色系 — 暖橙 */
--color-primary: #E8693B;
--color-primary-light: #F0976E;
--color-primary-pale: #FFF3EE;
--color-primary-ghost: rgba(232, 105, 59, 0.08);
/* 语义色 */
--color-green: #4CAF50;
--color-green-pale: #EDF7EE;
--color-purple: #7C3AED;
--color-purple-pale: #F4F0FF;
/* 表面色 */
--color-bg: #FFFBF7;
--color-surface: #FFFFFF;
--color-hairline: #F2EDE8;
/* 文字色 */
--color-text: #1A1A2E;
--color-text-secondary: #8C8C8C;
--color-text-muted: #B0B0B0;
/* 圆角 — 3 级 */
--radius-sm: 8rpx;
--radius-md: 16rpx;
--radius-lg: 24rpx;
--radius-full: 999rpx;
/* 阴影 — 3 级(极简克制) */
--shadow-sm: 0 1rpx 4rpx rgba(0, 0, 0, 0.04);
--shadow-md: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
--shadow-lg: 0 4rpx 24rpx rgba(0, 0, 0, 0.07);
/* 间距 — 8rpx 基数 */
--space-xs: 8rpx;
--space-sm: 16rpx;
--space-md: 24rpx;
--space-lg: 32rpx;
--space-xl: 48rpx;
--space-2xl: 64rpx;
/* 字号 — 5 级 */
--text-caption: 22rpx;
--text-body-sm: 24rpx;
--text-body: 28rpx;
--text-subtitle: 32rpx;
--text-title: 36rpx;
--text-headline: 40rpx;
/* 动画 */
--ease-out: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
/* 基础样式 */
background-color: var(--color-bg);
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
font-size: var(--text-body);
color: var(--color-text);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* ═══ 主按钮 ═══ */
.btn-primary {
display: flex;
align-items: center;
justify-content: center;
height: 96rpx;
line-height: 96rpx;
background: var(--color-primary);
color: #FFFFFF;
border-radius: var(--radius-lg);
font-size: var(--text-body);
font-weight: 600;
border: none;
padding: 0;
margin: 0;
box-sizing: border-box;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.btn-primary::after {
border: none;
border-radius: var(--radius-lg);
}
.btn-primary:active {
opacity: 0.85;
transform: scale(0.98);
}
.btn-primary[disabled] {
opacity: 0.35;
transform: none;
}
/* ═══ 通用卡片 ═══ */
.card {
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
overflow: hidden;
}
/* ═══ 标签 ═══ */
.tag {
display: inline-flex;
align-items: center;
font-size: var(--text-caption);
padding: 4rpx 14rpx;
border-radius: var(--radius-full);
font-weight: 500;
}
.tag-orange { color: var(--color-primary); background: var(--color-primary-pale); }
.tag-green { color: var(--color-green); background: var(--color-green-pale); }
.tag-purple { color: var(--color-purple); background: var(--color-purple-pale); }
/* ═══ 安全区 ═══ */
.safe-bottom {
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
.safe-top {
padding-top: constant(safe-area-inset-top);
padding-top: env(safe-area-inset-top);
}

View File

@@ -0,0 +1,105 @@
Component({
properties: {
show: { type: Boolean, value: false },
boxType: { type: String, value: 'takeout' },
dataReady: { type: Boolean, value: false }
},
data: {
act: 0,
boxEmoji: '🎁',
sparkEmoji: '✨',
confettiColors: [],
_pendingAct3: false
},
observers: {
'show': function(val) {
if (val) {
this.setData({ act: 0, _pendingAct3: false });
this.startAct1();
} else {
this.reset();
}
},
'dataReady': function(val) {
if (val && this.data._pendingAct3) {
this.playAct3();
}
},
'boxType': function(val) {
const config = this.getTypeConfig(val);
this.setData({
boxEmoji: config.boxEmoji,
sparkEmoji: config.sparkEmoji,
confettiColors: config.confettiColors
});
}
},
methods: {
getTypeConfig(type) {
const map = {
takeout: {
boxEmoji: '🛵',
sparkEmoji: '🛵',
confettiColors: ['#E8693B', '#FF8A5C', '#FFD54F', '#FFAB40',
'#FFCC80', '#E8693B', '#FF8A5C', '#FFD54F', '#FFAB40']
},
fridge: {
boxEmoji: '🥬',
sparkEmoji: '🥬',
confettiColors: ['#4CAF50', '#66BB6A', '#A5D6A7', '#81C784',
'#C8E6C9', '#4CAF50', '#66BB6A', '#A5D6A7', '#81C784']
},
explore: {
boxEmoji: '🍜',
sparkEmoji: '📍',
confettiColors: ['#7C3AED', '#9C27B0', '#CE93D8', '#BA68C8',
'#E1BEE7', '#7C3AED', '#9C27B0', '#CE93D8', '#BA68C8']
}
};
return map[type] || map.takeout;
},
/* ── Act 1: 光晕扩散 + 盒子震动 (0400ms) ── */
startAct1() {
wx.vibrateShort({ type: 'light' });
setTimeout(() => {
this.setData({ act: 1 });
}, 50);
// Act 2: 盒盖飞起 + 金光 + 白屏 (400ms)
setTimeout(() => {
wx.vibrateShort({ type: 'medium' });
this.setData({ act: 2 });
// 标记等待数据
this.setData({ _pendingAct3: true });
// 如果数据已就绪,立即进入 Act 3
if (this.data.dataReady) {
this.playAct3();
}
}, 400);
},
/* ── Act 3: 内容弹出 + 纸屑(数据就绪后触发) ── */
playAct3() {
this.setData({ _pendingAct3: false });
wx.vibrateLong();
setTimeout(() => {
this.setData({ act: 3 });
}, 150);
// 通知父页面动画完成
setTimeout(() => {
this.triggerEvent('done');
}, 2200);
},
reset() {
this.setData({ act: 0, _pendingAct3: false });
}
}
});

View File

@@ -0,0 +1,3 @@
{
"component": true
}

View File

@@ -0,0 +1,52 @@
<view class="animation-overlay box-{{boxType}} {{show ? 'visible' : ''}}" wx:if="{{show}}">
<!-- ═══ Act 1: 光晕扩散 + 背景变暗 ═══ -->
<view class="bg-dimmer {{act >= 1 ? 'active' : ''}}"></view>
<view class="act1-glow-ring {{act >= 1 ? 'active' : ''}}">
<view class="glow-inner"></view>
</view>
<!-- ═══ 盲盒主体 + 盒盖 ═══ -->
<view class="box-stage {{act >= 1 ? 'active' : ''}}">
<!-- 盒盖Act2 飞起) -->
<view class="box-lid {{act >= 2 ? 'active' : ''}}">
<view class="lid-top"></view>
</view>
<!-- 金色光芒Act2 从盒缝透出) -->
<view class="golden-light {{act >= 2 ? 'active' : ''}}"></view>
<!-- 盒体 -->
<view class="box-body {{act >= 2 ? 'compressed' : ''}}">
<text class="box-emoji">{{boxEmoji}}</text>
</view>
<!-- 粒子Act2 溅射) -->
<view class="spark-particles {{act >= 2 ? 'active' : ''}}">
<view class="spark s1">{{sparkEmoji}}</view>
<view class="spark s2">{{sparkEmoji}}</view>
<view class="spark s3">{{sparkEmoji}}</view>
<view class="spark s4">{{sparkEmoji}}</view>
</view>
</view>
<!-- ═══ Act 2: 白色闪屏过渡 ═══ -->
<view class="act2-mask {{act >= 2 ? 'active' : ''}}"></view>
<!-- ═══ Act 3: 内容承载层(弹性弹出) ═══ -->
<view class="act3-content {{act >= 3 ? 'active' : ''}}">
<view class="content-inner">
<slot></slot>
</view>
</view>
<!-- ═══ 彩带纸屑Act3 收尾) ═══ -->
<view class="confetti-stage {{act >= 3 ? 'active' : ''}}">
<view class="confetti c1" wx:for="{{confettiColors}}" wx:key="*this"
style="--c:{{item}}; --x:{{10 + index * 22}}%; --d:{{0.8 + index * 0.15}}s;">
</view>
</view>
</view>

View File

@@ -0,0 +1,304 @@
/*
* 开盒动效 · 三幕分镜
* 参考 doc/box.md
*
* Act 1 (0400ms): 光晕扩散 + 盒子震动 + 背景变暗
* Act 2 (4001000ms): 盒盖飞起 + 金色光芒 + 粒子溅射 + 白色闪屏
* Act 3 (10001800ms): 内容弹性弹出 + 彩带纸屑
*
* 总时长约 1.82.0s
*/
/* ═══ 覆盖层 ═══ */
.animation-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.animation-overlay.visible {
pointer-events: auto;
}
/* ═══ 背景变暗 ═══ */
.bg-dimmer {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.55);
opacity: 0;
transition: opacity 0.4s ease-out;
}
.bg-dimmer.active {
opacity: 1;
}
/* ════════════════════════
Act 1 · 光晕扩散 (0400ms)
════════════════════════ */
.act1-glow-ring {
position: absolute;
width: 320rpx;
height: 320rpx;
border-radius: 50%;
opacity: 0;
transform: scale(0.15);
}
.act1-glow-ring.active {
animation: glowExpand 0.5s ease-out forwards;
}
.glow-inner {
width: 100%;
height: 100%;
border-radius: 50%;
}
/* 模式颜色 */
.box-takeout .glow-inner {
background: radial-gradient(circle, #E8693B 0%, rgba(232,105,59,0.4) 35%, transparent 70%);
}
.box-fridge .glow-inner {
background: radial-gradient(circle, #4CAF50 0%, rgba(76,175,80,0.4) 35%, transparent 70%);
}
.box-explore .glow-inner {
background: radial-gradient(circle, #7C3AED 0%, rgba(124,58,237,0.4) 35%, transparent 70%);
}
@keyframes glowExpand {
0% { opacity: 0.9; transform: scale(0.15); }
50% { opacity: 0.6; transform: scale(1.5); }
100% { opacity: 0; transform: scale(3.2); }
}
/* ════════════════════════
Act 1 · 盒子震动
════════════════════════ */
.box-stage {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
opacity: 0;
transform: scale(0.85);
transition: opacity 0.15s ease, transform 0.15s ease;
}
.box-stage.active {
opacity: 1;
transform: scale(1);
}
/* ── 盒盖 ── */
.box-lid {
position: relative;
z-index: 3;
margin-bottom: -12rpx;
transition: transform 0.35s ease-in, opacity 0.35s ease-in;
transition-delay: 0.05s;
}
.lid-top {
width: 120rpx;
height: 40rpx;
background: linear-gradient(180deg, #F5A623 0%, #E8961A 100%);
border-radius: 20rpx 20rpx 4rpx 4rpx;
box-shadow: 0 4rpx 16rpx rgba(200, 120, 20, 0.4);
}
.box-lid.active {
transform: translateY(-80rpx) rotate(-8deg);
opacity: 0;
}
/* ── 金色光芒 ── */
.golden-light {
position: absolute;
z-index: 1;
width: 200rpx;
height: 200rpx;
border-radius: 50%;
opacity: 0;
transform: scale(0.3);
}
.box-takeout .golden-light {
background: radial-gradient(circle, #FFD54F 0%, rgba(255, 180, 50, 0.6) 30%, transparent 65%);
}
.box-fridge .golden-light {
background: radial-gradient(circle, #A5D6A7 0%, rgba(76, 175, 80, 0.5) 30%, transparent 65%);
}
.box-explore .golden-light {
background: radial-gradient(circle, #CE93D8 0%, rgba(124, 58, 237, 0.5) 30%, transparent 65%);
}
.golden-light.active {
animation: goldenBurst 0.6s ease-out forwards;
}
@keyframes goldenBurst {
0% { opacity: 0; transform: scale(0.3); }
30% { opacity: 1; transform: scale(1.8); }
100% { opacity: 0; transform: scale(3.5); }
}
/* ── 盒体Act2 压缩反弹) ── */
.box-body {
position: relative;
z-index: 2;
transition: transform 0.25s ease-out;
}
.box-body.compressed {
animation: bodyCompress 0.3s ease-out;
}
.box-emoji {
font-size: 160rpx;
display: block;
}
@keyframes bodyCompress {
0% { transform: scaleY(1); }
40% { transform: scaleY(0.85); }
100% { transform: scaleY(1); }
}
/* ── 粒子溅射Act2 ── */
.spark-particles {
position: absolute;
z-index: 0;
top: 50%; left: 50%;
width: 0; height: 0;
pointer-events: none;
}
.spark-particles.active .spark {
animation: sparkBurst 0.7s ease-out forwards;
}
.spark {
position: absolute;
font-size: 32rpx;
opacity: 0;
}
.spark.s1 { animation-delay: 0s !important; }
.spark.s2 { animation-delay: 0.06s !important; }
.spark.s3 { animation-delay: 0.12s !important; }
.spark.s4 { animation-delay: 0.18s !important; }
@keyframes sparkBurst {
0% { opacity: 1; transform: translate(0, 0) scale(0.5); }
100% { opacity: 0; transform: translate(var(--sx, 60rpx), var(--sy, -80rpx)) scale(1.2); }
}
.box-takeout .spark { --sx: 70rpx; --sy: -90rpx; }
.box-takeout .spark.s2 { --sx: -60rpx; --sy: -70rpx; }
.box-takeout .spark.s3 { --sx: 50rpx; --sy: -100rpx; }
.box-takeout .spark.s4 { --sx: -80rpx; --sy: -80rpx; }
.box-fridge .spark { --sx: 70rpx; --sy: -90rpx; }
.box-fridge .spark.s2 { --sx: -60rpx; --sy: -70rpx; }
.box-fridge .spark.s3 { --sx: 50rpx; --sy: -100rpx; }
.box-fridge .spark.s4 { --sx: -80rpx; --sy: -80rpx; }
.box-explore .spark { --sx: 70rpx; --sy: -90rpx; }
.box-explore .spark.s2 { --sx: -60rpx; --sy: -70rpx; }
.box-explore .spark.s3 { --sx: 50rpx; --sy: -100rpx; }
.box-explore .spark.s4 { --sx: -80rpx; --sy: -80rpx; }
/* ════════════════════════
Act 2 · 白色闪屏 (6001000ms)
════════════════════════ */
.act2-mask {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: #FFFFFF;
opacity: 0;
pointer-events: none;
}
.act2-mask.active {
animation: maskFlash 0.5s ease-in-out forwards;
animation-delay: 0.2s;
}
@keyframes maskFlash {
0% { opacity: 0; }
45% { opacity: 0.92; }
100% { opacity: 0; }
}
/* ════════════════════════
Act 3 · 内容弹性弹出 (10001800ms)
════════════════════════ */
.act3-content {
position: relative;
z-index: 20;
opacity: 0;
transform: scale(0.85) translateY(30rpx);
}
.act3-content.active {
animation: contentReveal 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.content-inner {
display: flex;
flex-direction: column;
align-items: center;
}
@keyframes contentReveal {
0% { opacity: 0; transform: scale(0.85) translateY(30rpx); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
/* ════════════════════════
Act 3 · 彩带纸屑 (1600ms+)
════════════════════════ */
.confetti-stage {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
overflow: hidden;
}
.confetti-stage.active .confetti {
animation: confettiDrop 1.4s ease-in forwards;
}
.confetti {
position: absolute;
top: -20rpx;
left: var(--x, 20%);
width: 14rpx;
height: 14rpx;
border-radius: 3rpx;
background: var(--c, #E8693B);
opacity: 0;
animation-delay: var(--d, 0s);
}
@keyframes confettiDrop {
0% { opacity: 1; transform: translateY(0) rotate(0deg) scale(1); }
30% { opacity: 0.9; transform: translateY(280rpx) rotate(180deg) scale(0.7); }
60% { opacity: 0.5; transform: translateY(600rpx) rotate(400deg) scale(0.4); }
100% { opacity: 0; transform: translateY(1000rpx) rotate(720deg) scale(0.1); }
}

View File

@@ -0,0 +1,12 @@
Component({
properties: {
name: { type: String, value: '' },
icon: { type: String, value: '' },
selected: { type: Boolean, value: false }
},
methods: {
onTap() {
this.triggerEvent('toggle', { name: this.data.name, selected: !this.data.selected });
}
}
});

View File

@@ -0,0 +1,3 @@
{
"component": true
}

View File

@@ -0,0 +1,4 @@
<view class="ingredient-tag {{selected ? 'selected' : ''}}" bind:tap="onTap">
<text>{{icon}} {{name}}</text>
<text class="check" wx:if="{{selected}}">✓</text>
</view>

View File

@@ -0,0 +1,27 @@
.ingredient-tag {
display: inline-flex;
align-items: center;
padding: 8rpx var(--space-md);
border-radius: var(--radius-full);
background: #F5F2EE;
font-size: var(--text-body-sm);
color: var(--color-text-secondary);
margin: 6rpx;
transition: all 0.2s var(--ease-out);
}
.ingredient-tag:active {
transform: scale(0.95);
}
.ingredient-tag.selected {
background: var(--color-green-pale);
color: var(--color-green);
font-weight: 600;
}
.check {
margin-left: 6rpx;
font-size: var(--text-caption);
font-weight: 700;
}

View File

@@ -0,0 +1,14 @@
Component({
properties: {
type: { type: String, value: 'takeout' },
icon: { type: String, value: '' },
title: { type: String, value: '' },
desc: { type: String, value: '' },
actionText: { type: String, value: '开盒' }
},
methods: {
onTap() {
this.triggerEvent('open', { type: this.data.type });
}
}
});

View File

@@ -0,0 +1,3 @@
{
"component": true
}

View File

@@ -0,0 +1,10 @@
<view class="mode-card {{type}}" bind:tap="onTap">
<view class="card-icon-wrap">
<text class="card-icon">{{icon}}</text>
</view>
<view class="card-body">
<view class="card-title">{{title}}</view>
<view class="card-desc">{{desc}}</view>
</view>
<view class="card-action">{{actionText}}</view>
</view>

View File

@@ -0,0 +1,93 @@
/*
* 模式卡片
* 水平布局:图标区 | 文字区 | 行动按钮
* 三种模式各用色条区分(无伪元素装饰)
*/
.mode-card {
display: flex;
align-items: center;
padding: var(--space-md);
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
transition: transform 0.15s var(--ease-out);
}
.mode-card:active {
transform: scale(0.985);
}
/* ── 图标区 ── */
.card-icon-wrap {
width: 88rpx;
height: 88rpx;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: var(--space-md);
}
.card-icon {
font-size: 44rpx;
line-height: 1;
}
/* ── 文字区 ── */
.card-body {
flex: 1;
min-width: 0;
}
.card-title {
font-size: var(--text-body);
font-weight: 600;
color: var(--color-text);
margin-bottom: 4rpx;
}
.card-desc {
font-size: var(--text-body-sm);
color: var(--color-text-muted);
}
/* ── 行动按钮 ── */
.card-action {
flex-shrink: 0;
font-size: var(--text-body-sm);
font-weight: 500;
padding: var(--space-xs) var(--space-md);
border-radius: var(--radius-full);
white-space: nowrap;
}
/* ═══ 各模式专属色 ═══ */
/* 外卖 — 橙 */
.mode-card.takeout .card-icon-wrap {
background: var(--color-primary-pale);
}
.mode-card.takeout .card-action {
color: var(--color-primary);
background: var(--color-primary-pale);
}
/* 冰箱 — 绿 */
.mode-card.fridge .card-icon-wrap {
background: var(--color-green-pale);
}
.mode-card.fridge .card-action {
color: var(--color-green);
background: var(--color-green-pale);
}
/* 探店 — 紫 */
.mode-card.explore .card-icon-wrap {
background: var(--color-purple-pale);
}
.mode-card.explore .card-action {
color: var(--color-purple);
background: var(--color-purple-pale);
}

View File

@@ -0,0 +1,13 @@
Component({
properties: {
imageUrl: { type: String, value: '' },
name: { type: String, value: '' },
rating: { type: Number, value: 0 },
sales: { type: String, value: '' },
avgPrice: { type: Number, value: 0 },
distance: { type: String, value: '' },
extra: { type: String, value: '' },
recommendReason: { type: String, value: '' },
signatureDishes: { type: Array, value: [] }
}
});

View File

@@ -0,0 +1,3 @@
{
"component": true
}

View File

@@ -0,0 +1,21 @@
<view class="result-card">
<image class="result-image" src="{{imageUrl}}" mode="aspectFill" wx:if="{{imageUrl}}"></image>
<view class="result-body">
<view class="result-rating" wx:if="{{rating}}">
<text class="star">⭐</text>
<text>{{rating}}</text>
<text wx:if="{{sales}}"> 月销{{sales}}</text>
</view>
<view class="result-name">{{name}}</view>
<view class="result-info" wx:if="{{avgPrice || distance}}">
<text wx:if="{{avgPrice}}">¥{{avgPrice}}/人</text>
<text wx:if="{{avgPrice && distance}}"> · </text>
<text wx:if="{{distance}}">{{distance}}</text>
</view>
<view class="result-extra" wx:if="{{extra}}">{{extra}}</view>
<view class="result-recommend" wx:if="{{recommendReason}}">"{{recommendReason}}"</view>
<view class="result-dishes" wx:if="{{signatureDishes && signatureDishes.length}}">
<text class="tag tag-orange" wx:for="{{signatureDishes}}" wx:key="*this">{{item}}</text>
</view>
</view>
</view>

View File

@@ -0,0 +1,81 @@
/*
* 结果卡片 — 外卖/探店共用
*/
.result-card {
width: 100%;
background: var(--color-surface);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
/* ── 图片 ── */
.result-image {
width: 100%;
height: 340rpx;
background: #F3F0EC;
}
/* ── 内容 ── */
.result-body {
padding: var(--space-md);
}
/* 评分 */
.result-rating {
display: inline-flex;
align-items: center;
font-size: var(--text-body-sm);
font-weight: 600;
color: #E8A040;
margin-bottom: 8rpx;
}
.result-rating .star {
margin-right: 4rpx;
}
/* 名称 */
.result-name {
font-size: var(--text-headline);
font-weight: 700;
color: var(--color-text);
margin-bottom: 8rpx;
}
/* 信息 */
.result-info {
font-size: var(--text-body-sm);
color: var(--color-text-secondary);
margin-bottom: 12rpx;
}
/* 配送 */
.result-extra {
font-size: var(--text-body-sm);
color: var(--color-text-muted);
margin-bottom: 12rpx;
padding: var(--space-xs) var(--space-sm);
background: #FAFAF8;
border-radius: var(--radius-sm);
}
/* 推荐语 */
.result-recommend {
font-size: var(--text-body-sm);
color: var(--color-primary);
margin-bottom: 16rpx;
}
/* 标签 */
.result-dishes {
display: flex;
flex-wrap: wrap;
}
.result-dishes .tag {
font-size: var(--text-caption);
margin-right: 8rpx;
margin-bottom: 8rpx;
}

View File

@@ -0,0 +1,79 @@
const app = getApp();
const api = require('../../utils/api');
const loc = require('../../utils/location');
Page({
data: {
showAnimation: false,
dataReady: false,
result: null,
error: '',
distanceText: ''
},
onLoad() {
this.roll();
},
roll() {
this.setData({
showAnimation: true,
dataReady: false,
error: '',
result: null
});
loc.getLocation().then((pos) => {
return api.post('/api/explore/roll', {
latitude: pos.latitude,
longitude: pos.longitude,
openid: 'anonymous'
});
}).then((data) => {
this.setData({
result: data,
distanceText: ((data.distance || 0) / 1000).toFixed(1) + 'km',
dataReady: true
});
this.saveRecord(data);
}).catch((err) => {
this.setData({
showAnimation: false,
error: err.message || '附近暂无推荐好店,换片区域试试?'
});
});
},
onAnimationDone() {
this.setData({ showAnimation: false });
},
navigate() {
const shop = this.data.result;
if (!shop) return;
wx.openLocation({
latitude: shop.latitude || app.globalData.location.latitude,
longitude: shop.longitude || app.globalData.location.longitude,
name: shop.name,
address: shop.address,
scale: 16
});
},
retry() {
this.roll();
},
saveRecord(data) {
const history = wx.getStorageSync('box_history') || [];
history.push({
id: Date.now().toString(),
icon: '🍱',
name: data.name,
time: new Date().toLocaleString(),
typeName: '探店盲盒'
});
wx.setStorageSync('box_history', history);
}
});

View File

@@ -0,0 +1,7 @@
{
"usingComponents": {
"result-card": "/components/result-card/result-card",
"box-animation": "/components/box-animation/box-animation"
},
"navigationBarTitleText": "探店盲盒"
}

View File

@@ -0,0 +1,37 @@
<view class="explore-page">
<view class="empty" wx:if="{{error}}">
<view class="empty-icon">😿</view>
<view>{{error}}</view>
<button class="btn-primary" style="margin-top:32rpx" bind:tap="retry">换片区域试试</button>
</view>
<view class="result-area" wx:if="{{!showAnimation && result}}">
<view class="result-title">🎊 附近发现一家宝藏店</view>
<result-card
imageUrl="{{result.imageUrl}}"
name="{{result.name}}"
rating="{{result.rating}}"
avgPrice="{{result.avgPrice}}"
distance="{{distanceText}}"
recommendReason="{{result.recommendReason}}"
signatureDishes="{{result.signatureDishes}}"
/>
<view class="actions">
<button class="btn-primary btn-main" bind:tap="navigate">导航过去</button>
<view class="btn-retry" bind:tap="retry">再开一个</view>
</view>
</view>
<box-animation
show="{{showAnimation}}"
boxType="explore"
dataReady="{{dataReady}}"
bind:done="onAnimationDone"
>
<view class="animation-result-preview" wx:if="{{result}}">
<view class="preview-emoji">🍜</view>
<view class="preview-text">{{result.name}}</view>
<view class="preview-sub" wx:if="{{result.rating}}">⭐ {{result.rating}}</view>
</view>
</box-animation>
</view>

View File

@@ -0,0 +1,92 @@
/*
* 探店结果页
*/
.explore-page {
padding: var(--space-md);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
.result-area {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.result-title {
font-size: var(--text-subtitle);
font-weight: 600;
color: var(--color-text);
margin: var(--space-md) 0 var(--space-md);
text-align: center;
}
.actions {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
margin-top: var(--space-lg);
}
.btn-main {
width: 80%;
}
.btn-retry {
margin-top: var(--space-md);
font-size: var(--text-body-sm);
color: var(--color-text-muted);
padding: var(--space-sm);
}
.btn-retry:active {
color: var(--color-purple);
}
/* ── 错误 ── */
.empty {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 200rpx;
text-align: center;
font-size: var(--text-body);
color: var(--color-text-secondary);
}
.empty-icon {
font-size: 80rpx;
margin-bottom: var(--space-md);
opacity: 0.6;
}
/* ── 动效预览 ── */
.animation-result-preview {
display: flex;
flex-direction: column;
align-items: center;
padding: 24rpx;
}
.preview-emoji {
font-size: 64rpx;
margin-bottom: 12rpx;
}
.preview-text {
font-size: var(--text-subtitle);
font-weight: 700;
color: #FFFFFF;
text-align: center;
}
.preview-sub {
font-size: var(--text-body-sm);
color: rgba(255, 255, 255, 0.8);
margin-top: 8rpx;
}

View File

@@ -0,0 +1,165 @@
const storage = require('../../utils/storage');
const app = getApp();
const DEFAULT_STAPLES = ['油', '盐', '酱油', '醋', '料酒', '生抽', '蚝油', '葱', '姜', '蒜'];
Page({
data: {
inputValue: '',
ingredients: [],
selected: {},
staples: [],
voiceRecording: false,
showStapleEditor: false,
stapleEditValue: ''
},
onLoad() {
const saved = storage.get('custom_staples', null);
this.setData({ staples: saved || [...DEFAULT_STAPLES] });
},
onInput(e) {
this.setData({ inputValue: e.detail.value });
},
addIngredient() {
const name = this.data.inputValue.trim();
if (!name) return;
this.add(name);
this.setData({ inputValue: '' });
},
onToggle(e) {
const { name, selected } = e.detail;
if (selected) {
this.add(name);
} else {
this.remove(name);
}
},
add(name) {
if (this.data.ingredients.includes(name)) return;
const ingredients = [...this.data.ingredients, name];
const selected = { ...this.data.selected, [name]: true };
this.setData({ ingredients, selected });
},
remove(name) {
const ingredients = this.data.ingredients.filter(i => i !== name);
const selected = { ...this.data.selected, [name]: false };
this.setData({ ingredients, selected });
},
/* ── 语音输入 ── */
voiceInput() {
const recorder = wx.getRecorderManager();
if (this.data.voiceRecording) {
// 松手停止
recorder.stop();
this.setData({ voiceRecording: false });
wx.hideLoading();
return;
}
this.setData({ voiceRecording: true });
wx.showLoading({ title: '正在听…', mask: true });
recorder.start({
duration: 10000,
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 48000,
format: 'mp3'
});
recorder.onStop((res) => {
this.setData({ voiceRecording: false });
wx.hideLoading();
// 上传录音到后端识别
wx.uploadFile({
url: app.globalData.baseUrl + '/api/voice/recognize',
filePath: res.tempFilePath,
name: 'audio',
header: { 'Content-Type': 'multipart/form-data' },
success: (uploadRes) => {
try {
const data = JSON.parse(uploadRes.data);
if (data.code === 200 && data.data && data.data.text) {
const text = data.data.text.trim();
if (text) {
// 按逗号/空格/顿号分割多个食材
const names = text.split(/[,,、\s]+/);
names.forEach(n => this.add(n));
wx.showToast({ title: '已识别 ' + names.length + ' 种食材', icon: 'success' });
return;
}
}
wx.showToast({ title: '未识别到食材,请手动输入', icon: 'none' });
} catch (e) {
wx.showToast({ title: '识别失败,请手动输入', icon: 'none' });
}
},
fail: () => {
wx.showToast({ title: '语音服务暂不可用', icon: 'none' });
}
});
});
recorder.onError(() => {
this.setData({ voiceRecording: false });
wx.hideLoading();
wx.showToast({ title: '录音失败,请重试', icon: 'none' });
});
},
/* ── 自定义常备调料 ── */
editStaples() {
this.setData({ showStapleEditor: true, stapleEditValue: '' });
},
onStapleInput(e) {
this.setData({ stapleEditValue: e.detail.value });
},
addStaple() {
const name = this.data.stapleEditValue.trim();
if (!name) return;
if (this.data.staples.includes(name)) {
wx.showToast({ title: '已存在', icon: 'none' });
return;
}
const staples = [...this.data.staples, name];
this.setData({ staples, stapleEditValue: '' });
storage.set('custom_staples', staples);
},
removeStaple(e) {
const name = e.currentTarget.dataset.name;
const staples = this.data.staples.filter(s => s !== name);
this.setData({ staples });
storage.set('custom_staples', staples);
},
closeStapleEditor() {
this.setData({ showStapleEditor: false });
},
/* ── 开盒 ── */
openBox() {
if (this.data.ingredients.length === 0) {
wx.showToast({ title: '请先输入食材', icon: 'none' });
return;
}
// 将用户自定义的常备调料一并传入后端
const payload = {
ingredients: this.data.ingredients,
staples: this.data.staples
};
const params = encodeURIComponent(JSON.stringify(payload));
wx.navigateTo({ url: '/pages/recipe-list/recipe-list?payload=' + params });
}
});

View File

@@ -0,0 +1,6 @@
{
"usingComponents": {
"ingredient-tag": "/components/ingredient-tag/ingredient-tag"
},
"navigationBarTitleText": "冰箱盲盒"
}

View File

@@ -0,0 +1,66 @@
<view class="fridge-page">
<view class="title">冰箱里有什么?</view>
<view class="input-area">
<input class="ingredient-input"
placeholder="输入食材名称"
value="{{inputValue}}"
bind:input="onInput"
bind:confirm="addIngredient"
/>
<view class="voice-btn {{voiceRecording ? 'recording' : ''}}" bind:tap="voiceInput">
<text>{{voiceRecording ? '🔴' : '🎤'}}</text>
</view>
</view>
<view class="section">
<view class="section-title">常用食材</view>
<view class="tag-list">
<ingredient-tag icon="🥚" name="鸡蛋" selected="{{selected['鸡蛋']}}" bind:toggle="onToggle" />
<ingredient-tag icon="🍅" name="西红柿" selected="{{selected['西红柿']}}" bind:toggle="onToggle" />
<ingredient-tag icon="🍗" name="鸡胸肉" selected="{{selected['鸡胸肉']}}" bind:toggle="onToggle" />
<ingredient-tag icon="🧅" name="洋葱" selected="{{selected['洋葱']}}" bind:toggle="onToggle" />
<ingredient-tag icon="🥔" name="土豆" selected="{{selected['土豆']}}" bind:toggle="onToggle" />
<ingredient-tag icon="🌶️" name="青椒" selected="{{selected['青椒']}}" bind:toggle="onToggle" />
<ingredient-tag icon="🥦" name="胡萝卜" selected="{{selected['胡萝卜']}}" bind:toggle="onToggle" />
<ingredient-tag icon="🥬" name="西兰花" selected="{{selected['西兰花']}}" bind:toggle="onToggle" />
<ingredient-tag icon="🥕" name="生菜" selected="{{selected['生菜']}}" bind:toggle="onToggle" />
<ingredient-tag icon="🥬" name="豆腐" selected="{{selected['豆腐']}}" bind:toggle="onToggle" />
<ingredient-tag icon="🥩" name="猪肉" selected="{{selected['猪肉']}}" bind:toggle="onToggle" />
<ingredient-tag icon="🐥" name="鸡腿" selected="{{selected['鸡腿']}}" bind:toggle="onToggle" />
</view>
</view>
<view class="section">
<view class="section-title">
我的常备调料
<text class="edit-link" bind:tap="editStaples">编辑</text>
</view>
<view class="tag-list staples">
<text class="staple-tag" wx:for="{{staples}}" wx:key="*this">{{item}}</text>
</view>
</view>
<button class="btn-primary btn-large" bind:tap="openBox" disabled="{{ingredients.length === 0}}">
开盒做饭!
</button>
<!-- 常备调料编辑弹窗 -->
<view class="modal-mask" wx:if="{{showStapleEditor}}" bind:tap="closeStapleEditor"></view>
<view class="modal-sheet {{showStapleEditor ? 'show' : ''}}">
<view class="sheet-header">
<text class="sheet-title">编辑常备调料</text>
<text class="sheet-close" bind:tap="closeStapleEditor">完成</text>
</view>
<view class="sheet-input-row">
<input class="sheet-input" placeholder="调料名称" value="{{stapleEditValue}}" bind:input="onStapleInput" bind:confirm="addStaple" />
<view class="btn-add" bind:tap="addStaple">添加</view>
</view>
<view class="sheet-tags">
<view class="sheet-tag" wx:for="{{staples}}" wx:key="*this">
<text>{{item}}</text>
<text class="tag-remove" bind:tap="removeStaple" data-name="{{item}}">✕</text>
</view>
</view>
</view>
</view>

View File

@@ -0,0 +1,206 @@
/*
* 冰箱盲盒 · 食材输入
*/
.fridge-page {
padding: var(--space-lg);
min-height: 100vh;
}
/* ── 标题 ── */
.title {
font-size: var(--text-headline);
font-weight: 700;
margin-bottom: var(--space-md);
}
/* ── 输入区 ── */
.input-area {
display: flex;
align-items: center;
margin-bottom: var(--space-lg);
}
.ingredient-input {
flex: 1;
height: 88rpx;
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 0 var(--space-md);
font-size: var(--text-body);
box-shadow: var(--shadow-sm);
margin-right: var(--space-sm);
}
.voice-btn {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
border-radius: var(--radius-lg);
font-size: 36rpx;
transition: background 0.15s ease;
}
.voice-btn:active {
opacity: 0.8;
}
.voice-btn.recording {
background: #E53935;
animation: voicePulse 0.6s ease-in-out infinite alternate;
}
@keyframes voicePulse {
from { transform: scale(1); }
to { transform: scale(1.08); }
}
/* ── 分区 ── */
.section {
margin-bottom: var(--space-lg);
}
.section-title {
font-size: var(--text-body-sm);
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: var(--space-sm);
display: flex;
align-items: center;
}
.tag-list {
display: flex;
flex-wrap: wrap;
}
/* ── 常备调料 ── */
.staple-tag {
display: inline-flex;
padding: 8rpx var(--space-md);
border-radius: var(--radius-full);
background: #F5F2EE;
font-size: var(--text-body-sm);
color: var(--color-text-muted);
margin: 4rpx 8rpx;
}
.edit-link {
font-size: var(--text-body-sm);
color: var(--color-primary);
font-weight: 500;
margin-left: 8rpx;
}
/* ── 开盒按钮 ── */
.btn-large {
width: 100%;
height: 100rpx;
font-size: var(--text-body);
margin-top: var(--space-xl);
}
/* ── 调料编辑弹窗 ── */
.modal-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 100;
}
.modal-sheet {
position: fixed;
left: 0; right: 0; bottom: 0;
background: var(--color-surface);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
padding: var(--space-lg);
padding-bottom: calc(var(--space-lg) + constant(safe-area-inset-bottom));
padding-bottom: calc(var(--space-lg) + env(safe-area-inset-bottom));
z-index: 101;
transform: translateY(100%);
transition: transform 0.3s ease;
}
.modal-sheet.show {
transform: translateY(0);
}
.sheet-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-md);
}
.sheet-title {
font-size: var(--text-subtitle);
font-weight: 700;
}
.sheet-close {
font-size: var(--text-body);
color: var(--color-primary);
font-weight: 600;
}
.sheet-input-row {
display: flex;
align-items: center;
margin-bottom: var(--space-md);
}
.sheet-input {
flex: 1;
height: 80rpx;
background: #F7F5F2;
border-radius: var(--radius-md);
padding: 0 var(--space-md);
font-size: var(--text-body);
margin-right: var(--space-sm);
}
.btn-add {
width: 120rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
color: #FFFFFF;
border-radius: var(--radius-md);
font-size: var(--text-body-sm);
font-weight: 600;
}
.btn-add:active {
opacity: 0.8;
}
.sheet-tags {
display: flex;
flex-wrap: wrap;
max-height: 360rpx;
overflow-y: auto;
}
.sheet-tag {
display: flex;
align-items: center;
padding: 8rpx var(--space-md);
background: #F7F5F2;
border-radius: var(--radius-full);
font-size: var(--text-body-sm);
color: var(--color-text);
margin-right: 12rpx;
margin-bottom: 12rpx;
}
.tag-remove {
margin-left: 8rpx;
font-size: var(--text-caption);
color: var(--color-text-muted);
padding: 2rpx;
}

View File

@@ -0,0 +1,89 @@
const app = getApp();
const tips = [
'今天宜尝试酸辣口味',
'唯有美食与爱不可辜负',
'厨房里的秘密,全在一勺之间',
'一日三餐,每一餐都值得认真对待',
'今天适合来点甜的犒劳自己',
'好心情从一顿好饭开始',
'美食是治愈一切的力量'
];
Page({
data: {
greeting: '',
locationText: '点击获取位置',
dailyTip: ''
},
onShow() {
this.updateGreeting();
this.updateLocation();
this.setData({ dailyTip: tips[Math.floor(Math.random() * tips.length)] });
},
updateGreeting() {
const h = new Date().getHours();
let g = '晚上好';
if (h < 9) g = '早上好';
else if (h < 12) g = '上午好';
else if (h < 14) g = '中午好';
else if (h < 18) g = '下午好';
this.setData({ greeting: g });
},
updateLocation() {
const loc = app.globalData.location;
if (loc) {
this.setData({
locationText: `已定位 (${loc.latitude.toFixed(2)}, ${loc.longitude.toFixed(2)})`
});
} else {
// 尝试获取定位
this.requestLocation();
}
},
requestLocation() {
app.getLocation((err, loc) => {
if (loc) {
this.setData({
locationText: `已定位 (${loc.latitude.toFixed(2)}, ${loc.longitude.toFixed(2)})`
});
} else {
this.setData({ locationText: '点击选择位置' });
}
});
},
chooseLocation() {
wx.chooseLocation({
success: (res) => {
app.globalData.location = {
latitude: res.latitude,
longitude: res.longitude
};
this.setData({
locationText: `已定位 (${res.latitude.toFixed(2)}, ${res.longitude.toFixed(2)})`
});
}
});
},
onOpenBox(e) {
// 开盒前检查定位
if (!app.globalData.location) {
wx.showToast({ title: '请先获取位置', icon: 'none' });
this.requestLocation();
return;
}
const type = e.detail.type;
const routes = {
takeout: '/pages/takeout-result/takeout-result',
fridge: '/pages/fridge-input/fridge-input',
explore: '/pages/explore-result/explore-result'
};
wx.navigateTo({ url: routes[type] || routes.takeout });
}
});

View File

@@ -0,0 +1,6 @@
{
"usingComponents": {
"mode-card": "/components/mode-card/mode-card"
},
"navigationBarTitleText": "吃啥盲盒"
}

View File

@@ -0,0 +1,41 @@
<view class="home">
<view class="header">
<view class="greeting">{{greeting}}</view>
<view class="location-badge" bind:tap="chooseLocation">{{locationText}}</view>
</view>
<view class="hero">
<view class="hero-emoji">🎁</view>
<view class="hero-title">今天吃啥?</view>
<view class="hero-subtitle">开个盲盒</view>
</view>
<view class="modes">
<mode-card
type="takeout"
icon="🛵"
title="外卖盲盒"
desc="随机高质量外卖"
actionText="开个外卖盒"
bind:open="onOpenBox"
/>
<mode-card
type="fridge"
icon="🥬"
title="冰箱盲盒"
desc="有啥食材做啥菜"
actionText="开个冰箱盒"
bind:open="onOpenBox"
/>
<mode-card
type="explore"
icon="🍱"
title="探店盲盒"
desc="发现隐藏好店"
actionText="开个附近盒"
bind:open="onOpenBox"
/>
</view>
<view class="daily-tip">{{dailyTip}}</view>
</view>

View File

@@ -0,0 +1,85 @@
/*
* 首页 · 盲盒大厅
* 空间节奏32 → 48 → 48 → 64
*/
.home {
padding: var(--space-lg) var(--space-lg);
min-height: 100vh;
}
/* ── 顶部 ── */
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-xl);
}
.greeting {
font-size: var(--text-body);
font-weight: 500;
color: var(--color-text-secondary);
}
.location-badge {
display: flex;
align-items: center;
font-size: var(--text-body-sm);
color: var(--color-primary);
padding: var(--space-xs) var(--space-sm);
background: var(--color-primary-pale);
border-radius: var(--radius-full);
}
.location-badge:active {
opacity: 0.7;
}
/* ── 主视觉 ── */
.hero {
text-align: center;
margin-bottom: var(--space-xl);
}
.hero-emoji {
display: inline-block;
font-size: 88rpx;
margin-bottom: var(--space-sm);
animation: hero-float 3s ease-in-out infinite;
}
@keyframes hero-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10rpx); }
}
.hero-title {
font-size: 44rpx;
font-weight: 700;
color: var(--color-text);
letter-spacing: 2rpx;
margin-bottom: var(--space-xs);
}
.hero-subtitle {
font-size: var(--text-subtitle);
color: var(--color-primary);
font-weight: 500;
}
/* ── 卡片列表 ── */
.modes {
display: flex;
flex-direction: column;
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
/* ── 每日一言 ── */
.daily-tip {
text-align: center;
font-size: var(--text-body-sm);
color: var(--color-text-muted);
padding: var(--space-lg) 0;
}

View File

@@ -0,0 +1,45 @@
const storage = require('../../utils/storage');
Page({
data: {
avatarUrl: '',
nickname: '',
prefs: { taste: '都可以', priceRange: 'all', allergies: '' },
shoppingList: []
},
onShow() {
const prefs = storage.get('user_prefs', { taste: '都可以', priceRange: 'all', allergies: '' });
const shoppingList = storage.get('shopping_list', []);
this.setData({ prefs, shoppingList });
},
setPref(e) {
const { key, val } = e.currentTarget.dataset;
const prefs = { ...this.data.prefs, [key]: val };
this.setData({ prefs });
storage.set('user_prefs', prefs);
},
setAllergies(e) {
const prefs = { ...this.data.prefs, allergies: e.detail.value };
this.setData({ prefs });
storage.set('user_prefs', prefs);
},
toggleItem(e) {
const idx = e.currentTarget.dataset.index;
const list = this.data.shoppingList;
list[idx].checked = !list[idx].checked;
this.setData({ shoppingList: list });
storage.set('shopping_list', list);
},
clearShopping() {
wx.showModal({
title: '清空清单',
content: '确定清空所有购物清单吗?',
success: (res) => {
if (res.confirm) {
this.setData({ shoppingList: [] });
storage.set('shopping_list', []);
}
}
});
}
});

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "我的"
}

View File

@@ -0,0 +1,48 @@
<view class="mine-page">
<view class="profile">
<image class="avatar" src="{{avatarUrl}}" mode="aspectFill" wx:if="{{avatarUrl}}"></image>
<view class="avatar-placeholder" wx:else>👤</view>
<view class="nickname">{{nickname || '点击登录'}}</view>
</view>
<view class="card-section">
<view class="card-title">口味偏好</view>
<view class="tag-row">
<view class="pref-tag {{prefs.taste === '辣' ? 'active' : ''}}" data-key="taste" data-val="辣" bind:tap="setPref">🌶️ 辣</view>
<view class="pref-tag {{prefs.taste === '清淡' ? 'active' : ''}}" data-key="taste" data-val="清淡" bind:tap="setPref">🌱 清淡</view>
<view class="pref-tag {{prefs.taste === '酸甜' ? 'active' : ''}}" data-key="taste" data-val="酸甜" bind:tap="setPref">🍋 酸甜</view>
<view class="pref-tag {{prefs.taste === '都可以' ? 'active' : ''}}" data-key="taste" data-val="都可以" bind:tap="setPref">😋 都可以</view>
</view>
</view>
<view class="card-section">
<view class="card-title">价格区间(外卖)</view>
<view class="tag-row">
<view class="pref-tag {{prefs.priceRange === 'low' ? 'active' : ''}}" data-key="priceRange" data-val="low" bind:tap="setPref">¥ 人均&lt;30</view>
<view class="pref-tag {{prefs.priceRange === 'medium' ? 'active' : ''}}" data-key="priceRange" data-val="medium" bind:tap="setPref">¥ 人均30-80</view>
<view class="pref-tag {{prefs.priceRange === 'high' ? 'active' : ''}}" data-key="priceRange" data-val="high" bind:tap="setPref">¥¥ 人均&gt;80</view>
<view class="pref-tag {{prefs.priceRange === 'all' ? 'active' : ''}}" data-key="priceRange" data-val="all" bind:tap="setPref">都行</view>
</view>
</view>
<view class="card-section">
<view class="card-title">忌口/过敏</view>
<input class="allergy-input" placeholder="如:海鲜、花生、牛奶(逗号分隔)" value="{{prefs.allergies}}" bind:blur="setAllergies" />
</view>
<view class="card-section" wx:if="{{shoppingList.length}}">
<view class="card-title">
购物清单 ({{shoppingList.length}}项)
<text class="clear-btn" bind:tap="clearShopping">清空</text>
</view>
<view class="shop-item {{item.checked ? 'checked' : ''}}" wx:for="{{shoppingList}}" wx:key="name" bind:tap="toggleItem" data-index="{{index}}">
<view class="shop-check">{{item.checked ? '✅' : '⬜'}}</view>
<view class="shop-info">
<view class="shop-name">{{item.name}} <text class="shop-amount">{{item.amount}}</text></view>
<view class="shop-from">来自:{{item.from}}</view>
</view>
</view>
</view>
<view class="about">吃啥盲盒 ChowBox v1.0.0</view>
</view>

View File

@@ -0,0 +1,158 @@
/*
* 我的
*/
.mine-page {
padding: var(--space-xl) var(--space-lg);
min-height: 100vh;
}
/* ── 个人资料 ── */
.profile {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: var(--space-xl);
}
.avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: #F0EDE8;
}
.avatar-placeholder {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: #F5F2EE;
display: flex;
align-items: center;
justify-content: center;
font-size: 56rpx;
}
.nickname {
font-size: var(--text-subtitle);
font-weight: 600;
color: var(--color-text);
margin-top: var(--space-sm);
}
/* ── 卡片 ── */
.card-section {
padding: var(--space-md);
margin-bottom: var(--space-sm);
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.card-title {
font-size: var(--text-body);
font-weight: 600;
color: var(--color-text);
margin-bottom: var(--space-sm);
}
/* ── 偏好标签 ── */
.tag-row {
display: flex;
flex-wrap: wrap;
}
.pref-tag {
padding: 8rpx var(--space-md);
border-radius: var(--radius-full);
background: #F5F2EE;
font-size: var(--text-body-sm);
color: var(--color-text-secondary);
margin-right: 12rpx;
margin-bottom: 12rpx;
transition: all 0.2s var(--ease-out);
}
.pref-tag:active {
transform: scale(0.95);
}
.pref-tag.active {
background: var(--color-primary-pale);
color: var(--color-primary);
font-weight: 600;
}
/* ── 过敏原输入 ── */
.allergy-input {
width: 100%;
font-size: var(--text-body-sm);
padding: 12rpx 0;
color: var(--color-text);
border-bottom: 1rpx solid var(--color-hairline);
}
/* ── 清空按钮 ── */
.clear-btn {
font-size: var(--text-body-sm);
color: var(--color-primary);
float: right;
font-weight: 500;
}
/* ── 购物清单 ── */
.shop-item {
display: flex;
align-items: center;
padding: 14rpx 0;
border-bottom: 1rpx solid var(--color-hairline);
}
.shop-item:last-child {
border-bottom: none;
}
.shop-item:active {
opacity: 0.6;
}
.shop-item.checked .shop-name {
text-decoration: line-through;
color: var(--color-text-muted);
}
.shop-check {
font-size: 32rpx;
margin-right: var(--space-sm);
}
.shop-info {
flex: 1;
min-width: 0;
}
.shop-name {
font-size: var(--text-body);
font-weight: 500;
color: var(--color-text);
}
.shop-amount {
font-size: var(--text-body-sm);
color: var(--color-text-muted);
}
.shop-from {
font-size: var(--text-caption);
color: var(--color-text-muted);
margin-top: 2rpx;
}
/* ── 版本 ── */
.about {
text-align: center;
font-size: var(--text-body-sm);
color: var(--color-text-muted);
margin-top: var(--space-2xl);
padding-bottom: var(--space-xl);
}

View File

@@ -0,0 +1,96 @@
const api = require('../../utils/api');
const storage = require('../../utils/storage');
Page({
data: { loading: true, recipe: null, missingIngredients: [] },
onLoad(options) {
if (options.id) {
api.get('/api/recipe/' + options.id).then(data => {
this.setData({ loading: false, recipe: data });
}).catch(() => {
this.setData({ loading: false });
wx.showToast({ title: '加载失败', icon: 'none' });
});
}
if (options.missing) {
try {
this.setData({ missingIngredients: JSON.parse(decodeURIComponent(options.missing)) });
} catch (e) {
// ignore
}
}
// 启用分享
wx.showShareMenu({
withShareTicket: false,
menus: ['shareAppMessage', 'shareTimeline']
});
},
onShareAppMessage() {
const recipe = this.data.recipe;
if (!recipe) return {};
return {
title: '我用冰箱剩菜做出了「' + recipe.name + '」!你也来试试?',
path: '/pages/recipe-detail/recipe-detail?id=' + recipe.id,
imageUrl: recipe.imageUrl || ''
};
},
onShareTimeline() {
const recipe = this.data.recipe;
if (!recipe) return {};
return {
title: recipe.name + ' · 冰箱盲盒开出的好菜',
query: 'id=' + recipe.id,
imageUrl: recipe.imageUrl || ''
};
},
startTimer() {
wx.showToast({ title: '计时器功能开发中', icon: 'none' });
},
addToShopping() {
const recipe = this.data.recipe;
if (!recipe || !recipe.ingredients) return;
const raw = storage.get('shopping_list', []);
const existing = Array.isArray(raw) ? raw : [];
const names = new Set(existing.map(i => i.name));
// 只添加"缺少的食材";若无缺失信息则提示而非加全部
const missing = this.data.missingIngredients;
if (!Array.isArray(missing) || missing.length === 0) {
wx.showToast({ title: '该菜谱食材已齐全', icon: 'none' });
return;
}
const targetNames = new Set(missing);
let added = 0;
recipe.ingredients.forEach(ing => {
if (!targetNames.has(ing.ingredientName)) return;
if (names.has(ing.ingredientName)) return;
existing.push({
name: ing.ingredientName,
amount: ing.amount || '',
from: recipe.name,
checked: false
});
names.add(ing.ingredientName);
added++;
});
storage.set('shopping_list', existing);
if (added === 0) {
wx.showToast({ title: '缺少食材已在清单中', icon: 'none' });
} else {
wx.showToast({ title: '已添加 ' + added + ' 项缺少食材', icon: 'success' });
}
}
});

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "菜谱详情"
}

View File

@@ -0,0 +1,43 @@
<view class="recipe-detail" wx:if="{{recipe}}">
<image class="hero-img" src="{{recipe.imageUrl}}" mode="aspectFill" wx:if="{{recipe.imageUrl}}"></image>
<view class="hero-placeholder" wx:else>🍲</view>
<view class="detail-header">
<view class="detail-name">{{recipe.name}}</view>
<view class="detail-meta">
<text>⭐ 难度{{recipe.difficulty}}</text>
<text>⏱️ {{recipe.cookTime}}分钟</text>
<text>{{recipe.category}}</text>
</view>
</view>
<view class="section">
<view class="section-title">用料</view>
<view class="ingredient-list">
<view class="ing-item {{item.isStaple ? 'staple' : ''}}" wx:for="{{recipe.ingredients}}" wx:key="id">
<text>{{item.ingredientName}}</text>
<text class="amount">{{item.amount}}</text>
</view>
</view>
</view>
<view class="section">
<view class="section-title">步骤</view>
<view class="step-list">
<view class="step-item" wx:for="{{recipe.steps}}" wx:key="id">
<view class="step-num">{{item.stepOrder}}</view>
<view class="step-body">
<view class="step-text">{{item.content}}</view>
<image class="step-img" src="{{item.imageUrl}}" mode="widthFix" wx:if="{{item.imageUrl}}"></image>
</view>
</view>
</view>
</view>
<view class="bottom-bar">
<button class="btn-primary btn-small" bind:tap="startTimer">计时器</button>
<view class="btn-outline" bind:tap="addToShopping">加入购物清单</view>
</view>
</view>
<view class="center" wx:if="{{loading}}">加载中…</view>

View File

@@ -0,0 +1,196 @@
/*
* 菜谱详情
*/
.recipe-detail {
padding-bottom: 140rpx;
}
/* ── 主图 ── */
.hero-img {
width: 100%;
height: 400rpx;
background: #F2EFEB;
}
.hero-placeholder {
width: 100%;
height: 300rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 100rpx;
background: linear-gradient(180deg, #FFF8F2, #FFF0E4);
}
/* ── 头部 ── */
.detail-header {
padding: var(--space-md) var(--space-lg) var(--space-sm);
}
.detail-name {
font-size: var(--text-headline);
font-weight: 700;
color: var(--color-text);
margin-bottom: 8rpx;
}
.detail-meta {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: var(--text-body-sm);
color: var(--color-text-muted);
}
.detail-meta text {
display: inline-flex;
align-items: center;
padding: 4rpx 14rpx;
background: #F5F2EE;
border-radius: var(--radius-full);
}
/* ── 分区 ── */
.section {
padding: 0 var(--space-lg);
margin-bottom: var(--space-lg);
}
.section-title {
font-size: var(--text-body);
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-sm);
}
/* ── 用料 ── */
.ingredient-list {
display: flex;
flex-wrap: wrap;
}
.ing-item {
display: flex;
align-items: center;
padding: 8rpx var(--space-md);
background: #F7F5F2;
border-radius: var(--radius-full);
font-size: var(--text-body-sm);
color: var(--color-text);
margin-right: 12rpx;
margin-bottom: 12rpx;
}
.ing-item.staple {
opacity: 0.4;
}
.amount {
color: var(--color-text-muted);
margin-left: 8rpx;
font-size: var(--text-caption);
}
/* ── 步骤 ── */
.step-list {
display: flex;
flex-direction: column;
}
.step-item {
display: flex;
align-items: flex-start;
margin-bottom: var(--space-sm);
}
.step-item:last-child {
margin-bottom: 0;
}
.step-num {
width: 48rpx;
height: 48rpx;
background: var(--color-primary);
color: #FFFFFF;
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-body-sm);
font-weight: 700;
flex-shrink: 0;
margin-right: var(--space-sm);
}
.step-body {
flex: 1;
min-width: 0;
}
.step-text {
font-size: var(--text-body);
line-height: 1.7;
color: var(--color-text);
padding-top: 6rpx;
}
.step-img {
width: 100%;
border-radius: var(--radius-md);
margin-top: var(--space-sm);
background: #F2EFEB;
}
/* ── 底部栏 ── */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
padding: var(--space-sm) var(--space-lg);
padding-bottom: calc(var(--space-sm) + constant(safe-area-inset-bottom));
padding-bottom: calc(var(--space-sm) + env(safe-area-inset-bottom));
background: var(--color-surface);
box-shadow: 0 -1rpx 8rpx rgba(0, 0, 0, 0.04);
z-index: 10;
}
.btn-small {
flex: 1;
height: 80rpx;
font-size: var(--text-body-sm);
margin-right: var(--space-sm);
}
.btn-small:last-child {
margin-right: 0;
}
.btn-outline {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
height: 80rpx;
border: 1rpx solid var(--color-hairline);
color: var(--color-text);
background: var(--color-surface);
border-radius: var(--radius-lg);
font-size: var(--text-body-sm);
font-weight: 500;
transition: background 0.15s ease;
}
.btn-outline:active {
background: #FAFAF8;
}
/* ── 加载 ── */
.center {
padding: 160rpx 0;
text-align: center;
color: var(--color-text-muted);
font-size: var(--text-body);
}

View File

@@ -0,0 +1,82 @@
const api = require('../../utils/api');
Page({
data: {
showAnimation: false,
dataReady: false,
loading: false,
results: [],
ingredients: []
},
onLoad(options) {
if (options.payload) {
try {
const payload = JSON.parse(decodeURIComponent(options.payload));
const ingredients = payload.ingredients || [];
const staples = payload.staples || [];
this.setData({ ingredients });
this.match(ingredients, staples);
} catch (e) {
wx.showToast({ title: '参数错误', icon: 'none' });
}
}
},
match(ingredients, staples) {
this.setData({
showAnimation: true,
dataReady: false,
results: []
});
api.post('/api/fridge/match', { ingredients: ingredients, staples: staples })
.then((data) => {
this.setData({ results: data, dataReady: true });
if (data.length > 0) {
const history = wx.getStorageSync('box_history') || [];
history.push({
id: Date.now().toString(),
icon: '🥬',
name: data[0].recipe.name + ' (' + data[0].matchRate + '%匹配)',
time: new Date().toLocaleString(),
typeName: '冰箱盲盒'
});
wx.setStorageSync('box_history', history);
}
})
.catch(() => {
this.setData({
showAnimation: false,
results: [],
loading: false
});
wx.showToast({ title: '匹配失败,请重试', icon: 'none' });
});
},
onAnimationDone() {
this.setData({ showAnimation: false });
},
goDetail(e) {
const id = e.currentTarget.dataset.id;
let missing = e.currentTarget.dataset.missing;
// dataset 值可能是字符串,需要解析
if (typeof missing === 'string') {
try { missing = JSON.parse(missing); } catch (e) { missing = []; }
}
if (!Array.isArray(missing)) missing = [];
let url = '/pages/recipe-detail/recipe-detail?id=' + id;
if (missing.length > 0) {
url += '&missing=' + encodeURIComponent(JSON.stringify(missing));
}
wx.navigateTo({ url: url });
},
reshuffle() {
this.match(this.data.ingredients);
}
});

View File

@@ -0,0 +1,6 @@
{
"usingComponents": {
"box-animation": "/components/box-animation/box-animation"
},
"navigationBarTitleText": "菜谱推荐"
}

View File

@@ -0,0 +1,42 @@
<view class="recipe-list-page">
<box-animation
show="{{showAnimation}}"
boxType="fridge"
dataReady="{{dataReady}}"
bind:done="onAnimationDone"
>
<view class="animation-result-preview" wx:if="{{results.length > 0}}">
<view class="preview-emoji">🥬</view>
<view class="preview-text">{{results[0].recipe.name}}</view>
<view class="preview-sub">匹配度 {{results[0].matchRate}}%</view>
</view>
</box-animation>
<view class="list-header" wx:if="{{!showAnimation && results.length}}">
为你找到 {{results.length}} 个菜谱
</view>
<view class="recipe-cards" wx:if="{{!showAnimation}}">
<view class="recipe-card card" wx:for="{{results}}" wx:key="recipe.id" bind:tap="goDetail" data-id="{{item.recipe.id}}" data-missing="{{item.missingIngredients}}">
<view class="card-left">
<view class="recipe-name">{{item.recipe.name}}</view>
<view class="recipe-meta">
<text wx:if="{{item.recipe.difficulty}}">⭐{{item.recipe.difficulty}}</text>
<text wx:if="{{item.recipe.cookTime}}"> ⏱️{{item.recipe.cookTime}}分钟</text>
</view>
<view class="match-rate">
<view class="rate-bar">
<view class="rate-fill" style="width:{{item.matchRate}}%"></view>
</view>
<text>{{item.matchRate}}%</text>
</view>
<view class="missing" wx:if="{{item.missingIngredients.length}}">
缺:<text wx:for="{{item.missingIngredients}}" wx:key="*this">{{item}} </text>
</view>
</view>
</view>
</view>
<view class="center" wx:if="{{!showAnimation && results.length === 0}}">没有匹配的菜谱,试试换个食材组合</view>
<view class="btn-retry" wx:if="{{!showAnimation && results.length}}" bind:tap="reshuffle">换一批菜谱</view>
</view>

View File

@@ -0,0 +1,133 @@
/*
* 菜谱推荐列表
*/
.recipe-list-page {
padding: var(--space-md) var(--space-lg);
min-height: 100vh;
}
/* ── 页头 ── */
.list-header {
font-size: var(--text-body);
font-weight: 600;
color: var(--color-text);
margin-bottom: var(--space-md);
}
/* ── 卡片 ── */
.recipe-cards {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.recipe-card {
padding: var(--space-md);
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
transition: transform 0.15s ease;
}
.recipe-card:active {
transform: scale(0.985);
}
/* ── 内容 ── */
.recipe-name {
font-size: var(--text-subtitle);
font-weight: 700;
color: var(--color-text);
margin-bottom: 4rpx;
}
.recipe-meta {
font-size: var(--text-body-sm);
color: var(--color-text-muted);
margin-bottom: var(--space-sm);
}
/* ── 匹配度条 ── */
.match-rate {
display: flex;
align-items: center;
margin-bottom: 8rpx;
font-size: var(--text-body-sm);
font-weight: 600;
color: var(--color-text);
}
.rate-bar {
flex: 1;
height: 6rpx;
background: #F0EDE8;
border-radius: 3rpx;
overflow: hidden;
margin-right: 12rpx;
}
.rate-fill {
height: 100%;
background: var(--color-green);
border-radius: 3rpx;
transition: width 0.5s ease;
}
/* ── 缺少食材 ── */
.missing {
font-size: var(--text-caption);
color: #E8A040;
margin-top: 8rpx;
}
/* ── 空态 ── */
.center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
font-size: var(--text-body);
color: var(--color-text-muted);
}
/* ── 换一批 ── */
.btn-retry {
text-align: center;
font-size: var(--text-body);
font-weight: 500;
color: var(--color-primary);
padding: var(--space-md);
}
.btn-retry:active {
opacity: 0.6;
}
/* ── 动效内预览 ── */
.animation-result-preview {
display: flex;
flex-direction: column;
align-items: center;
padding: 24rpx;
}
.preview-emoji {
font-size: 64rpx;
margin-bottom: 12rpx;
}
.preview-text {
font-size: var(--text-subtitle);
font-weight: 700;
color: #FFFFFF;
text-align: center;
}
.preview-sub {
font-size: var(--text-body-sm);
color: rgba(255, 255, 255, 0.8);
margin-top: 8rpx;
}

View File

@@ -0,0 +1,8 @@
Page({
data: { items: [] },
onShow() { this.loadRecords(); },
loadRecords() {
const history = wx.getStorageSync('box_history') || [];
this.setData({ items: history.reverse() });
}
});

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "开盒记录"
}

View File

@@ -0,0 +1,18 @@
<view class="records-page">
<view class="empty" wx:if="{{items.length === 0}}">
<view class="empty-icon">📜</view>
<view>还没有开盒记录</view>
<view class="sub">去首页开个盲盒吧</view>
</view>
<view class="record-list" wx:else>
<view class="record-item" wx:for="{{items}}" wx:key="id">
<view class="record-icon">{{item.icon}}</view>
<view class="record-info">
<view class="record-name">{{item.name}}</view>
<view class="record-time">{{item.time}}</view>
</view>
<view class="record-type tag tag-orange">{{item.typeName}}</view>
</view>
</view>
</view>

View File

@@ -0,0 +1,81 @@
/*
* 开盒记录
*/
.records-page {
padding: var(--space-md) var(--space-lg);
min-height: 100vh;
}
/* ── 空态 ── */
.empty {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 220rpx;
font-size: var(--text-body);
color: var(--color-text-muted);
}
.empty-icon {
font-size: 80rpx;
margin-bottom: var(--space-md);
opacity: 0.5;
}
.sub {
font-size: var(--text-body-sm);
color: var(--color-text-muted);
margin-top: 8rpx;
}
/* ── 列表 ── */
.record-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.record-item {
display: flex;
align-items: center;
padding: var(--space-md);
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
transition: transform 0.15s var(--ease-out);
}
.record-item:active {
transform: scale(0.985);
}
.record-icon {
font-size: 44rpx;
margin-right: var(--space-sm);
opacity: 0.8;
}
.record-info {
flex: 1;
min-width: 0;
}
.record-name {
font-size: var(--text-body);
font-weight: 600;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.record-time {
font-size: var(--text-caption);
color: var(--color-text-muted);
margin-top: 4rpx;
}
.record-type {
flex-shrink: 0;
}

View File

@@ -0,0 +1,138 @@
const app = getApp();
const api = require('../../utils/api');
const loc = require('../../utils/location');
const storage = require('../../utils/storage');
Page({
data: {
showAnimation: false,
dataReady: false,
result: null,
error: '',
distanceText: '',
deliveryInfo: ''
},
onLoad() {
this.roll();
},
roll() {
this.setData({
showAnimation: true,
dataReady: false,
error: '',
result: null
});
const prefs = storage.get('user_prefs', {});
loc.getLocation().then((pos) => {
return api.post('/api/takeout/roll', {
latitude: pos.latitude,
longitude: pos.longitude,
openid: 'anonymous',
taste: prefs.taste || '都可以',
priceRange: prefs.priceRange || 'all',
allergies: prefs.allergies || ''
});
}).then((data) => {
const distKm = ((data.distance || 0) / 1000).toFixed(1);
const deliveryTime = data.deliveryTime || '';
const minOrder = data.minOrder || '';
const deliveryFee = data.deliveryFee ? ('配送费' + data.deliveryFee) : '';
const parts = [deliveryTime, minOrder, deliveryFee].filter(Boolean);
this.setData({
result: data,
distanceText: distKm + 'km',
deliveryInfo: parts.join(' · '),
dataReady: true
});
}).catch((err) => {
this.setData({
showAnimation: false,
error: err.message || '附近喵星人占领了,换片区域试试?'
});
});
},
onAnimationDone() {
this.setData({ showAnimation: false });
},
goOrder() {
const shop = this.data.result;
if (!shop) return;
const shopName = shop.name || '';
const shopAddress = shop.address || '';
const lat = app.globalData.location ? app.globalData.location.latitude : 0;
const lng = app.globalData.location ? app.globalData.location.longitude : 0;
wx.showActionSheet({
itemList: ['美团外卖', '饿了么', '查看地图位置'],
success: (res) => {
if (res.tapIndex === 0) {
this.openMeituan(shopName);
} else if (res.tapIndex === 1) {
this.openEleme(shopName);
} else if (res.tapIndex === 2) {
wx.openLocation({
latitude: shop.latitude || lat,
longitude: shop.longitude || lng,
name: shopName,
address: shopAddress,
scale: 16
});
}
},
fail: () => {
this.openMeituan(shopName);
}
});
},
openMeituan(name) {
wx.navigateToMiniProgram({
appId: 'wxde8ac0a21135c07d',
path: '',
extraData: { query: name },
success: () => {
this.saveRecord(this.data.result);
},
fail: () => {
wx.showToast({ title: '请安装美团外卖 APP', icon: 'none', duration: 2000 });
}
});
},
openEleme(name) {
wx.navigateToMiniProgram({
appId: 'wxece3a9a4c82f58c9',
path: '',
extraData: { query: name },
success: () => {
this.saveRecord(this.data.result);
},
fail: () => {
wx.showToast({ title: '请安装饿了么 APP', icon: 'none', duration: 2000 });
}
});
},
retry() {
this.roll();
},
saveRecord(data) {
const history = wx.getStorageSync('box_history') || [];
history.push({
id: Date.now().toString(),
icon: '🛵',
name: data.name,
time: new Date().toLocaleString(),
typeName: '外卖盲盒'
});
wx.setStorageSync('box_history', history);
}
});

View File

@@ -0,0 +1,7 @@
{
"usingComponents": {
"result-card": "/components/result-card/result-card",
"box-animation": "/components/box-animation/box-animation"
},
"navigationBarTitleText": "外卖盲盒"
}

View File

@@ -0,0 +1,41 @@
<view class="takeout-page">
<!-- 错误态 -->
<view class="empty" wx:if="{{error}}">
<view class="empty-icon">😿</view>
<view>{{error}}</view>
<button class="btn-primary" style="margin-top:32rpx" bind:tap="retry">换个区域试试</button>
</view>
<!-- 结果区(动效结束后展示) -->
<view class="result-area" wx:if="{{!showAnimation && result}}">
<view class="result-title">🎉 你抽到的外卖是…</view>
<result-card
imageUrl="{{result.imageUrl}}"
name="{{result.name}}"
rating="{{result.rating}}"
avgPrice="{{result.avgPrice}}"
distance="{{distanceText}}"
extra="{{deliveryInfo}}"
recommendReason="{{result.recommendReason}}"
signatureDishes="{{result.signatureDishes}}"
/>
<view class="actions">
<button class="btn-primary btn-main" bind:tap="goOrder">去下单</button>
<view class="btn-retry" bind:tap="retry">再开一个</view>
</view>
</view>
<!-- 开盒动效覆盖层 -->
<box-animation
show="{{showAnimation}}"
boxType="takeout"
dataReady="{{dataReady}}"
bind:done="onAnimationDone"
>
<view class="animation-result-preview" wx:if="{{result}}">
<view class="preview-emoji">🛵</view>
<view class="preview-text">{{result.name}}</view>
<view class="preview-sub" wx:if="{{result.rating}}">⭐ {{result.rating}}</view>
</view>
</box-animation>
</view>

View File

@@ -0,0 +1,93 @@
/*
* 外卖结果页
* 流程:动效覆盖层(加载+揭晓)→ 结果展示
*/
.takeout-page {
padding: var(--space-md);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
/* ── 结果区(动效结束后) ── */
.result-area {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.result-title {
font-size: var(--text-subtitle);
font-weight: 600;
color: var(--color-text);
margin: var(--space-md) 0 var(--space-md);
text-align: center;
}
.actions {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
margin-top: var(--space-lg);
}
.btn-main {
width: 80%;
}
.btn-retry {
margin-top: var(--space-md);
font-size: var(--text-body-sm);
color: var(--color-text-muted);
padding: var(--space-sm);
}
.btn-retry:active {
color: var(--color-primary);
}
/* ── 错误态 ── */
.empty {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 200rpx;
font-size: var(--text-body);
color: var(--color-text-secondary);
}
.empty-icon {
font-size: 80rpx;
margin-bottom: var(--space-md);
opacity: 0.6;
}
/* ── 动效内预览Act 3 slot 内容) ── */
.animation-result-preview {
display: flex;
flex-direction: column;
align-items: center;
padding: 24rpx;
}
.preview-emoji {
font-size: 64rpx;
margin-bottom: 12rpx;
}
.preview-text {
font-size: var(--text-subtitle);
font-weight: 700;
color: #FFFFFF;
text-align: center;
}
.preview-sub {
font-size: var(--text-body-sm);
color: rgba(255, 255, 255, 0.8);
margin-top: 8rpx;
}

View File

@@ -0,0 +1,44 @@
{
"description": "吃啥盲盒 ChowBox",
"packOptions": {
"ignore": [],
"include": []
},
"setting": {
"urlCheck": true,
"es6": true,
"enhance": true,
"postcss": true,
"preloadBackgroundData": false,
"minified": true,
"newFeature": true,
"coverView": true,
"nodeModules": false,
"autoAudits": false,
"showShadowRootInWxmlPanel": true,
"scopeDataCheck": false,
"uglifyFileName": false,
"checkInvalidKey": true,
"checkSiteMap": true,
"uploadWithSourceMap": true,
"compileHotReLoad": false,
"lazyloadPlaceholderEnable": false,
"useMultiFrameRuntime": true,
"useApiHook": true,
"useApiHostProcess": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
},
"compileType": "miniprogram",
"libVersion": "3.15.2",
"appid": "wxf634a69e89290cc7",
"projectname": "chowbox",
"condition": {},
"editorSetting": {
"tabIndent": "insertSpaces",
"tabSize": 2
}
}

6
miniapp/sitemap.json Normal file
View File

@@ -0,0 +1,6 @@
{
"rules": [{
"action": "allow",
"page": "*"
}]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
miniapp/static/tab-home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
miniapp/static/tab-mine.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

88
miniapp/utils/api.js Normal file
View File

@@ -0,0 +1,88 @@
const app = getApp();
// 客户端缓存key → { data, expireAt }
const CACHE_PREFIX = 'api_cache_';
const DEFAULT_TTL = 30 * 60 * 1000; // 默认 30 分钟
function request(method, path, data) {
return new Promise((resolve, reject) => {
wx.request({
url: app.globalData.baseUrl + path,
method: method,
data: data,
header: { 'Content-Type': 'application/json' },
success: (res) => {
if (res.statusCode === 200 && res.data.code === 200) {
resolve(res.data.data);
} else {
reject(res.data);
}
},
fail: (err) => {
reject(err);
}
});
});
}
/**
* 带客户端缓存的 GET 请求
* @param {string} path 请求路径
* @param {object} data 请求参数
* @param {number} ttlMs 缓存时长(毫秒),默认 30 分钟
*/
function getWithCache(path, data, ttlMs) {
const cacheKey = CACHE_PREFIX + path + '_' + JSON.stringify(data || {});
const ttl = ttlMs || DEFAULT_TTL;
// 读缓存
try {
const cached = wx.getStorageSync(cacheKey);
if (cached && cached.expireAt > Date.now()) {
return Promise.resolve(cached.data);
}
} catch (e) {
// 读取失败,忽略
}
return request('GET', path, data).then((result) => {
// 写缓存
try {
wx.setStorageSync(cacheKey, {
data: result,
expireAt: Date.now() + ttl
});
} catch (e) {
// 写入失败,忽略(存储满了等)
}
return result;
});
}
/**
* 清除过期缓存
*/
function clearExpiredCache() {
try {
const info = wx.getStorageInfoSync();
const keys = info.keys || [];
const now = Date.now();
keys.forEach((key) => {
if (key.startsWith(CACHE_PREFIX)) {
const cached = wx.getStorageSync(key);
if (cached && cached.expireAt < now) {
wx.removeStorageSync(key);
}
}
});
} catch (e) {
// 静默失败
}
}
module.exports = {
get: (path, data) => request('GET', path, data),
post: (path, data) => request('POST', path, data),
getWithCache,
clearExpiredCache
};

37
miniapp/utils/location.js Normal file
View File

@@ -0,0 +1,37 @@
const app = getApp();
function getLocation() {
return new Promise((resolve, reject) => {
const cached = app.globalData.location;
if (cached) {
resolve(cached);
return;
}
wx.getLocation({
type: 'gcj02',
success: (res) => {
const loc = { latitude: res.latitude, longitude: res.longitude };
app.globalData.location = loc;
resolve(loc);
},
fail: (err) => {
// 常见错误:未授权、隐私协议未同意
const msg = err.errMsg || '';
if (msg.indexOf('auth deny') >= 0 || msg.indexOf('authorize') >= 0) {
wx.showModal({
title: '需要定位权限',
content: '请在设置中允许小程序获取位置,或点击右上角手动选择位置',
confirmText: '去设置',
success: (res) => {
if (res.confirm) wx.openSetting();
}
});
}
reject(new Error('定位失败'));
}
});
});
}
module.exports = { getLocation };

18
miniapp/utils/storage.js Normal file
View File

@@ -0,0 +1,18 @@
function get(key, defaultValue) {
try {
const value = wx.getStorageSync(key);
return value !== '' ? value : defaultValue;
} catch (e) {
return defaultValue;
}
}
function set(key, value) {
wx.setStorageSync(key, value);
}
function remove(key) {
wx.removeStorageSync(key);
}
module.exports = { get, set, remove };