Spaces:
Runtime error
Runtime error
| """ | |
| 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; | |
| } | |
| } | |
| """ |