PregoPal / utils.py
J.B-Lin
refactor: 将三天总结从框架层面合并到营养报告,统一UI设计
a4f0e44
Raw
History Blame Contribute Delete
39.8 kB
"""
PregoPal - 工具函数
====================
通用工具函数,如字体设置、国际化等。
"""
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
from pathlib import Path
# ============================================================
# 中文字体设置
# ============================================================
def setup_chinese_font():
"""尝试设置中文字体,返回使用的字体名称"""
font_candidates = [
'SimHei', 'Microsoft YaHei', 'SimSun', 'KaiTi', 'FangSong',
'WenQuanYi Micro Hei', 'Noto Sans CJK SC', 'Noto Sans SC',
'Source Han Sans SC', 'PingFang SC', 'Hiragino Sans GB', 'STHeiti',
]
available = [f.name for f in fm.fontManager.ttflist]
for font in font_candidates:
if font in available:
plt.rcParams['font.sans-serif'] = [font, 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
return font
system_fonts = [
Path("C:/Windows/Fonts/simhei.ttf"),
Path("C:/Windows/Fonts/msyh.ttc"),
Path("C:/Windows/Fonts/simsun.ttc"),
]
for fp in system_fonts:
if fp.exists():
try:
fm.fontManager.addfont(str(fp))
font_name = fm.FontProperties(fname=str(fp)).get_name()
plt.rcParams['font.sans-serif'] = [font_name, 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
return font_name
except Exception:
continue
plt.rcParams['font.sans-serif'] = ['DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
return None
# ============================================================
# 国际化(i18n)
# ============================================================
ZH = {
"app_title": "🌸 PregoPal - 孕期陪护AI助手",
"app_subtitle": "一个温馨的家庭式孕期AI伴侣",
"tab_home_suffix": "首页",
"tab_family_suffix": "家庭饮食习惯",
"tab_report_suffix": "营养报告",
"chat_label": "💬 对话",
"msg_placeholder": "例如:我今天中午吃了番茄牛腩",
"btn_send": "发送",
"btn_clear": "🗑️ 清空对话",
"voice_title": "#### 🔊 声纹识别",
"voice_register": "注册",
"voice_identify": "识别",
"member_name": "姓名",
"member_relation": "身份",
"relation_choices": ["孕妇", "丈夫", "婆婆", "妈妈", "爸爸", "其他家人"],
"btn_register": "📝 注册",
"btn_identify": "🔊 识别",
"briefing_title": "#### 📋 今日简报",
"tab_preferences": "🥗 饮食偏好",
"tab_recipes": "🍳 家庭菜谱",
"tab_memory": "📝 家庭记事",
"btn_refresh": "🔄 刷新",
"trimester_label": "孕期阶段",
"trimester_choices": ["孕早期", "孕中期", "孕晚期"],
"btn_analyze": "📊 运行分析",
"analysis_result": "📋 分析结果",
"menu_suggestion": "💡 菜单改进建议",
"analysis_days": "分析天数",
"btn_generate": "📊 生成报告",
"report_text": "📋 文本报告",
"lang_label": "🌐 语言",
"lang_zh": "中文",
"lang_en": "English",
"family_title": "👨‍👩‍👧‍👦 家庭饮食档案",
"family_subtitle": "AI 自动记录的家庭饮食习惯",
"no_recipes": "暂无菜谱记录 — AI 将在对话中自动学习",
"no_preferences": "暂无饮食偏好记录 — AI 将在对话中自动学习",
"no_memories": "暂无家庭记忆 — AI 将在对话中自动学习",
"report_title": "📈 营养分析报告",
"report_subtitle": "基于近期饮食数据的 AI 分析",
"report_overall": "综合营养评分",
"report_good": "良好",
"report_need_attention": "需关注",
"report_meal_completion": "餐次完成率",
"report_nutrient_coverage": "关键营养覆盖",
"report_diversity": "饮食多样性",
"report_suggestions": "饮食建议",
"report_days": "近{days}天",
# 三天深度分析(已合并到营养报告中)
"three_day_title": "📊 三天深度营养趋势",
"three_day_subtitle": "基于近三日饮食数据的持续缺失追踪",
"three_day_days": "分析了 {days} 天数据",
"three_day_duration": "持续不足的营养素",
"three_day_suggestions": "🍽️ 食材补充建议",
"three_day_summary": "📋 趋势总结",
"three_day_no_data": "近三天无饮食记录,跳过深度分析",
"three_day_all_good": "✅ 各营养素摄入基本达标,继续保持!",
}
EN = {
"app_title": "🌸 PregoPal - Pregnancy Companion AI",
"app_subtitle": "A cozy home-oriented AI companion for expectant mothers",
"tab_home_suffix": "Home",
"tab_family_suffix": "Family Diet",
"tab_report_suffix": "Nutrition Report",
"chat_label": "💬 Chat",
"msg_placeholder": "e.g. I had tomato beef brisket for lunch today",
"btn_send": "Send",
"btn_clear": "🗑️ Clear",
"voice_title": "#### 🔊 Voiceprint",
"voice_register": "Register",
"voice_identify": "Identify",
"member_name": "Name",
"member_relation": "Role",
"relation_choices": ["Pregnant", "Husband", "Mother-in-law", "Mom", "Dad", "Other"],
"btn_register": "📝 Register",
"btn_identify": "🔊 Identify",
"briefing_title": "#### 📋 Today's Briefing",
"tab_preferences": "🥗 Preferences",
"tab_recipes": "🍳 Recipes",
"tab_memory": "📝 Memories",
"btn_refresh": "🔄 Refresh",
"trimester_label": "Trimester",
"trimester_choices": ["1st Trimester", "2nd Trimester", "3rd Trimester"],
"btn_analyze": "📊 Analyze",
"analysis_result": "📋 Analysis Result",
"menu_suggestion": "💡 Menu Suggestions",
"analysis_days": "Analysis Days",
"btn_generate": "📊 Generate Report",
"report_text": "📋 Text Report",
"lang_label": "🌐 Language",
"lang_zh": "中文",
"lang_en": "English",
"family_title": "👨‍👩‍👧‍👦 Family Diet Profile",
"family_subtitle": "AI-automated family diet memory",
"no_recipes": "No recipes yet — AI will learn from conversations",
"no_preferences": "No preferences yet — AI will learn from conversations",
"no_memories": "No memories yet — AI will learn from conversations",
"report_title": "📈 Nutrition Report",
"report_subtitle": "AI analysis based on recent diet data",
"report_overall": "Overall Score",
"report_good": "Good",
"report_need_attention": "Needs Attention",
"report_meal_completion": "Meal Completion",
"report_nutrient_coverage": "Key Nutrients",
"report_diversity": "Diet Diversity",
"report_suggestions": "Dietary Suggestions",
"report_days": "Last {days} days",
# 三天深度分析(合并到营养报告中)
"three_day_title": "📊 3-Day Nutrient Trends",
"three_day_subtitle": "Continuous deficiency tracking based on recent diet data",
"three_day_days": "Analyzed {days} days of data",
"three_day_duration": "Persistent Deficits",
"three_day_suggestions": "🍽️ Food Suggestions",
"three_day_summary": "📋 Trend Summary",
"three_day_no_data": "No diet records in the last 3 days, skipping deep analysis",
"three_day_all_good": "✅ All nutrients adequate, keep it up!",
}
def t(key: str, lang: str = "zh") -> str:
"""翻译函数"""
d = ZH if lang == "zh" else EN
return d.get(key, key)
# ============================================================
# 首页卡片数据提取
# ============================================================
def get_home_cards(loop=None):
"""从 Loop 上下文提取首页卡片数据"""
briefing = loop.get_briefing() if loop else {}
trimester = briefing.get("trimester", "孕中期")
focus_nutrients = briefing.get("dri_analysis", {}).get("focus_nutrients", [])
recommended_foods = briefing.get("recommended_foods", [])
from modules.family_manager import RecipeManager
all_recipes = RecipeManager.load_all()
recipe_count = len(all_recipes)
recipe_names = [r.get("name", "") for r in all_recipes[:3]]
yesterday = briefing.get("yesterday_diet", {})
yesterday_summary = yesterday.get("summary", "暂无记录")
meal_count = yesterday.get("meal_count", 0)
weight_eval = briefing.get("weight_evaluation", {})
weight_status = weight_eval.get("status", "暂无数据")
weight_trend = weight_eval.get("trend", "")
thinking_keywords = briefing.get("thinking_keywords", "")
return {
"trimester": trimester,
"focus_nutrients": focus_nutrients,
"recommended_foods": recommended_foods,
"recipe_count": recipe_count,
"recipe_names": recipe_names,
"yesterday_summary": yesterday_summary,
"meal_count": meal_count,
"weight_status": weight_status,
"weight_trend": weight_trend,
"thinking_keywords": thinking_keywords,
}
# ============================================================
# Helper: 家庭数据 → HTML 卡片
# ============================================================
def render_family_recipes_html(lang="zh") -> str:
"""渲染家庭菜谱为现代卡片"""
from modules.family_manager import RecipeManager
recipes = RecipeManager.load_all()
if not recipes:
no = t("no_recipes", lang)
return f'<div style="padding:24px;text-align:center;color:#aaa;font-size:15px;">{no}</div>'
cards = ""
for r in recipes:
name = r.get("name", "")
cook = r.get("cook", "")
diff = r.get("difficulty", "")
diff_stars = {"简单": "⭐", "中等": "⭐⭐", "困难": "⭐⭐⭐", "Easy": "⭐", "Medium": "⭐⭐", "Hard": "⭐⭐⭐"}
stars = diff_stars.get(diff, "")
cards += f"""
<div style="background:#fff;border-radius:14px;padding:14px 16px;box-shadow:0 1px 6px rgba(0,0,0,0.04);display:flex;flex-direction:column;gap:4px;border:1px solid #f0f0f0;transition:all 0.2s;">
<div style="font-weight:600;font-size:15px;color:#E91E63;">{name}</div>
<div style="font-size:13px;color:#888;">
<span>👨‍🍳 {cook}</span>
<span style="margin-left:12px;">{stars}</span>
</div>
</div>"""
return f"""
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px;">
{cards}
</div>"""
def render_family_preferences_html(lang="zh") -> str:
"""渲染饮食偏好为现代卡片"""
from modules.family_manager import PreferenceManager
members = PreferenceManager.load_all()
if not members:
no = t("no_preferences", lang)
return f'<div style="padding:24px;text-align:center;color:#aaa;font-size:15px;">{no}</div>'
cards = ""
for m in members:
name = m.get("name", "")
role = m.get("role", "")
pref = m.get("preferences", "")
avoid = m.get("avoid", "")
allergies = m.get("allergies", "")
tags = []
if pref: tags.append(f'<span style="background:#E8F5E9;color:#2E7D32;padding:2px 10px;border-radius:99px;font-size:12px;">✅ {pref}</span>')
if avoid: tags.append(f'<span style="background:#FFF3E0;color:#E65100;padding:2px 10px;border-radius:99px;font-size:12px;">❌ {avoid}</span>')
if allergies: tags.append(f'<span style="background:#FFEBEE;color:#C62828;padding:2px 10px;border-radius:99px;font-size:12px;">⚠ {allergies}</span>')
if not tags: tags.append(f'<span style="color:#bbb;font-size:12px;">暂无记录</span>')
role_icons = {"孕妇":"🤰","丈夫":"👨","婆婆":"👩","妈妈":"👩","爸爸":"👨","其他家人":"👤",
"Pregnant":"🤰","Husband":"👨","Mother-in-law":"👩","Mom":"👩","Dad":"👨","Other":"👤"}
icon = role_icons.get(role, "👤")
role_str = f" {role}" if role else ""
cards += f"""
<div style="background:#fff;border-radius:14px;padding:16px;box-shadow:0 1px 6px rgba(0,0,0,0.04);border:1px solid #f0f0f0;">
<div style="font-weight:600;font-size:16px;margin-bottom:8px;">
{icon}<span style="margin-left:4px;">{name}{role_str}</span>
</div>
<div style="display:flex;flex-wrap:wrap;gap:6px;">
{"".join(tags)}
</div>
</div>"""
return f"""
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;">
{cards}
</div>"""
def render_family_memories_html(lang="zh") -> str:
"""渲染家庭记忆为时间线"""
from modules.family_manager import MemoryManager
import re
data = MemoryManager.load_all()
if not any(data.values()):
no = t("no_memories", lang)
return f'<div style="padding:24px;text-align:center;color:#aaa;font-size:15px;">{no}</div>'
# 将 dict 展平为 (date, content) 列表
flat = []
for section_key, label in [("relationships", "关系"), ("events", "事件"), ("daily", "日常")]:
for line in data.get(section_key, []):
m = re.match(r'-\s*\*{0,2}(\d{4}-\d{2}-\d{2})\*{0,2}:?\s*(.+)', line)
if m:
flat.append((m.group(1), m.group(2)))
else:
# 无日期的行(如关系描述),用空日期
clean = re.sub(r'^-\s*\*{0,2}(.*?)\*{0,2}:?\s*', r'\1: ', line).strip()
flat.append(("", clean))
# 按日期排序(最新的在前),取最近10条
flat.sort(key=lambda x: x[0] if x[0] else "0000", reverse=True)
items = ""
for date_str, content in flat[:10]:
disp_date = date_str[-5:] if date_str else ""
if not content: continue
items += f"""
<div style="display:flex;gap:12px;padding:8px 0;">
<div style="min-width:50px;font-size:13px;color:#E91E63;font-weight:500;">{disp_date}</div>
<div style="flex:1;background:#fff;border-radius:10px;padding:10px 14px;border:1px solid #f0f0f0;font-size:14px;color:#333;">{content}</div>
</div>"""
if not items:
return f'<div style="padding:24px;text-align:center;color:#aaa;font-size:15px;">{t("no_memories", lang)}</div>'
return f"""
<div style="max-height:320px;overflow-y:auto;padding:4px 0;">
{items}
</div>"""
# ============================================================
# 营养报告 HTML(含三天深度分析)
# ============================================================
# 统一的设计令牌(Design Tokens)
# Header: 粉色渐变 background:linear-gradient(135deg,#FCE4EC,#FFF0F2)
# 卡片: background:#fff; border-radius:14px; box-shadow + border
# 建议: background:linear-gradient(135deg,#FFF8E1,#FFFDE7); border:1px solid #FFE082
# 强调色: #880E4F (深粉红), #E91E63 (粉红), #E65100 (橙)
# ============================================================
def render_nutrition_report_html(analysis: dict, days: int, lang="zh", deficit_data: dict = None) -> str:
"""渲染营养报告为现代 Dashboard HTML(含三天深度趋势分析)"""
if not analysis or "error" in analysis:
return f'<div style="padding:40px;text-align:center;color:#aaa;font-size:16px;">{analysis.get("error","暂无数据")}</div>'
score = analysis.get("score", 0)
meal_counts = analysis.get("meal_counts", {})
total_days = analysis.get("total_days", 0)
nutrition_coverage = analysis.get("nutrition_coverage", {})
suggestions = analysis.get("suggestions", [])
food_items = analysis.get("food_items", [])
diversity_score = analysis.get("diversity_score", 0)
# Score bar color
bar_color = "#4CAF50" if score >= 70 else "#FF9800" if score >= 50 else "#F44336"
bar_status = t("report_good", lang) if score >= 70 else t("report_need_attention", lang)
# Meal completion bars
meal_labels = {"早餐":"🌅", "午餐":"☀️", "晚餐":"🌙", "加餐":"🍪"}
meal_rows = ""
for mt, label in {"早餐":"Breakfast","午餐":"Lunch","晚餐":"Dinner","加餐":"Snack"}.items():
cnt = meal_counts.get(mt, 0)
pct = min(100, round(cnt / max(total_days, 1) * 100))
icon = meal_labels.get(mt, "🍽️")
mcolor = "#4CAF50" if pct >= 80 else "#FF9800" if pct >= 50 else "#F44336"
meal_rows += f"""
<div style="margin-bottom:10px;">
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:3px;">
<span>{icon} {mt}</span><span style="font-weight:600;">{pct}%</span>
</div>
<div style="background:#f0f0f0;border-radius:99px;height:8px;overflow:hidden;">
<div style="width:{pct}%;background:{mcolor};height:100%;border-radius:99px;transition:width 0.6s;"></div>
</div>
</div>"""
# Nutrient coverage — 中英文双语
n_rows = ""
for nut, info in nutrition_coverage.items():
pct = info.get("percentage", 0) if isinstance(info, dict) else info
try: pct = min(100, int(pct))
except: pct = 0
ncolor = "#4CAF50" if pct >= 70 else "#FF9800" if pct >= 40 else "#F44336"
nd = _nutrient_display(nut, lang)
n_rows += f"""
<div style="margin-bottom:8px;">
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:2px;">
<span>{nd}</span><span style="font-weight:600;">{pct}%</span>
</div>
<div style="background:#f0f0f0;border-radius:99px;height:6px;overflow:hidden;">
<div style="width:{pct}%;background:{ncolor};height:100%;border-radius:99px;"></div>
</div>
</div>"""
# Suggestions
sug_items = ""
for s in suggestions[:5]:
color = "#F44336" if "⚠" in s or "不足" in s or "不够" in s or "缺乏" in s else "#FF9800"
sug_items += f'<div style="padding:6px 0;font-size:13px;color:{color};">• {s}</div>'
# Diversity
div_color = "#4CAF50" if diversity_score >= 60 else "#FF9800" if diversity_score >= 30 else "#F44336"
food_count = len(food_items)
food_str = "、".join(food_items[:8])
if len(food_items) > 8: food_str += "⋯"
days_str = t("report_days", lang).format(days=days)
# ============================================================
# 三天深度趋势分析(合并到营养报告内)
# ============================================================
three_day_html = _render_deficit_section(deficit_data, lang)
return f"""
<div style="font-family:system-ui,-apple-system,sans-serif;max-width:100%;">
<!-- Header -->
<div style="background:linear-gradient(135deg,#FCE4EC,#FFF0F2);border-radius:18px;padding:20px 24px;margin-bottom:16px;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div style="font-size:20px;font-weight:700;color:#880E4F;">📊 {t("report_overall", lang)}</div>
<div style="color:#888;font-size:13px;margin-top:4px;">{days_str} · 共 {total_days} 天</div>
</div>
<div style="text-align:right;">
<div style="font-size:36px;font-weight:800;color:{bar_color};">{score}</div>
<div style="font-size:14px;color:#555;">/ 100</div>
</div>
</div>
<div style="background:#f0f0f0;border-radius:99px;height:10px;margin-top:12px;overflow:hidden;">
<div style="width:{min(100,score)}%;background:linear-gradient(90deg,#F44336,#FF9800,#4CAF50);height:100%;border-radius:99px;transition:width 0.8s;"></div>
</div>
<div style="margin-top:6px;font-size:14px;color:#555;text-align:center;">{bar_status}</div>
</div>
<!-- 3 cards row -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:16px;">
<!-- Meal Completion -->
<div style="background:#fff;border-radius:14px;padding:14px 16px;box-shadow:0 1px 6px rgba(0,0,0,0.04);border:1px solid #f0f0f0;">
<div style="font-weight:600;font-size:14px;margin-bottom:10px;color:#333;">🍽️ {t("report_meal_completion", lang)}</div>
{meal_rows}
</div>
<!-- Nutrient Coverage -->
<div style="background:#fff;border-radius:14px;padding:14px 16px;box-shadow:0 1px 6px rgba(0,0,0,0.04);border:1px solid #f0f0f0;">
<div style="font-weight:600;font-size:14px;margin-bottom:10px;color:#333;">🥗 {t("report_nutrient_coverage", lang)}</div>
{n_rows if n_rows else '<div style="color:#bbb;font-size:13px;">暂无数据</div>'}
</div>
<!-- Diversity -->
<div style="background:#fff;border-radius:14px;padding:14px 16px;box-shadow:0 1px 6px rgba(0,0,0,0.04);border:1px solid #f0f0f0;">
<div style="font-weight:600;font-size:14px;margin-bottom:10px;color:#333;">🌿 {t("report_diversity", lang)}</div>
<div style="text-align:center;padding:8px 0;">
<div style="font-size:32px;font-weight:700;color:{div_color};">{diversity_score}</div>
<div style="font-size:13px;color:#888;">/ 100</div>
</div>
<div style="background:#f0f0f0;border-radius:99px;height:6px;overflow:hidden;margin:6px 0;">
<div style="width:{min(100,diversity_score)}%;background:{div_color};height:100%;border-radius:99px;"></div>
</div>
<div style="margin-top:8px;font-size:12px;color:#666;">
<span>{food_count} 种食材</span>
<div style="font-size:12px;color:#999;margin-top:2px;">{food_str}</div>
</div>
</div>
</div>
<!-- Suggestions -->
<div style="background:linear-gradient(135deg,#FFF8E1,#FFFDE7);border-radius:14px;padding:14px 18px;border:1px solid #FFE082;">
<div style="font-weight:600;font-size:14px;margin-bottom:6px;color:#E65100;">💡 {t("report_suggestions", lang)}</div>
{sug_items if sug_items else '<div style="font-size:13px;color:#888;">暂无建议</div>'}
</div>
<!-- ★ 三天深度趋势分析 — 使用与营养报告统一的UI设计 -->
{three_day_html}
</div>"""
def _render_deficit_section(deficit_data: dict, lang="zh") -> str:
"""
渲染三天缺失深度分析板块,与营养报告使用同一套现代UI视觉语言。
Args:
deficit_data: 来自 ThreeDaySummaryPlugin 的数据字典,包含:
- "days_analyzed": int
- "persistent_deficits": list[str]
- "report": str (纯文本报告)
lang: 语言标识 "zh" / "en"
Returns:
str: 纯 HTML 字符串,使用与营养报告统一的风格
"""
if not deficit_data:
return ""
days_analyzed = deficit_data.get("days_analyzed", 0)
persistent_deficits = deficit_data.get("persistent_deficits", [])
report_text = deficit_data.get("report", "")
# 如果没有数据则显示提示
if deficit_data.get("status") == "no_data" or days_analyzed == 0:
no_data_msg = t("three_day_no_data", lang)
return f"""
<div style="margin-top:20px;padding:20px;text-align:center;color:#aaa;font-size:15px;
background:#fff;border-radius:14px;border:1px solid #f0f0f0;
box-shadow:0 1px 6px rgba(0,0,0,0.04);">
{no_data_msg}
</div>"""
# ——— Persistent Deficits: 紧凑标签式展示 ———
deficit_tags = ""
for nutrient in persistent_deficits:
nd = _nutrient_display(nutrient, lang)
deficit_tags += f"""
<span style="display:inline-flex;align-items:center;gap:5px;
background:#FFF0F0;color:#C62828;padding:5px 14px;
border-radius:99px;font-size:13px;font-weight:500;
border:1px solid #FFCDD2;white-space:nowrap;">
⚠️ {nd}
</span>"""
if not deficit_tags:
all_good = t("three_day_all_good", lang)
deficit_tags = f"""
<span style="display:inline-flex;align-items:center;gap:5px;
background:#E8F5E9;color:#2E7D32;padding:5px 14px;
border-radius:99px;font-size:13px;font-weight:500;
border:1px solid #C8E6C9;">
🎉 {all_good}
</span>"""
# ——— Food Suggestions ———
from modules.nutrition_standards import DRIsParser
suggestion_rows = ""
for nutrient in persistent_deficits[:3]:
nd = _nutrient_display(nutrient, lang)
foods = DRIsParser.RICH_FOODS.get(nutrient, [])
if foods:
food_tags = "".join(
f'<span style="background:#E8F5E9;color:#2E7D32;padding:4px 14px;'
f'border-radius:99px;font-size:13px;white-space:nowrap;'
f'border:1px solid #C8E6C9;">{f}</span>'
for f in foods[:6]
)
suggestion_rows += f"""
<div style="margin-bottom:14px;padding-bottom:12px;{'' if nutrient == persistent_deficits[-1] else 'border-bottom:1px dashed #E0E0E0;'}">
<div style="font-weight:600;font-size:14px;color:#E91E63;margin-bottom:8px;">
💪 {nd}
</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;">
{food_tags}
</div>
</div>"""
if not suggestion_rows:
suggestion_rows = f"""
<div style="font-size:14px;color:#888;text-align:center;padding:16px 0;">
🎉 {t("three_day_all_good", lang)}
</div>"""
# ——— Summary: 结构化UI展示 ———
summary_sections_html = ""
if report_text:
lines = report_text.strip().split("\n")
critical_items = []
warning_items = []
ok_items = []
info_items = []
for line in lines:
line = line.strip()
if not line:
continue
if line.startswith("❌"):
text = line.lstrip("❌").strip()
if text:
critical_items.append(text)
elif line.startswith("⚠️") or line.startswith("⚠"):
text = line.lstrip("⚠️⚠").strip()
if text:
warning_items.append(text)
elif line.startswith("✅"):
text = line.lstrip("✅").strip()
if text:
ok_items.append(text)
elif line.startswith("📊") or line.startswith("="):
continue
elif line.startswith("💡") or line.startswith("-"):
continue
else:
info_items.append(line)
# 构建结构化Summary卡片
summary_cards = ""
if critical_items:
for item in critical_items:
summary_cards += f"""
<div style="display:flex;align-items:center;gap:10px;padding:6px 12px;
background:#FFF0F0;border-radius:8px;border-left:3px solid #F44336;">
<span style="font-size:16px;">❌</span>
<span style="font-size:13px;color:#C62828;">{item}</span>
</div>"""
if warning_items:
for item in warning_items:
summary_cards += f"""
<div style="display:flex;align-items:center;gap:10px;padding:6px 12px;
background:#FFF8E1;border-radius:8px;border-left:3px solid #FF9800;">
<span style="font-size:16px;">⚠️</span>
<span style="font-size:13px;color:#E65100;">{item}</span>
</div>"""
if ok_items:
for item in ok_items:
summary_cards += f"""
<div style="display:flex;align-items:center;gap:10px;padding:6px 12px;
background:#E8F5E9;border-radius:8px;border-left:3px solid #4CAF50;">
<span style="font-size:16px;">✅</span>
<span style="font-size:13px;color:#2E7D32;">{item}</span>
</div>"""
if info_items:
for item in info_items:
summary_cards += f"""
<div style="display:flex;align-items:center;gap:10px;padding:6px 12px;
background:#F5F5F5;border-radius:8px;border-left:3px solid #9E9E9E;">
<span style="font-size:16px;">📌</span>
<span style="font-size:13px;color:#555;">{item}</span>
</div>"""
if summary_cards:
summary_sections_html = f"""
<div style="display:flex;flex-direction:column;gap:6px;">
{summary_cards}
</div>"""
days_label = t("three_day_days", lang).format(days=days_analyzed)
# ——— 拼装最终 HTML(使用与营养报告统一的设计语言) ———
return f"""
<!-- 三天深度趋势分析 — 使用与营养报告一致的粉色渐变主题 -->
<div style="margin-top:20px;">
<!-- 头部:与营养报告Header统一的粉色渐变 -->
<div style="background:linear-gradient(135deg,#FCE4EC,#FFF0F2);border-radius:18px;padding:20px 24px;margin-bottom:16px;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div style="font-size:20px;font-weight:700;color:#880E4F;">{t("three_day_title", lang)}</div>
<div style="color:#888;font-size:13px;margin-top:4px;">{t("three_day_subtitle", lang)}</div>
</div>
<div style="text-align:right;">
<div style="font-size:32px;font-weight:800;color:#E91E63;">{days_analyzed}</div>
<div style="font-size:12px;color:#888;">{'天' if lang == 'zh' else 'days'}</div>
</div>
</div>
<div style="margin-top:8px;font-size:13px;color:#555;">{days_label}</div>
</div>
<!-- 双列布局 1:2 → 左侧:缺失营养素标签 | 右侧:食材建议 -->
<div style="display:grid;grid-template-columns:1fr 2fr;gap:12px;margin-bottom:16px;">
<!-- 左侧:持续不足的营养素 — 与报告一致的白色卡片 -->
<div style="background:#fff;border-radius:14px;padding:16px;
box-shadow:0 1px 6px rgba(0,0,0,0.04);border:1px solid #f0f0f0;">
<div style="font-weight:600;font-size:14px;margin-bottom:12px;color:#333;">
⚠️ {t("three_day_duration", lang)}
</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;">
{deficit_tags}
</div>
</div>
<!-- 右侧:食材补充建议 — 与报告建议区一致的金色渐变 -->
<div style="background:linear-gradient(135deg,#FFF8E1,#FFFDE7);border-radius:14px;padding:16px 20px;
border:1px solid #FFE082;">
<div style="font-weight:600;font-size:15px;margin-bottom:12px;color:#E65100;">
{t("three_day_suggestions", lang)}
</div>
{suggestion_rows}
</div>
</div>
<!-- 总结部分:结构化UI展示 -->
{f'''
<div style="background:#fff;border-radius:14px;padding:16px 20px;
box-shadow:0 1px 6px rgba(0,0,0,0.04);border:1px solid #f0f0f0;">
<div style="font-weight:600;font-size:15px;margin-bottom:12px;color:#333;">
📋 {t("three_day_summary", lang)}
</div>
{summary_sections_html}
</div>
''' if summary_sections_html else ''}
</div>"""
# 保留旧函数名作为代理(兼容外部调用),标记为 deprecated
def render_three_day_deficit_section(deficit_data: dict, lang="zh") -> str:
"""已弃用:三天分析已合并到 render_nutrition_report_html 中。保留此函数保持兼容。"""
return _render_deficit_section(deficit_data, lang)
# ============================================================
# 营养素中英文对照表
# ============================================================
NUTRIENT_EN_NAMES = {
"能量(MJ)": "Energy",
"蛋白质(g)": "Protein",
"钙(mg)": "Calcium",
"铁(mg)": "Iron",
"锌(mg)": "Zinc",
"维生素A(μg RAE)": "Vitamin A",
"维生素D(μg)": "Vitamin D",
"维生素E(mg α-TE)": "Vitamin E",
"维生素B1(mg)": "Vitamin B1",
"维生素B2(mg)": "Vitamin B2",
"叶酸(μg DFE)": "Folate",
"碘(μg)": "Iodine",
"镁(mg)": "Magnesium",
"硒(μg)": "Selenium",
"钾(mg)": "Potassium",
"钠(mg)": "Sodium",
"磷(mg)": "Phosphorus",
"维生素C(mg)": "Vitamin C",
"膳食纤维(g)": "Dietary Fiber",
}
def _nutrient_display(name_cn: str, lang: str) -> str:
"""返回营养素名的展示文本(中文模式显示中文+英文,英文模式显示英文)"""
en_name = NUTRIENT_EN_NAMES.get(name_cn, name_cn)
if lang == "zh":
return f"{name_cn} / {en_name}"
return en_name
# ============================================================
# 自定义 CSS 样式
# ============================================================
CUSTOM_CSS = """
/* =====================
PregoPal Modern UI v2
===================== */
/* 全局容器 */
.gradio-container {
max-width: 1200px !important;
margin: 0 auto !important;
padding: 4px 16px !important;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif !important;
}
/* 顶部标题禁止选中 */
h1, h2, h3 {
-webkit-user-select: none !important;
user-select: none !important;
}
/* ======== Modern Tab Bar (永不折叠) ======== */
.tabs {
display: flex !important;
flex-direction: column !important;
gap: 0 !important;
}
.tabs > .tab-nav {
display: flex !important;
flex-wrap: nowrap !important;
overflow-x: auto !important;
gap: 0 !important;
background: #f8f8fb !important;
border-radius: 16px 16px 0 0 !important;
padding: 4px 4px 0 4px !important;
-webkit-overflow-scrolling: touch !important;
}
.tabs > .tab-nav > button {
flex-shrink: 0 !important;
min-width: auto !important;
white-space: nowrap !important;
font-size: 14px !important;
font-weight: 500 !important;
padding: 10px 18px !important;
border-radius: 12px 12px 0 0 !important;
background: transparent !important;
border: none !important;
color: #888 !important;
transition: all 0.2s ease !important;
margin: 0 1px !important;
cursor: pointer !important;
}
.tabs > .tab-nav > button:hover {
background: rgba(233, 30, 99, 0.04) !important;
color: #E91E63 !important;
}
.tabs > .tab-nav > button.selected {
background: #fff !important;
color: #E91E63 !important;
font-weight: 600 !important;
box-shadow: 0 -2px 8px rgba(233, 30, 99, 0.08) !important;
}
/* ======== Cards (Glassmorphism) ======== */
.glass-card {
background: rgba(255,255,255,0.75) !important;
backdrop-filter: blur(12px) !important;
-webkit-backdrop-filter: blur(12px) !important;
border-radius: 16px !important;
padding: 20px 24px !important;
border: 1px solid rgba(255,255,255,0.8) !important;
box-shadow: 0 2px 16px rgba(233, 30, 99, 0.06) !important;
transition: all 0.3s ease !important;
}
.glass-card:hover {
box-shadow: 0 4px 24px rgba(233, 30, 99, 0.10) !important;
transform: translateY(-2px);
}
/* home cards (首页卡片) */
.home-card {
border-radius: 16px !important;
padding: 16px 20px !important;
background: #fff !important;
border: 1px solid #f0f0f0 !important;
box-shadow: 0 1px 8px rgba(0,0,0,0.04) !important;
transition: all 0.2s ease !important;
}
.home-card:hover {
box-shadow: 0 4px 20px rgba(233, 30, 99, 0.08) !important;
}
.home-card h3 {
margin: 0 0 6px 0 !important;
font-size: 16px !important;
}
.home-card p {
margin: 0 !important;
font-size: 14px !important;
color: #555 !important;
}
/* AI Thinking Box — div 版本(无 textarea 外壳) */
.thinking-box {
background: linear-gradient(135deg, #F3E5F5, #FFF) !important;
border: 1px solid #E1BEE7 !important;
border-radius: 12px !important;
padding: 12px 16px !important;
font-size: 14px !important;
color: #6A1B9A !important;
}
/* 语音按钮 — 纯 HTML 圆形按钮 hover/active 效果(主样式在 inline style 中) */
.voice-main-btn:hover {
transform: scale(1.05) !important;
box-shadow: 0 12px 40px rgba(255, 64, 129, 0.4) !important;
}
.voice-main-btn:active {
transform: scale(0.95) !important;
}
/* 按钮统一 */
button, .gr-button {
border-radius: 12px !important;
font-weight: 500 !important;
transition: all 0.2s ease !important;
}
input, textarea, .gr-textbox {
border-radius: 12px !important;
}
/* 消除 Gradio Group 默认嵌套边框 */
.gr-group, .gr-box {
border: none !important;
box-shadow: none !important;
}
.gr-group > .gr-box,
.gr-box > .gr-box {
border: none !important;
box-shadow: none !important;
}
/* ======== 消除所有Gradio容器的灰色填充和双重边框 ======== */
/* 消除所有 Group/Box 的灰色背景 */
.gr-group, .gr-box, .gr-form, .gr-panel {
background: transparent !important;
}
/* 消除所有文本输入区域的灰色填充 */
textarea, input[type="text"], input[type="number"], input[type="search"],
input[type="email"], input[type="password"], input[type="url"],
.gr-textarea, .gr-textbox, .gr-input {
background: #fff !important;
border-color: #e0e0e0 !important;
box-shadow: none !important;
}
/* 消除焦点时的双重外框 */
textarea:focus, input:focus, .gr-textarea:focus, .gr-textbox:focus {
outline: none !important;
border-color: #E91E63 !important;
box-shadow: 0 0 0 2px rgba(233, 30, 99, 0.15) !important;
}
/* Gradio button/input wrap 容器 — 去除额外边框 */
.gr-box > .gr-textarea, .gr-box > .gr-textbox,
.gr-form > .gr-box, .gr-group > .gr-box {
border: none !important;
background: transparent !important;
box-shadow: none !important;
}
/* 所有卡片容器白色背景 + 取消默认边框 */
.home-card, .home-card.gr-group, .home-card .gr-box {
background: #fff !important;
border: 1px solid #f0f0f0 !important;
border-radius: 16px !important;
box-shadow: 0 1px 8px rgba(0,0,0,0.04) !important;
}
/* 消除 Gr.Group 默认灰色内边 */
.gr-group {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
gap: 0 !important;
}
/* 消除 Trimester 卡片 group 的双层边框 */
.card-trimester.gr-group,
.card-trimester .gr-box {
border: 1px solid #f0f0f0 !important;
border-radius: 16px !important;
box-shadow: 0 1px 8px rgba(0,0,0,0.04) !important;
}
.card-trimester.gr-group .gr-box {
border: none !important;
}
/* 响应式调整 */
@media (max-width: 768px) {
.voice-main-btn {
width: 120px !important;
height: 120px !important;
font-size: 36px !important;
}
.gradio-container {
padding: 4px !important;
}
.tabs > .tab-nav > button {
font-size: 12px !important;
padding: 8px 12px !important;
}
}
"""