|
@@ -1,7 +1,142 @@
|
|
|
<template>
|
|
<template>
|
|
|
<view class="recommendation-page" :style="{ '--font-scale': fontScale }">
|
|
<view class="recommendation-page" :style="{ '--font-scale': fontScale }">
|
|
|
|
|
|
|
|
- <scroll-view class="content" scroll-y>
|
|
|
|
|
|
|
+ <scroll-view class="content" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
|
|
|
|
|
+ <!-- ===== 推荐食材 ===== -->
|
|
|
|
|
+ <view class="section-title-bar">
|
|
|
|
|
+ <text class="section-title">推荐食材</text>
|
|
|
|
|
+ <text class="section-subtitle">查看家庭成员适合与戒备的食材</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <view class="food-section">
|
|
|
|
|
+ <!-- 成员切换条(统一组件 MemberSelector:家庭总览 / 个人 / 各成员) -->
|
|
|
|
|
+ <MemberSelector
|
|
|
|
|
+ variant="minimal"
|
|
|
|
|
+ v-if="memberList.length > 1"
|
|
|
|
|
+ :members="memberList"
|
|
|
|
|
+ :selected-member-id="currentMemberId"
|
|
|
|
|
+ :show-overview="true"
|
|
|
|
|
+ :overview-active="currentView === 'family'"
|
|
|
|
|
+ @overview="switchView('family')"
|
|
|
|
|
+ @select="switchMember" />
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 模式切换(仅家庭模式展示) -->
|
|
|
|
|
+ <view class="mode-bar" v-if="familyMode">
|
|
|
|
|
+ <view class="mode-btn" :class="{'active': sortType === 'desc'}" @tap="setSort('desc')">
|
|
|
|
|
+ <text>推荐值 ↓</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="mode-btn" :class="{'active': sortType === 'asc'}" @tap="setSort('asc')">
|
|
|
|
|
+ <text>推荐值 ↑</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 搜索栏 -->
|
|
|
|
|
+ <view class="search-bar">
|
|
|
|
|
+ <input class="search-input" placeholder="搜索食材..." v-model="searchQuery" @confirm="searchFood" />
|
|
|
|
|
+ <view class="search-btn" @tap="searchFood">搜索</view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 分类标签 -->
|
|
|
|
|
+ <view class="category-tabs">
|
|
|
|
|
+ <view class="tab-item" :class="{'tab-active': activeCategory === 'all'}" @tap="switchCategory('all')">
|
|
|
|
|
+ <text>全部</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="tab-item" :class="{'tab-active': activeCategory === '蔬菜'}" @tap="switchCategory('蔬菜')">
|
|
|
|
|
+ <text>蔬菜</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="tab-item" :class="{'tab-active': activeCategory === '水果'}" @tap="switchCategory('水果')">
|
|
|
|
|
+ <text>水果</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="tab-item" :class="{'tab-active': activeCategory === '肉禽'}" @tap="switchCategory('肉禽')">
|
|
|
|
|
+ <text>肉禽</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="tab-item" :class="{'tab-active': activeCategory === '水产'}" @tap="switchCategory('水产')">
|
|
|
|
|
+ <text>水产</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="tab-item" :class="{'tab-active': activeCategory === '谷物'}" @tap="switchCategory('谷物')">
|
|
|
|
|
+ <text>谷物</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="tab-item" :class="{'tab-active': activeCategory === '蛋奶'}" @tap="switchCategory('蛋奶')">
|
|
|
|
|
+ <text>蛋奶</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 筛选栏 -->
|
|
|
|
|
+ <view class="filter-bar">
|
|
|
|
|
+ <view class="filter-item" :class="{'filter-active': filterType === 'suitable'}" @tap="setFilter('suitable')">
|
|
|
|
|
+ <text>✅ 适合</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="filter-item" :class="{'filter-active': filterType === 'unsuitable'}" @tap="setFilter('unsuitable')">
|
|
|
|
|
+ <text>❌ 戒备</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="filter-item" :class="{'filter-active': filterType === 'all'}" @tap="setFilter('all')">
|
|
|
|
|
+ <text>📋 全部</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 家庭总览(取家庭成员该项食材推荐指数的最小值) -->
|
|
|
|
|
+ <view class="family-overview" v-if="currentView === 'family' && familyOverview && familyOverview.length > 0">
|
|
|
|
|
+ <view class="overview-title">
|
|
|
|
|
+ <text>🏠 家庭推荐概况(共 {{ familyMembers.length }} 人 · 取家庭最保守值)</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="overview-item" v-for="(item, idx) in familyOverview" :key="idx">
|
|
|
|
|
+ <view class="overview-header">
|
|
|
|
|
+ <text class="food-name">{{ item.name }}</text>
|
|
|
|
|
+ <view class="score-badge" :class="item.color">
|
|
|
|
|
+ <text>{{ item.score }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="overview-detail">
|
|
|
|
|
+ <text class="category-label">{{ item.category }}</text>
|
|
|
|
|
+ <view class="member-scores">
|
|
|
|
|
+ <text v-for="(s, mi) in item.memberScores" :key="mi"
|
|
|
|
|
+ class="member-score"
|
|
|
|
|
+ :class="s.score >= 0 ? 'score-green' : 'score-red'">
|
|
|
|
|
+ {{ s.name }}:{{ s.score >= 0 ? '+' : '' }}{{ s.score }}
|
|
|
|
|
+ </text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 食材列表 -->
|
|
|
|
|
+ <view class="food-list" v-if="currentView === 'personal'">
|
|
|
|
|
+ <view class="food-card" v-for="(item, idx) in filteredFoods" :key="getFoodKey(item, idx)">
|
|
|
|
|
+ <view class="food-left">
|
|
|
|
|
+ <view class="ingredient-badge" :class="item.scoreClass">
|
|
|
|
|
+ <text class="ingredient-badge-text">{{ idx + 1 }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="food-info">
|
|
|
|
|
+ <text class="food-name">{{ item.name }}</text>
|
|
|
|
|
+ <view class="food-meta" v-if="item.category">
|
|
|
|
|
+ <text class="meta-item">{{ item.category }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="food-right">
|
|
|
|
|
+ <text class="score-value" :class="item.scoreClass">{{ item.score || '--' }}</text>
|
|
|
|
|
+ <view class="food-status-badge" :class="'badge-' + item.status">
|
|
|
|
|
+ <text>{{ item.statusText }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <view class="food-empty-state" v-if="!foodLoading && filteredFoods.length === 0 && currentView === 'personal'">
|
|
|
|
|
+ <text class="food-empty-icon">🥗</text>
|
|
|
|
|
+ <text class="food-empty-text">暂无匹配食材</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <view class="loading-state" v-if="foodLoading">
|
|
|
|
|
+ <text class="loading-text">加载中...</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ===== 今日食谱 ===== -->
|
|
|
|
|
+ <view class="section-title-bar">
|
|
|
|
|
+ <text class="section-title">今日食谱</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
<!-- 无推荐状态 -->
|
|
<!-- 无推荐状态 -->
|
|
|
<view class="empty-state" v-if="!recommendation">
|
|
<view class="empty-state" v-if="!recommendation">
|
|
|
<view class="empty-icon-wrap"><text class="empty-icon">🍽️</text></view>
|
|
<view class="empty-icon-wrap"><text class="empty-icon">🍽️</text></view>
|
|
@@ -104,17 +239,35 @@
|
|
|
|
|
|
|
|
<script>
|
|
<script>
|
|
|
import { getDietRecommendation, generateDietRecommendation, completeDietRecommendation, regenerateDietRecommendation } from '@/utils/api.js'
|
|
import { getDietRecommendation, generateDietRecommendation, completeDietRecommendation, regenerateDietRecommendation } from '@/utils/api.js'
|
|
|
|
|
+import config from '@/config.js'
|
|
|
|
|
+import MemberSelector from '@/components/MemberSelector.vue'
|
|
|
|
|
+import { memberIdOf } from '@/utils/memberNormalize.js'
|
|
|
|
|
|
|
|
import fontScaleMixin from '@/mixins/font-scale.js'
|
|
import fontScaleMixin from '@/mixins/font-scale.js'
|
|
|
|
|
|
|
|
export default {
|
|
export default {
|
|
|
mixins: [fontScaleMixin],
|
|
mixins: [fontScaleMixin],
|
|
|
|
|
+ components: { MemberSelector },
|
|
|
data() {
|
|
data() {
|
|
|
return {
|
|
return {
|
|
|
recommendation: null,
|
|
recommendation: null,
|
|
|
meals: [],
|
|
meals: [],
|
|
|
summary: {},
|
|
summary: {},
|
|
|
- loading: false
|
|
|
|
|
|
|
+ loading: false,
|
|
|
|
|
+ // ===== 推荐食材(原「食材查询」页迁移)=====
|
|
|
|
|
+ searchQuery: '',
|
|
|
|
|
+ activeCategory: 'all',
|
|
|
|
|
+ filterType: 'suitable',
|
|
|
|
|
+ sortType: 'desc',
|
|
|
|
|
+ currentView: 'personal',
|
|
|
|
|
+ allFoods: [],
|
|
|
|
|
+ familyData: null,
|
|
|
|
|
+ familyMembers: [],
|
|
|
|
|
+ familyOverview: [],
|
|
|
|
|
+ currentMemberId: null,
|
|
|
|
|
+ memberList: [],
|
|
|
|
|
+ foodLoading: false,
|
|
|
|
|
+ refreshing: false
|
|
|
}
|
|
}
|
|
|
},
|
|
},
|
|
|
computed: {
|
|
computed: {
|
|
@@ -125,10 +278,68 @@ export default {
|
|
|
'completed': '已完成'
|
|
'completed': '已完成'
|
|
|
}
|
|
}
|
|
|
return map[this.recommendation && this.recommendation.status] || '待确认'
|
|
return map[this.recommendation && this.recommendation.status] || '待确认'
|
|
|
|
|
+ },
|
|
|
|
|
+ familyMode: function() {
|
|
|
|
|
+ return this.currentView === 'family'
|
|
|
|
|
+ },
|
|
|
|
|
+ filteredFoods: function() {
|
|
|
|
|
+ var self = this
|
|
|
|
|
+ var result = self.currentView === 'family'
|
|
|
|
|
+ ? self.familyOverview
|
|
|
|
|
+ : self.allFoods
|
|
|
|
|
+
|
|
|
|
|
+ // 分类筛选(食材分类为中文,用关键词集合匹配不同数据源的分类命名)
|
|
|
|
|
+ if (self.activeCategory !== 'all') {
|
|
|
|
|
+ var keywords = self.getCategoryKeywords(self.activeCategory)
|
|
|
|
|
+ result = result.filter(function(f) {
|
|
|
|
|
+ if (!f.category) return false
|
|
|
|
|
+ for (var ki = 0; ki < keywords.length; ki++) {
|
|
|
|
|
+ if (f.category.indexOf(keywords[ki]) !== -1) return true
|
|
|
|
|
+ }
|
|
|
|
|
+ return false
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 搜索筛选
|
|
|
|
|
+ if (self.searchQuery) {
|
|
|
|
|
+ result = result.filter(function(f) {
|
|
|
|
|
+ return (f.name && f.name.indexOf(self.searchQuery) !== -1) ||
|
|
|
|
|
+ (f.category && f.category.indexOf(self.searchQuery) !== -1)
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 适合/戒备筛选(戒备 = 推荐指数 < 0)
|
|
|
|
|
+ if (self.filterType === 'suitable') {
|
|
|
|
|
+ result = result.filter(function(f) {
|
|
|
|
|
+ return f.score >= 0
|
|
|
|
|
+ })
|
|
|
|
|
+ } else if (self.filterType === 'unsuitable') {
|
|
|
|
|
+ result = result.filter(function(f) {
|
|
|
|
|
+ return f.score < 0
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 排序(戒备模式默认升序,见 setFilter)
|
|
|
|
|
+ if (self.sortType === 'asc') {
|
|
|
|
|
+ result = result.slice().sort(function(a, b) {
|
|
|
|
|
+ return (a.score || 0) - (b.score || 0)
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ result = result.slice().sort(function(a, b) {
|
|
|
|
|
+ return (b.score || 0) - (a.score || 0)
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return result
|
|
|
}
|
|
}
|
|
|
},
|
|
},
|
|
|
- onLoad: function() {
|
|
|
|
|
|
|
+ onLoad: function(options) {
|
|
|
|
|
+ var opts = options || {}
|
|
|
|
|
+ this.currentMemberId = opts.memberId ? parseInt(opts.memberId) : null
|
|
|
this.loadRecommendation()
|
|
this.loadRecommendation()
|
|
|
|
|
+ this.loadMembers()
|
|
|
|
|
+ this.loadFamilyData()
|
|
|
|
|
+ this.loadFoods()
|
|
|
},
|
|
},
|
|
|
onShow: function() {
|
|
onShow: function() {
|
|
|
this.loadRecommendation()
|
|
this.loadRecommendation()
|
|
@@ -251,6 +462,224 @@ export default {
|
|
|
var m = (d.getMonth() + 1).toString().padStart(2, '0')
|
|
var m = (d.getMonth() + 1).toString().padStart(2, '0')
|
|
|
var day = d.getDate().toString().padStart(2, '0')
|
|
var day = d.getDate().toString().padStart(2, '0')
|
|
|
return d.getFullYear() + '-' + m + '-' + day
|
|
return d.getFullYear() + '-' + m + '-' + day
|
|
|
|
|
+ },
|
|
|
|
|
+ // ===== 推荐食材(原「食材查询」页迁移)=====
|
|
|
|
|
+ getFoodKey: function(item, idx) {
|
|
|
|
|
+ return item.id || idx
|
|
|
|
|
+ },
|
|
|
|
|
+ loadMembers: function() {
|
|
|
|
|
+ var self = this
|
|
|
|
|
+ var token = uni.getStorageSync('token')
|
|
|
|
|
+ if (!token) return
|
|
|
|
|
+
|
|
|
|
|
+ uni.request({
|
|
|
|
|
+ url: config.API_BASE_URL + '/api/food/recommendation/members',
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
|
|
|
|
+ success: function(res) {
|
|
|
|
|
+ if (res.data && res.data.code === 200 && res.data.data) {
|
|
|
|
|
+ self.memberList = res.data.data
|
|
|
|
|
+ // 默认选中「个人」= isSelf 行(统一身份键 familyMemberId,不再用 userId)
|
|
|
|
|
+ if (!self.currentMemberId && self.memberList.length > 0) {
|
|
|
|
|
+ var defaultMember = self.getSelfRow() || self.memberList[0]
|
|
|
|
|
+ self.currentMemberId = memberIdOf(defaultMember)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ loadFoods: function() {
|
|
|
|
|
+ var self = this
|
|
|
|
|
+ var token = uni.getStorageSync('token')
|
|
|
|
|
+ if (!token) return
|
|
|
|
|
+
|
|
|
|
|
+ self.foodLoading = true
|
|
|
|
|
+ var url = config.API_BASE_URL + '/api/health/foods'
|
|
|
|
|
+ var data = {}
|
|
|
|
|
+ if (self.currentMemberId) {
|
|
|
|
|
+ data.memberId = self.currentMemberId
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ uni.request({
|
|
|
|
|
+ url: url,
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ data: data,
|
|
|
|
|
+ header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
|
|
|
|
+ success: function(res) {
|
|
|
|
|
+ self.foodLoading = false
|
|
|
|
|
+ if (res.data && res.data.code === 200 && res.data.data) {
|
|
|
|
|
+ self.allFoods = res.data.data.map(function(f) {
|
|
|
|
|
+ f.scoreClass = self.getScoreClass(f.score)
|
|
|
|
|
+ f.statusText = self.getStatusText(f.score)
|
|
|
|
|
+ f.status = f.score >= 0 ? 'suitable' : 'unsuitable'
|
|
|
|
|
+ return f
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: function() {
|
|
|
|
|
+ self.foodLoading = false
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ loadFamilyData: function() {
|
|
|
|
|
+ var self = this
|
|
|
|
|
+ var token = uni.getStorageSync('token')
|
|
|
|
|
+ if (!token) return
|
|
|
|
|
+
|
|
|
|
|
+ var familyId = uni.getStorageSync('familyId')
|
|
|
|
|
+ if (!familyId) return
|
|
|
|
|
+
|
|
|
|
|
+ uni.request({
|
|
|
|
|
+ url: config.API_BASE_URL + '/api/health/foods/by-family',
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
|
|
|
|
+ success: function(res) {
|
|
|
|
|
+ if (res.data && res.data.code === 200 && res.data.data) {
|
|
|
|
|
+ self.familyData = res.data.data
|
|
|
|
|
+ self.familyMembers = Object.keys(res.data.data.byMember || {}).map(function(key) {
|
|
|
|
|
+ return { userId: key, nickname: res.data.data.memberNames[key] || '成员' }
|
|
|
|
|
+ })
|
|
|
|
|
+ // 优先使用后端持久化总览(overview,minIndexScore = 家庭成员该项食材最新推荐指数的最小值)
|
|
|
|
|
+ if (res.data.data.overview && res.data.data.overview.length > 0) {
|
|
|
|
|
+ self.familyOverview = res.data.data.overview.map(function(o) {
|
|
|
|
|
+ var min = o.minIndexScore
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: o.foodId,
|
|
|
|
|
+ name: o.foodName,
|
|
|
|
|
+ category: o.category,
|
|
|
|
|
+ score: min,
|
|
|
|
|
+ scoreClass: self.getScoreClass(min),
|
|
|
|
|
+ color: self.getScoreClass(min),
|
|
|
|
|
+ statusText: self.getStatusText(min),
|
|
|
|
|
+ status: min >= 0 ? 'suitable' : 'unsuitable',
|
|
|
|
|
+ memberCount: o.memberCount || 0,
|
|
|
|
|
+ memberScores: (o.memberScores || []).map(function(s) {
|
|
|
|
|
+ return { name: s.nickname || '', score: s.score }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 后端未返回 overview 时的降级路径:前端按 min 计算(比 avg 更安全)
|
|
|
|
|
+ self.buildFamilyOverview(res.data.data.total || res.data.data.totalList, res.data.data.byMember, res.data.data.memberNames)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ buildFamilyOverview: function(totalList, byMember, memberNames) {
|
|
|
|
|
+ var self = this
|
|
|
|
|
+ var overview = []
|
|
|
|
|
+ var list = totalList || []
|
|
|
|
|
+ for (var i = 0; i < list.length; i++) {
|
|
|
|
|
+ var food = list[i]
|
|
|
|
|
+ var memberScores = []
|
|
|
|
|
+
|
|
|
|
|
+ for (var j = 0; j < self.familyMembers.length; j++) {
|
|
|
|
|
+ var member = self.familyMembers[j]
|
|
|
|
|
+ var memberFoods = byMember[member.userId]
|
|
|
|
|
+ if (memberFoods) {
|
|
|
|
|
+ for (var k = 0; k < memberFoods.length; k++) {
|
|
|
|
|
+ if (memberFoods[k].foodId === food.id) {
|
|
|
|
|
+ var score = memberFoods[k].indexScore || 0
|
|
|
|
|
+ memberScores.push({ name: member.nickname, score: score })
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 降级路径:取家庭成员该项食材的最小值(最保守值),比 avg 更安全
|
|
|
|
|
+ if (memberScores.length > 0) {
|
|
|
|
|
+ var minScore = memberScores[0].score
|
|
|
|
|
+ for (var mi = 1; mi < memberScores.length; mi++) {
|
|
|
|
|
+ if (memberScores[mi].score < minScore) minScore = memberScores[mi].score
|
|
|
|
|
+ }
|
|
|
|
|
+ overview.push({
|
|
|
|
|
+ id: food.id,
|
|
|
|
|
+ name: food.name,
|
|
|
|
|
+ category: food.category,
|
|
|
|
|
+ score: minScore,
|
|
|
|
|
+ scoreClass: self.getScoreClass(minScore),
|
|
|
|
|
+ color: self.getScoreClass(minScore),
|
|
|
|
|
+ statusText: self.getStatusText(minScore),
|
|
|
|
|
+ status: minScore >= 0 ? 'suitable' : 'unsuitable',
|
|
|
|
|
+ memberCount: memberScores.length,
|
|
|
|
|
+ memberScores: memberScores
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 按最小推荐值排序(从高到低)
|
|
|
|
|
+ overview.sort(function(a, b) {
|
|
|
|
|
+ return b.score - a.score
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ self.familyOverview = overview
|
|
|
|
|
+ },
|
|
|
|
|
+ getScoreClass: function(score) {
|
|
|
|
|
+ if (score === undefined || score === null) return 'neutral'
|
|
|
|
|
+ if (score >= 0) return 'good'
|
|
|
|
|
+ return 'bad'
|
|
|
|
|
+ },
|
|
|
|
|
+ getStatusText: function(score) {
|
|
|
|
|
+ if (score === undefined || score === null) return '未知'
|
|
|
|
|
+ if (score >= 0) return '适合'
|
|
|
|
|
+ return '戒备'
|
|
|
|
|
+ },
|
|
|
|
|
+ searchFood: function() {
|
|
|
|
|
+ // 搜索由 computed 自动处理
|
|
|
|
|
+ },
|
|
|
|
|
+ switchCategory: function(category) {
|
|
|
|
|
+ this.activeCategory = category
|
|
|
|
|
+ },
|
|
|
|
|
+ getCategoryKeywords: function(category) {
|
|
|
|
|
+ var map = {
|
|
|
|
|
+ '蔬菜': ['蔬菜'],
|
|
|
|
|
+ '水果': ['水果'],
|
|
|
|
|
+ '肉禽': ['肉类', '肉禽'],
|
|
|
|
|
+ '水产': ['水产', '水产品'],
|
|
|
|
|
+ '谷物': ['谷物', '主食', '全谷物'],
|
|
|
|
|
+ '蛋奶': ['蛋类', '蛋奶', '乳制品']
|
|
|
|
|
+ }
|
|
|
|
|
+ return map[category] || []
|
|
|
|
|
+ },
|
|
|
|
|
+ setFilter: function(type) {
|
|
|
|
|
+ this.filterType = type
|
|
|
|
|
+ // 适合/全部:倒序,推荐值大的排前面;戒备:升序,推荐值小的排前面
|
|
|
|
|
+ if (type === 'unsuitable') {
|
|
|
|
|
+ this.sortType = 'asc'
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.sortType = 'desc'
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ setSort: function(type) {
|
|
|
|
|
+ this.sortType = type
|
|
|
|
|
+ },
|
|
|
|
|
+ switchView: function(view) {
|
|
|
|
|
+ this.currentView = view
|
|
|
|
|
+ },
|
|
|
|
|
+ // 取「本人」行(isSelf):后端已按 user_id 折叠,此处仅取第一行 isSelf
|
|
|
|
|
+ getSelfRow: function() {
|
|
|
|
|
+ var members = this.memberList || []
|
|
|
|
|
+ for (var i = 0; i < members.length; i++) {
|
|
|
|
|
+ if (members[i] && members[i].isSelf) return members[i]
|
|
|
|
|
+ }
|
|
|
|
|
+ return null
|
|
|
|
|
+ },
|
|
|
|
|
+ switchMember: function(member) {
|
|
|
|
|
+ // 统一身份键:familyMemberId(= family_members.id);userId 仅用于折叠,不再作主键
|
|
|
|
|
+ this.currentMemberId = memberIdOf(member)
|
|
|
|
|
+ this.currentView = 'personal'
|
|
|
|
|
+ this.loadFoods()
|
|
|
|
|
+ },
|
|
|
|
|
+ onRefresh: function() {
|
|
|
|
|
+ var self = this
|
|
|
|
|
+ self.refreshing = true
|
|
|
|
|
+ self.loadFoods()
|
|
|
|
|
+ self.loadFamilyData()
|
|
|
|
|
+ setTimeout(function() {
|
|
|
|
|
+ self.refreshing = false
|
|
|
|
|
+ }, 500)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -432,4 +861,313 @@ export default {
|
|
|
font-size: calc(28rpx * var(--font-scale, 1)); color: $app-surface; font-weight: 600;
|
|
font-size: calc(28rpx * var(--font-scale, 1)); color: $app-surface; font-weight: 600;
|
|
|
box-shadow: 0 6rpx 20rpx rgba(255, 140, 66, 0.25);
|
|
box-shadow: 0 6rpx 20rpx rgba(255, 140, 66, 0.25);
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+/* ===== 推荐食材(原「食材查询」页迁移)===== */
|
|
|
|
|
+.section-title-bar {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ padding: 8rpx 0 16rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.section-title {
|
|
|
|
|
+ font-size: calc(34rpx * var(--font-scale, 1));
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+ color: $facade-text-strong;
|
|
|
|
|
+}
|
|
|
|
|
+.section-subtitle {
|
|
|
|
|
+ font-size: calc(24rpx * var(--font-scale, 1));
|
|
|
|
|
+ color: $facade-text-muted;
|
|
|
|
|
+ margin-top: 6rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.food-section {
|
|
|
|
|
+ background-color: $app-surface;
|
|
|
|
|
+ border-radius: 24rpx;
|
|
|
|
|
+ padding: 20rpx;
|
|
|
|
|
+ margin-bottom: 32rpx;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* 模式切换(排序) */
|
|
|
|
|
+.mode-bar {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ padding: 16rpx 0;
|
|
|
|
|
+ gap: 20rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.mode-btn {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ padding: 12rpx 0;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ border-radius: 24rpx;
|
|
|
|
|
+ background: $facade-fill-2;
|
|
|
|
|
+ color: $facade-text-body;
|
|
|
|
|
+ font-size: calc(26rpx * var(--font-scale, 1));
|
|
|
|
|
+}
|
|
|
|
|
+.mode-btn.active {
|
|
|
|
|
+ background: linear-gradient(135deg, $status-success, $status-success-light-3);
|
|
|
|
|
+ color: $app-surface;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* 搜索栏 */
|
|
|
|
|
+.search-bar {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ padding: 20rpx 0;
|
|
|
|
|
+}
|
|
|
|
|
+.search-input {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ border: 1rpx solid $facade-border-soft;
|
|
|
|
|
+ border-radius: 24rpx;
|
|
|
|
|
+ padding: 16rpx 24rpx;
|
|
|
|
|
+ font-size: calc(26rpx * var(--font-scale, 1));
|
|
|
|
|
+ background-color: $facade-fill-ghost;
|
|
|
|
|
+}
|
|
|
|
|
+.search-btn {
|
|
|
|
|
+ margin-left: 20rpx;
|
|
|
|
|
+ padding: 16rpx 32rpx;
|
|
|
|
|
+ background-color: $dim-body;
|
|
|
|
|
+ color: $app-surface;
|
|
|
|
|
+ border-radius: 24rpx;
|
|
|
|
|
+ font-size: calc(26rpx * var(--font-scale, 1));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* 分类标签 */
|
|
|
|
|
+.category-tabs {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ gap: 16rpx;
|
|
|
|
|
+ margin-bottom: 20rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.tab-item {
|
|
|
|
|
+ padding: 12rpx 24rpx;
|
|
|
|
|
+ border: 1rpx solid $facade-border-soft;
|
|
|
|
|
+ border-radius: 24rpx;
|
|
|
|
|
+ font-size: calc(24rpx * var(--font-scale, 1));
|
|
|
|
|
+ color: $facade-text-body;
|
|
|
|
|
+ background-color: $app-surface;
|
|
|
|
|
+}
|
|
|
|
|
+.tab-active {
|
|
|
|
|
+ border-color: $dim-body;
|
|
|
|
|
+ color: $dim-body;
|
|
|
|
|
+ background-color: $flat-card;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* 筛选栏 */
|
|
|
|
|
+.filter-bar {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ gap: 20rpx;
|
|
|
|
|
+ margin-bottom: 20rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.filter-item {
|
|
|
|
|
+ padding: 12rpx 24rpx;
|
|
|
|
|
+ border: 1rpx solid $facade-border-soft;
|
|
|
|
|
+ border-radius: 24rpx;
|
|
|
|
|
+ font-size: calc(24rpx * var(--font-scale, 1));
|
|
|
|
|
+ color: $facade-text-body;
|
|
|
|
|
+ background-color: $app-surface;
|
|
|
|
|
+}
|
|
|
|
|
+.filter-active {
|
|
|
|
|
+ border-color: $mat-green;
|
|
|
|
|
+ color: $mat-green;
|
|
|
|
|
+ background-color: $mat-green-tint;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* 家庭总览 */
|
|
|
|
|
+.family-overview {
|
|
|
|
|
+ background: $facade-fill-ghost-2;
|
|
|
|
|
+ border-radius: 16rpx;
|
|
|
|
|
+ padding: 20rpx;
|
|
|
|
|
+ margin-bottom: 20rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.overview-title {
|
|
|
|
|
+ font-size: calc(28rpx * var(--font-scale, 1));
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ color: $facade-text-strong;
|
|
|
|
|
+ margin-bottom: 16rpx;
|
|
|
|
|
+ padding-bottom: 16rpx;
|
|
|
|
|
+ border-bottom: 1rpx solid $facade-fill;
|
|
|
|
|
+}
|
|
|
|
|
+.overview-item {
|
|
|
|
|
+ padding: 16rpx 0;
|
|
|
|
|
+ border-bottom: 1rpx solid $facade-fill-2;
|
|
|
|
|
+}
|
|
|
|
|
+.overview-item:last-child {
|
|
|
|
|
+ border-bottom: none;
|
|
|
|
|
+}
|
|
|
|
|
+.overview-header {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ margin-bottom: 8rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.overview-detail {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 8rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.category-label {
|
|
|
|
|
+ font-size: calc(22rpx * var(--font-scale, 1));
|
|
|
|
|
+ color: $facade-text-muted;
|
|
|
|
|
+}
|
|
|
|
|
+.member-scores {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ gap: 12rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.member-score {
|
|
|
|
|
+ font-size: calc(20rpx * var(--font-scale, 1));
|
|
|
|
|
+ padding: 4rpx 12rpx;
|
|
|
|
|
+ border-radius: 16rpx;
|
|
|
|
|
+ background: $facade-fill-2;
|
|
|
|
|
+}
|
|
|
|
|
+.score-green {
|
|
|
|
|
+ color: $status-success;
|
|
|
|
|
+ background: $mat-green-tint;
|
|
|
|
|
+}
|
|
|
|
|
+.score-red {
|
|
|
|
|
+ color: $status-danger;
|
|
|
|
|
+ background: $status-danger-soft;
|
|
|
|
|
+}
|
|
|
|
|
+.score-badge {
|
|
|
|
|
+ min-width: 72rpx;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ padding: 4rpx 14rpx;
|
|
|
|
|
+ border-radius: 16rpx;
|
|
|
|
|
+ font-size: calc(24rpx * var(--font-scale, 1));
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+ color: $app-surface;
|
|
|
|
|
+ background: linear-gradient(135deg, $dim-body, $dim-body-light);
|
|
|
|
|
+}
|
|
|
|
|
+.score-badge.good {
|
|
|
|
|
+ background: linear-gradient(135deg, $status-success, $status-success-light-3);
|
|
|
|
|
+}
|
|
|
|
|
+.score-badge.bad {
|
|
|
|
|
+ background: linear-gradient(135deg, $status-danger, $status-danger-deep);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* 食材列表 */
|
|
|
|
|
+.food-list {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 16rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.food-card {
|
|
|
|
|
+ background: $facade-fill-ghost-2;
|
|
|
|
|
+ border-radius: 16rpx;
|
|
|
|
|
+ padding: 18rpx 20rpx;
|
|
|
|
|
+ box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+}
|
|
|
|
|
+.food-left {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+}
|
|
|
|
|
+.ingredient-badge {
|
|
|
|
|
+ width: 36rpx;
|
|
|
|
|
+ height: 36rpx;
|
|
|
|
|
+ border-radius: 8rpx;
|
|
|
|
|
+ background: linear-gradient(135deg, $dim-body, $dim-body-light);
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ margin-right: 16rpx;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+}
|
|
|
|
|
+.ingredient-badge.good {
|
|
|
|
|
+ background: linear-gradient(135deg, $status-success, $status-success-light-3);
|
|
|
|
|
+}
|
|
|
|
|
+.ingredient-badge.bad {
|
|
|
|
|
+ background: linear-gradient(135deg, $status-danger, $status-danger-deep);
|
|
|
|
|
+}
|
|
|
|
|
+.ingredient-badge-text {
|
|
|
|
|
+ font-size: calc(18rpx * var(--font-scale, 1));
|
|
|
|
|
+ color: $app-surface;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+}
|
|
|
|
|
+.food-info {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+}
|
|
|
|
|
+.food-name {
|
|
|
|
|
+ font-size: calc(28rpx * var(--font-scale, 1));
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ color: $text-charcoal;
|
|
|
|
|
+ display: block;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ text-overflow: ellipsis;
|
|
|
|
|
+}
|
|
|
|
|
+.food-meta {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ gap: 8rpx;
|
|
|
|
|
+ margin-top: 6rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.meta-item {
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+ font-size: calc(20rpx * var(--font-scale, 1));
|
|
|
|
|
+ color: $facade-text-faint;
|
|
|
|
|
+ background: $facade-fill-2;
|
|
|
|
|
+ padding: 4rpx 14rpx;
|
|
|
|
|
+ border-radius: 8rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.food-right {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 12rpx;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+}
|
|
|
|
|
+.score-value {
|
|
|
|
|
+ font-size: calc(28rpx * var(--font-scale, 1));
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+ min-width: 60rpx;
|
|
|
|
|
+ text-align: right;
|
|
|
|
|
+}
|
|
|
|
|
+.score-value.good { color: $status-success; }
|
|
|
|
|
+.score-value.bad { color: $status-danger; }
|
|
|
|
|
+.food-status-badge {
|
|
|
|
|
+ padding: 6rpx 16rpx;
|
|
|
|
|
+ border-radius: 24rpx;
|
|
|
|
|
+ font-size: calc(22rpx * var(--font-scale, 1));
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+}
|
|
|
|
|
+.badge-suitable {
|
|
|
|
|
+ background: $mat-green-tint;
|
|
|
|
|
+ color: $mat-green;
|
|
|
|
|
+}
|
|
|
|
|
+.badge-unsuitable {
|
|
|
|
|
+ background: $fortune-0-tint;
|
|
|
|
|
+ color: $mat-red;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* 空状态 */
|
|
|
|
|
+.food-empty-state {
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ padding: 60rpx 40rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.food-empty-icon {
|
|
|
|
|
+ font-size: calc(80rpx * var(--font-scale, 1));
|
|
|
|
|
+ display: block;
|
|
|
|
|
+ margin-bottom: 20rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.food-empty-text {
|
|
|
|
|
+ font-size: calc(30rpx * var(--font-scale, 1));
|
|
|
|
|
+ color: $facade-text-strong;
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+ display: block;
|
|
|
|
|
+ margin-bottom: 12rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.loading-state {
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ padding: 60rpx 0;
|
|
|
|
|
+}
|
|
|
|
|
+.loading-text {
|
|
|
|
|
+ font-size: calc(26rpx * var(--font-scale, 1));
|
|
|
|
|
+ color: $facade-text-muted;
|
|
|
|
|
+}
|
|
|
</style>
|
|
</style>
|