diff --git a/README.ja.md b/README.ja.md index 2d23852..981b4cc 100644 --- a/README.ja.md +++ b/README.ja.md @@ -27,7 +27,7 @@ | 機能 | 説明 | | :--- | :--- | | **スマートアップロード** | 衣類の写真をアップロードすると `rembg` で背景を自動除去し、AI ビジョンモデルでカテゴリ・色・スタイルを分析 | -| **天気連動スタイリング** | QWeather API と連携し、リアルタイムの天気に基づいたコーディネートを提案 | +| **天気連動スタイリング** | Open-Meteo の無料グローバル天気 API を利用し、リアルタイム天気に基づいたコーディネートを提案 | | **デジタルワードローブ** | 構造化されたビューで衣類を閲覧・検索・管理 | | **AI レコメンデーション** | Gemini や OpenAI 互換プロバイダーによるパーソナライズされたコーディネート生成 | | **レスポンシブ UI** | Tailwind CSS によるモダンなインターフェースで、デスクトップ・タブレット・モバイルに対応 | @@ -60,7 +60,6 @@ - **Node.js** `v20+`  |  **Python** `v3.10+` - [Google Gemini API Key](https://aistudio.google.com/app/apikey) または OpenAI 互換 API キー -- [QWeather API Key](https://console.qweather.com) ### 1. クローンと設定 diff --git a/README.md b/README.md index 47dd046..619f56f 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Upload clothing photos, remove backgrounds automatically, classify garments with | Feature | Description | | :--- | :--- | | **Smart Upload** | Upload clothing photos, auto-remove backgrounds with `rembg`, analyze category, color, and style via AI vision models | -| **Weather-Based Styling** | Integrates QWeather API to generate outfit suggestions based on real-time conditions | +| **Weather-Based Styling** | Integrates free global weather data (Open-Meteo) to generate outfit suggestions based on real-time conditions | | **Digital Wardrobe** | Browse, search, and manage your clothing in a structured wardrobe view | | **AI Recommendations** | Supports Gemini and OpenAI-compatible providers for personalized outfit generation | | **Responsive UI** | Optimized for desktop, tablet, and mobile with a modern Tailwind CSS interface | @@ -60,7 +60,6 @@ Upload clothing photos, remove backgrounds automatically, classify garments with - **Node.js** `v20+`  |  **Python** `v3.10+` - [Google Gemini API Key](https://aistudio.google.com/app/apikey) or an OpenAI-compatible API key -- [QWeather API Key](https://console.qweather.com) ### 1. Clone & Configure diff --git a/README.zh-CN.md b/README.zh-CN.md index a8198af..35b081d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -27,7 +27,7 @@ | 特性 | 描述 | | :--- | :--- | | **智能上传** | 上传衣服照片后,使用 `rembg` 自动去背景,并通过视觉模型识别类别、颜色和风格 | -| **天气穿搭** | 集成和风天气 API,根据实时天气生成更合适的穿搭建议 | +| **天气穿搭** | 集成 Open-Meteo 免费全球天气接口,根据实时天气生成更合适的穿搭建议 | | **虚拟衣柜** | 以结构化方式浏览、搜索和管理所有衣物 | | **AI 推荐** | 支持 Gemini 和 OpenAI 风格接口,用于生成个性化穿搭方案 | | **响应式界面** | 基于 Tailwind CSS,适配桌面、平板和手机 | @@ -60,7 +60,6 @@ - **Node.js** `v20+`  |  **Python** `v3.10+` - [Google Gemini API Key](https://aistudio.google.com/app/apikey) 或 OpenAI 兼容接口 Key -- [和风天气 API Key](https://console.qweather.com) ### 1. 克隆与配置 diff --git a/backend/api/__pycache__/upload.cpython-314.pyc b/backend/api/__pycache__/upload.cpython-314.pyc index 27275d7..3d0bbd3 100644 Binary files a/backend/api/__pycache__/upload.cpython-314.pyc and b/backend/api/__pycache__/upload.cpython-314.pyc differ diff --git a/backend/api/config.py b/backend/api/config.py index 2588c12..12a13cf 100644 --- a/backend/api/config.py +++ b/backend/api/config.py @@ -25,8 +25,7 @@ async def set_config(config_update: LLMConfigUpdate): model=config_update.model, removebg_api_key=config_update.removebg_api_key, bg_removal_method=config_update.bg_removal_method, - qweather_api_key=config_update.qweather_api_key, - qweather_api_host=config_update.qweather_api_host, + weather_location=config_update.weather_location, zodiac_sign=config_update.zodiac_sign ) return { @@ -34,6 +33,8 @@ async def set_config(config_update: LLMConfigUpdate): "message": "配置已更新", "config": get_masked_config() } + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/api/horoscope.py b/backend/api/horoscope.py index d002222..08d9744 100644 --- a/backend/api/horoscope.py +++ b/backend/api/horoscope.py @@ -7,7 +7,7 @@ from pydantic import BaseModel from services.horoscope import get_daily_horoscope -from services.weather import get_weather +from services.weather import get_weather, normalize_location_request, DEFAULT_LOCATION_QUERY router = APIRouter() @@ -23,24 +23,47 @@ class HoroscopeResponse(BaseModel): lucky_color: str lucky_number: int suggestion: str + source_provider: str + llm_status: str + llm_reasoning: str @router.get("/horoscope/daily", response_model=HoroscopeResponse) async def get_today_horoscope( location: str = Query( - default="101020100", - description="LocationID 或 经纬度坐标" + default=DEFAULT_LOCATION_QUERY, + description="城市名 或 经纬度坐标" ), + city: Optional[str] = Query(default=None, description="城市(结构化查询参数)"), + state: Optional[str] = Query(default=None, description="省/州(结构化查询参数)"), + country: Optional[str] = Query(default=None, description="国家(结构化查询参数)"), zodiac_sign: Optional[str] = Query( default=None, description="可选,若传入会覆盖设置中的星座" - ) + ), + include_inference: bool = Query( + default=True, + description="是否执行并返回 LLM 推理结果。false 时仅返回并缓存基础运势。" + ), ): """ 获取今日星座运势 """ - weather = await get_weather(location) + normalized_location, validation_error = normalize_location_request( + location=location, + city=city, + state=state, + country=country, + ) + if validation_error: + raise HTTPException(status_code=422, detail=validation_error) + + weather = await get_weather(normalized_location) if not weather: raise HTTPException(status_code=500, detail="获取天气信息失败") - return await get_daily_horoscope(weather=weather, zodiac_sign=zodiac_sign) + return await get_daily_horoscope( + weather=weather, + zodiac_sign=zodiac_sign, + include_inference=include_inference, + ) diff --git a/backend/api/recommendation.py b/backend/api/recommendation.py index ccdabd4..eb67ee7 100644 --- a/backend/api/recommendation.py +++ b/backend/api/recommendation.py @@ -4,9 +4,9 @@ """ from fastapi import APIRouter, HTTPException, Query from typing import Optional -from services.weather import get_weather +from services.weather import get_weather, normalize_location_request, DEFAULT_LOCATION_QUERY from services.recommendation import get_ai_recommendation -from pydantic import BaseModel +from pydantic import BaseModel, Field router = APIRouter() @@ -14,41 +14,56 @@ class RecommendationResponse(BaseModel): """推荐响应""" weather: dict + horoscope: Optional[dict] = None + temperature_rule: Optional[dict] = None recommendation_text: str + outfit_summary: Optional[str] = None + selection_reasons: Optional[dict] = None suggested_top: Optional[dict] = None suggested_bottom: Optional[dict] = None + suggested_shoes: Optional[dict] = None + suggested_accessories: list[dict] = Field(default_factory=list) + purchase_suggestions: list[dict] = Field(default_factory=list) @router.get("/recommendation", response_model=RecommendationResponse) async def get_outfit_recommendation( location: str = Query( - default="101020100", - description="LocationID 或 经纬度坐标(如 '116.41,39.92')" - ) + default=DEFAULT_LOCATION_QUERY, + description="城市名 或 经纬度坐标(如 '31.23,121.47' 或 '121.47,31.23')" + ), + city: Optional[str] = Query(default=None, description="城市(结构化查询参数)"), + state: Optional[str] = Query(default=None, description="省/州(结构化查询参数)"), + country: Optional[str] = Query(default=None, description="国家(结构化查询参数)"), + zodiac_sign: Optional[str] = Query( + default=None, + description="可选,临时指定星座(会覆盖设置中的星座)" + ), ): """ 获取AI穿搭推荐 参数: - location: LocationID(如 101010100=北京)或 经纬度坐标(如 116.41,39.92) - + location: 城市名(如 上海、Tokyo)或 经纬度坐标(如 31.23,121.47) 返回: 天气信息 + AI推荐文本 + 推荐的衣服和裤子 - - 常用城市 LocationID: - - 101010100: 北京 - - 101020100: 上海 - - 101280101: 广州 - - 101280601: 深圳 - - 101210101: 杭州 """ + normalized_location, validation_error = normalize_location_request( + location=location, + city=city, + state=state, + country=country, + ) + if validation_error: + raise HTTPException(status_code=422, detail=validation_error) + # 获取天气信息 - weather = await get_weather(location) + weather = await get_weather(normalized_location) if not weather: raise HTTPException(status_code=500, detail="获取天气信息失败") # 获取AI推荐 - recommendation = await get_ai_recommendation(weather) + recommendation = await get_ai_recommendation(weather, zodiac_sign=zodiac_sign) return recommendation diff --git a/backend/api/upload.py b/backend/api/upload.py index 8a79a90..5b2618b 100644 --- a/backend/api/upload.py +++ b/backend/api/upload.py @@ -9,7 +9,7 @@ from services.removebg import remove_background_api from services.openai_compatible import analyze_clothes_openai from storage.config_store import load_config -from domain.clothes import ClothesSemantics, ClothesCreate, ClothesItem +from domain.clothes import ClothesSemantics, ClothesCreate, ClothesItem, normalize_category_value from storage.db import add_clothes, get_clothes_by_id router = APIRouter() @@ -18,6 +18,8 @@ UPLOAD_DIR = Path(__file__).parent.parent / "uploads" UPLOAD_DIR.mkdir(exist_ok=True) +ALLOWED_CATEGORIES = {"top", "bottom", "shoes", "accessory"} + @router.post("/upload", response_model=ClothesItem) async def upload_image(file: UploadFile = File(...)): @@ -68,9 +70,13 @@ async def upload_image(file: UploadFile = File(...)): with open(filepath, "wb") as f: f.write(processed_bytes) + normalized_category = normalize_category_value(semantics.category) + if normalized_category not in ALLOWED_CATEGORIES: + normalized_category = "accessory" + # 保存到数据库 clothes_data = ClothesCreate( - category=semantics.category, + category=normalized_category, item=semantics.item, style_semantics=semantics.style_semantics, season_semantics=semantics.season_semantics, diff --git a/backend/api/wardrobe.py b/backend/api/wardrobe.py index 913b657..7329319 100644 --- a/backend/api/wardrobe.py +++ b/backend/api/wardrobe.py @@ -2,12 +2,11 @@ 衣柜 API - 获取和管理衣物 """ from fastapi import APIRouter, HTTPException -from typing import Optional from domain.clothes import ClothesItem, WardrobeResponse, ClothesCreate +from domain.clothes import normalize_category_value from storage.db import ( get_all_clothes, - get_clothes_by_category, get_clothes_by_id, delete_clothes, update_clothes @@ -21,18 +20,20 @@ async def get_wardrobe(): """ 获取整个衣柜 - 按 top/bottom/shoes 三类返回所有衣物 + 按 top/bottom/shoes/accessory 四类返回所有衣物 """ all_clothes = await get_all_clothes() - tops = [c for c in all_clothes if c.category == "top"] - bottoms = [c for c in all_clothes if c.category == "bottom"] - shoes = [c for c in all_clothes if c.category == "shoes"] + tops = [c for c in all_clothes if normalize_category_value(c.category) == "top"] + bottoms = [c for c in all_clothes if normalize_category_value(c.category) == "bottom"] + shoes = [c for c in all_clothes if normalize_category_value(c.category) == "shoes"] + accessories = [c for c in all_clothes if normalize_category_value(c.category) == "accessory"] return WardrobeResponse( tops=tops, bottoms=bottoms, - shoes=shoes + shoes=shoes, + accessories=accessories ) @@ -42,15 +43,17 @@ async def get_wardrobe_category(category: str): 按类别获取衣物 Args: - category: top, bottom, shoes + category: top, bottom, shoes, accessory """ - if category not in ["top", "bottom", "shoes"]: + category = normalize_category_value(category) + if category not in ["top", "bottom", "shoes", "accessory"]: raise HTTPException( status_code=400, - detail="类别必须是 top, bottom 或 shoes" + detail="类别必须是 top, bottom, shoes 或 accessory" ) - return await get_clothes_by_category(category) + all_clothes = await get_all_clothes() + return [item for item in all_clothes if normalize_category_value(item.category) == category] @router.get("/clothes/{clothes_id}", response_model=ClothesItem) diff --git a/backend/api/weather.py b/backend/api/weather.py index 2b2483b..b6387cb 100644 --- a/backend/api/weather.py +++ b/backend/api/weather.py @@ -10,6 +10,8 @@ get_season_from_weather, get_clothing_suggestion, search_city, + normalize_location_request, + DEFAULT_LOCATION_QUERY, WeatherInfo, WeatherResponse, CityInfo @@ -21,30 +23,36 @@ @router.get("/weather", response_model=WeatherInfo) async def get_current_weather( location: str = Query( - default="101020100", - description="LocationID 或 经纬度坐标(如 '116.41,39.92')" - ) + default=DEFAULT_LOCATION_QUERY, + description="城市名 或 经纬度坐标(如 '31.23,121.47' 或 '121.47,31.23')" + ), + city: Optional[str] = Query(default=None, description="城市(结构化查询参数)"), + state: Optional[str] = Query(default=None, description="省/州(结构化查询参数)"), + country: Optional[str] = Query(default=None, description="国家(结构化查询参数)"), ): """ 获取当前天气信息 参数: - location: LocationID(如 101010100=北京)或 经纬度坐标(如 116.41,39.92) + location: 城市名(如 上海、Tokyo)或 经纬度坐标(如 31.23,121.47) 返回: 简化的天气信息 - 常用城市 LocationID: - - 101010100: 北京 - - 101020100: 上海 - - 101280101: 广州 - - 101280601: 深圳 - - 101210101: 杭州 - - 更多城市 ID 可通过和风天气 GeoAPI 查询: - https://dev.qweather.com/docs/api/geoapi/ + 说明: + - 天气数据使用 Open-Meteo 免费全球接口 + - 无需配置天气 API Key """ - weather = await get_weather(location) + normalized_location, validation_error = normalize_location_request( + location=location, + city=city, + state=state, + country=country, + ) + if validation_error: + raise HTTPException(status_code=422, detail=validation_error) + + weather = await get_weather(normalized_location) if not weather: raise HTTPException(status_code=500, detail="获取天气信息失败") @@ -55,20 +63,32 @@ async def get_current_weather( @router.get("/weather/raw", response_model=WeatherResponse) async def get_raw_weather( location: str = Query( - default="101020100", - description="LocationID 或 经纬度坐标" - ) + default=DEFAULT_LOCATION_QUERY, + description="城市名 或 经纬度坐标" + ), + city: Optional[str] = Query(default=None, description="城市(结构化查询参数)"), + state: Optional[str] = Query(default=None, description="省/州(结构化查询参数)"), + country: Optional[str] = Query(default=None, description="国家(结构化查询参数)"), ): """ - 获取和风天气原始数据 + 获取天气原始数据(兼容旧响应结构) 参数: - location: LocationID 或 经纬度坐标 + location: 城市名 或 经纬度坐标 返回: - 完整的和风天气 API 响应数据 + 兼容 WeatherResponse 的原始数据 """ - weather = await get_qweather_now(location) + normalized_location, validation_error = normalize_location_request( + location=location, + city=city, + state=state, + country=country, + ) + if validation_error: + raise HTTPException(status_code=422, detail=validation_error) + + weather = await get_qweather_now(normalized_location) if not weather: raise HTTPException(status_code=500, detail="获取天气信息失败") @@ -79,20 +99,32 @@ async def get_raw_weather( @router.get("/weather/suggestion") async def get_weather_suggestion( location: str = Query( - default="101020100", - description="LocationID 或 经纬度坐标" - ) + default=DEFAULT_LOCATION_QUERY, + description="城市名 或 经纬度坐标" + ), + city: Optional[str] = Query(default=None, description="城市(结构化查询参数)"), + state: Optional[str] = Query(default=None, description="省/州(结构化查询参数)"), + country: Optional[str] = Query(default=None, description="国家(结构化查询参数)"), ): """ 获取基于天气的穿搭建议 参数: - location: LocationID 或 经纬度坐标 + location: 城市名 或 经纬度坐标 返回: 天气信息 + 穿搭建议 + 适合季节 """ - weather = await get_weather(location) + normalized_location, validation_error = normalize_location_request( + location=location, + city=city, + state=state, + country=country, + ) + if validation_error: + raise HTTPException(status_code=422, detail=validation_error) + + weather = await get_weather(normalized_location) if not weather: raise HTTPException(status_code=500, detail="获取天气信息失败") @@ -131,7 +163,7 @@ async def search_cities( limit: 返回结果数量(默认10,最多20) 返回: - 城市信息列表,包含城市名称、LocationID等 + 城市信息列表,包含城市名称和坐标 ID(经度,纬度) 示例: - /api/cities?query=北京 diff --git a/backend/domain/clothes.py b/backend/domain/clothes.py index 4ca933f..cf673b8 100644 --- a/backend/domain/clothes.py +++ b/backend/domain/clothes.py @@ -5,10 +5,26 @@ from typing import List, Optional from datetime import datetime +CATEGORY_ALIASES = { + "top": {"top", "tops", "上衣", "上装", "外套"}, + "bottom": {"bottom", "bottoms", "裤子", "下装", "裙子"}, + "shoes": {"shoes", "shoe", "鞋", "鞋子", "鞋履"}, + "accessory": {"accessory", "accessories", "饰品", "配饰", "首饰", "珠宝"}, +} + + +def normalize_category_value(category: str) -> str: + """将类别归一化为 top/bottom/shoes/accessory。""" + value = (category or "").strip().lower() + for canonical, aliases in CATEGORY_ALIASES.items(): + if value in aliases: + return canonical + return value + class ClothesSemantics(BaseModel): """Gemini Vision 返回的语义数据结构""" - category: str # top | bottom | shoes + category: str # top | bottom | shoes | accessory item: str # 具体衣物名称 style_semantics: List[str] # 风格标签 season_semantics: List[str] # 季节 @@ -48,3 +64,4 @@ class WardrobeResponse(BaseModel): tops: List[ClothesItem] bottoms: List[ClothesItem] shoes: List[ClothesItem] + accessories: List[ClothesItem] diff --git a/backend/domain/config.py b/backend/domain/config.py index 7840f74..d31ced5 100644 --- a/backend/domain/config.py +++ b/backend/domain/config.py @@ -13,9 +13,8 @@ class LLMConfig(BaseModel): # remove.bg 配置 removebg_api_key: str = "mcigdPJZy9oU6c2SMiEwj9VA" bg_removal_method: Literal["local", "removebg"] = "removebg" # 本地 rembg 或 remove.bg API - # 和风天气 API 配置 - qweather_api_key: str = "baaa1bb687294acc949bf2a979e0084e" - qweather_api_host: str = "devapi.qweather.com" # 免费版: devapi.qweather.com | 付费版: api.qweather.com + # 默认天气城市(用于首页与推荐页) + weather_location: str = "上海, 上海市, 中国" # 用户星座配置(用于首页运势) zodiac_sign: str = "" @@ -27,8 +26,7 @@ class LLMConfigUpdate(BaseModel): model: Optional[str] = None removebg_api_key: Optional[str] = None bg_removal_method: Optional[Literal["local", "removebg"]] = None - qweather_api_key: Optional[str] = None - qweather_api_host: Optional[str] = None + weather_location: Optional[str] = None zodiac_sign: Optional[str] = None diff --git a/backend/domain/prompts.py b/backend/domain/prompts.py index cd867ce..d68a978 100644 --- a/backend/domain/prompts.py +++ b/backend/domain/prompts.py @@ -13,7 +13,7 @@ JSON Schema: { - "category": "top | bottom | shoes", + "category": "top | bottom | shoes | accessory", "item": "具体衣物名称,如 T恤、牛仔裤、运动鞋", "style_semantics": ["风格标签,如 休闲、正式、运动"], "season_semantics": ["春", "夏", "秋", "冬"], @@ -22,5 +22,7 @@ "description": "一句话语义总结" } +当图片主体是首饰/配件(如项链、手链、帽子、围巾、手表、眼镜、腰带)时,category 必须是 accessory。 + 如果无法判断,请填 "unknown"。 """ diff --git a/backend/services/horoscope.py b/backend/services/horoscope.py index 361343a..5b9d80b 100644 --- a/backend/services/horoscope.py +++ b/backend/services/horoscope.py @@ -1,16 +1,24 @@ """ 星座运势服务 -根据日期、天气和用户星座生成今日运势 +1) 先拉取并存储 aztro 原始数据 +2) 再按需执行 LLM 推理 """ +import os from datetime import datetime from typing import Optional import httpx from storage.config_store import load_config -from services.openai_compatible import extract_json_from_response +from storage.db import ( + get_horoscope_record, + upsert_horoscope_source, + update_horoscope_inference, +) from services.weather import WeatherInfo +AZTRO_API_URL = os.getenv("AZTRO_API_URL", "https://aztro.sameerkumar.website").rstrip("/") + ZODIAC_NAMES = { "aries": "白羊座", "taurus": "金牛座", @@ -127,80 +135,121 @@ def build_weather_tip(weather: WeatherInfo) -> str: return "整体体感平稳,穿搭上可兼顾舒适与层次感。" -def fallback_horoscope(sign_key: str, weather: WeatherInfo) -> dict: - """在未配置 LLM 或调用失败时提供基础运势。""" - today = datetime.now().strftime("%Y-%m-%d") +def _to_lucky_number(raw_value: object, default: int = 7) -> int: + try: + lucky_number = int(raw_value) + except Exception: + lucky_number = default + return min(max(lucky_number, 1), 99) + + +def fallback_horoscope_source(sign_key: str, weather: WeatherInfo, today: str) -> dict: + """aztro 不可用时的兜底源数据。""" day_seed = datetime.now().toordinal() sign_index = list(ZODIAC_NAMES.keys()).index(sign_key) - lucky_number = ((day_seed + sign_index * 7) % 89) + 11 trait = ZODIAC_TRAITS.get(sign_key, "节奏感") - weather_tip = build_weather_tip(weather) return { - "date": today, - "summary": f"今天你的关键词是{trait},把精力集中在一件最重要的事上,会有更稳定的收获。", + "current_date": today, + "date_range": "", + "description": f"今天你的关键词是{trait},把精力集中在一件最重要的事上,会有更稳定的收获。", "mood": "稳中有进", - "lucky_color": DEFAULT_COLORS.get(sign_key, "浅蓝色"), + "color": DEFAULT_COLORS.get(sign_key, "浅蓝色"), "lucky_number": lucky_number, - "suggestion": weather_tip + "lucky_time": "", + "compatibility": "", + "weather_tip": build_weather_tip(weather), } -def sanitize_horoscope_payload(payload: dict, weather_tip: str) -> dict: - """清洗 LLM 输出,保证字段完整可用。""" - summary = str(payload.get("summary", "")).strip() or "今天整体节奏平稳,适合把注意力放在核心目标。" +def sanitize_aztro_payload(payload: dict, sign_key: str, today: str, weather: WeatherInfo) -> dict: + """清洗 aztro 输出,保证字段完整可用。""" + description = str(payload.get("description", "")).strip() or "今天整体节奏平稳,适合把注意力放在核心目标。" mood = str(payload.get("mood", "")).strip() or "平稳" - lucky_color = str(payload.get("lucky_color", "")).strip() or "浅蓝色" - suggestion = str(payload.get("suggestion", "")).strip() or weather_tip - - raw_number = payload.get("lucky_number", 7) - try: - lucky_number = int(raw_number) - except Exception: - lucky_number = 7 - lucky_number = min(max(lucky_number, 1), 99) + color = str(payload.get("color", "")).strip() or DEFAULT_COLORS.get(sign_key, "浅蓝色") + lucky_time = str(payload.get("lucky_time", "")).strip() + compatibility = str(payload.get("compatibility", "")).strip() + date_range = str(payload.get("date_range", "")).strip() + current_date = str(payload.get("current_date", "")).strip() or today return { - "summary": summary, + "current_date": current_date, + "date_range": date_range, + "description": description, "mood": mood, - "lucky_color": lucky_color, - "lucky_number": lucky_number, - "suggestion": suggestion + "color": color, + "lucky_number": _to_lucky_number(payload.get("lucky_number", 7)), + "lucky_time": lucky_time, + "compatibility": compatibility, + "weather_tip": build_weather_tip(weather), } -async def generate_llm_horoscope(sign_key: str, weather: WeatherInfo) -> Optional[dict]: - """调用 OpenAI 兼容接口生成运势文本。""" +async def fetch_aztro_horoscope(sign_key: str, today: str, weather: WeatherInfo) -> Optional[dict]: + """获取 aztro 今日运势。""" + url = f"{AZTRO_API_URL}/?sign={sign_key}&day=today" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post(url, headers={"Accept": "application/json"}) + if response.status_code in (404, 405): + response = await client.get(url, headers={"Accept": "application/json"}) + + if response.status_code != 200: + print(f"aztro 请求失败: {response.status_code} {response.text[:200]}") + return None + + data = response.json() + if not isinstance(data, dict): + print("aztro 返回格式异常") + return None + + return sanitize_aztro_payload(data, sign_key=sign_key, today=today, weather=weather) + except Exception as exc: + print(f"aztro 调用异常: {exc}") + return None + + +async def generate_llm_reasoning( + sign_key: str, + zodiac_name: str, + weather: WeatherInfo, + source_payload: dict, +) -> tuple[Optional[str], str, str]: + """ + 基于已存储的原始运势数据做 LLM 推理。 + Returns: + (推理文本, 状态, 错误信息) + """ config = load_config() if not config.api_key: - return None + return None, "skipped", "未配置 LLM API Key" api_base = config.api_base.rstrip("/") if not api_base.endswith("/v1"): api_base = f"{api_base}/v1" - today = datetime.now().strftime("%Y-%m-%d") - zodiac_name = ZODIAC_NAMES.get(sign_key, sign_key) - weather_tip = build_weather_tip(weather) - prompt = f""" -你是一个风格克制、实用导向的星座顾问。请为用户生成今日运势。 - -日期:{today} -星座:{zodiac_name} -天气:{weather.condition},气温 {weather.temperature}°C,体感 {weather.feelsLike}°C,湿度 {weather.humidity}% - -请输出 JSON,字段必须完整: -{{ - "summary": "40-80字,描述今日整体运势,避免绝对化和恐吓表述", - "mood": "2-6字情绪关键词", - "lucky_color": "1个颜色词", - "lucky_number": 1-99 的整数, - "suggestion": "结合天气和运势给出 1 条可执行建议(20-50字)" -}} - -只返回 JSON,不要代码块,不要额外说明。 +你是一名理性、可执行导向的运势分析助手。请基于以下星座原始数据给出穿搭场景推理。 + +星座:{zodiac_name}({sign_key}) +aztro 原始数据: +- 日期:{source_payload.get('current_date', '')} +- 描述:{source_payload.get('description', '')} +- 心情:{source_payload.get('mood', '')} +- 幸运色:{source_payload.get('color', '')} +- 幸运数字:{source_payload.get('lucky_number', '')} +- 幸运时段:{source_payload.get('lucky_time', '')} +- 契合星座:{source_payload.get('compatibility', '')} + +天气: +- {weather.condition},温度 {weather.temperature}°C,体感 {weather.feelsLike}°C,湿度 {weather.humidity}% + +输出要求: +1. 输出 2-3 句中文 +2. 给出可执行的穿搭/配色建议 +3. 不要绝对化、不要神秘化 +4. 不要代码块,不要 JSON """ payload = { @@ -208,46 +257,99 @@ async def generate_llm_horoscope(sign_key: str, weather: WeatherInfo) -> Optiona "messages": [ { "role": "system", - "content": "你输出结构化 JSON,内容友好、理性、可执行。" + "content": "你做简洁、务实的推理,避免夸张表达。" }, { "role": "user", "content": prompt } ], - "temperature": 0.7 + "temperature": 0.6 } try: - async with httpx.AsyncClient(timeout=12.0) as client: + async with httpx.AsyncClient(timeout=15.0) as client: response = await client.post( f"{api_base}/chat/completions", headers={ "Authorization": f"Bearer {config.api_key}", - "Content-Type": "application/json" + "Content-Type": "application/json", }, - json=payload + json=payload, ) if response.status_code != 200: - print(f"LLM 星座运势请求失败: {response.status_code} {response.text[:200]}") - return None - - content = response.json()["choices"][0]["message"]["content"] - parsed = extract_json_from_response(content) - return sanitize_horoscope_payload(parsed, weather_tip) + err = f"LLM 星座推理请求失败: {response.status_code}" + print(err) + return None, "failed", err + + content = ( + response.json() + .get("choices", [{}])[0] + .get("message", {}) + .get("content", "") + .strip() + ) + if not content: + return None, "failed", "LLM 返回空内容" + + return content, "done", "" except Exception as exc: - print(f"LLM 星座运势调用异常: {exc}") - return None + err = f"LLM 星座推理异常: {exc}" + print(err) + return None, "failed", err + + +def build_suggestion(weather: WeatherInfo, source_payload: dict) -> str: + weather_tip = str(source_payload.get("weather_tip", "")).strip() or build_weather_tip(weather) + lucky_time = str(source_payload.get("lucky_time", "")).strip() + if lucky_time: + return f"幸运时段:{lucky_time}。{weather_tip}" + return weather_tip + + +def build_horoscope_response( + *, + today: str, + sign_key: str, + source_provider: str, + source_payload: dict, + weather: WeatherInfo, + llm_status: str, + llm_reasoning: str, +) -> dict: + zodiac_name = ZODIAC_NAMES.get(sign_key, sign_key) + summary = str(source_payload.get("description", "")).strip() or "今天整体节奏平稳,建议聚焦最重要的一件事。" + mood = str(source_payload.get("mood", "")).strip() or "平稳" + lucky_color = str(source_payload.get("color", "")).strip() or DEFAULT_COLORS.get(sign_key, "浅蓝色") + lucky_number = _to_lucky_number(source_payload.get("lucky_number", 7)) + + return { + "date": today, + "zodiac_sign": sign_key, + "zodiac_name": zodiac_name, + "is_configured": True, + "summary": summary, + "mood": mood, + "lucky_color": lucky_color, + "lucky_number": lucky_number, + "suggestion": build_suggestion(weather, source_payload), + "source_provider": source_provider, + "llm_status": llm_status, + "llm_reasoning": llm_reasoning or "", + } -async def get_daily_horoscope(weather: WeatherInfo, zodiac_sign: Optional[str] = None) -> dict: - """获取今日星座运势。""" +async def get_daily_horoscope( + weather: WeatherInfo, + zodiac_sign: Optional[str] = None, + include_inference: bool = True, +) -> dict: + """获取今日星座运势(先源数据,后可选推理)。""" today = datetime.now().strftime("%Y-%m-%d") config = load_config() sign_key = normalize_zodiac_sign(zodiac_sign) or normalize_zodiac_sign(config.zodiac_sign) - if not sign_key: return { "date": today, @@ -258,21 +360,63 @@ async def get_daily_horoscope(weather: WeatherInfo, zodiac_sign: Optional[str] = "mood": "待设置", "lucky_color": "云白色", "lucky_number": 6, - "suggestion": build_weather_tip(weather) + "suggestion": build_weather_tip(weather), + "source_provider": "none", + "llm_status": "skipped", + "llm_reasoning": "", } - llm_result = await generate_llm_horoscope(sign_key, weather) - if not llm_result: - llm_result = fallback_horoscope(sign_key, weather) + zodiac_name = ZODIAC_NAMES.get(sign_key, sign_key) - return { - "date": today, - "zodiac_sign": sign_key, - "zodiac_name": ZODIAC_NAMES.get(sign_key, sign_key), - "is_configured": True, - "summary": llm_result["summary"], - "mood": llm_result["mood"], - "lucky_color": llm_result["lucky_color"], - "lucky_number": llm_result["lucky_number"], - "suggestion": llm_result["suggestion"] - } + cached = await get_horoscope_record(record_date=today, zodiac_sign=sign_key) + if cached: + source_payload = cached.get("source_payload") or {} + source_provider = cached.get("source_provider", "cached") + llm_status = cached.get("llm_status", "pending") + llm_reasoning = cached.get("llm_reasoning", "") + record_id = int(cached["id"]) + else: + source_payload = await fetch_aztro_horoscope(sign_key=sign_key, today=today, weather=weather) + source_provider = "aztro" + if not source_payload: + source_payload = fallback_horoscope_source(sign_key=sign_key, weather=weather, today=today) + source_provider = "fallback" + + record_id = await upsert_horoscope_source( + record_date=today, + zodiac_sign=sign_key, + zodiac_name=zodiac_name, + source_provider=source_provider, + source_payload=source_payload, + ) + llm_status = "pending" + llm_reasoning = "" + + # 每天只推理一次:只有当天首次记录的 pending 状态才执行推理 + if include_inference and llm_status == "pending": + reasoning, status, err = await generate_llm_reasoning( + sign_key=sign_key, + zodiac_name=zodiac_name, + weather=weather, + source_payload=source_payload, + ) + if reasoning: + llm_reasoning = reasoning + llm_status = status + + await update_horoscope_inference( + record_id=record_id, + llm_status=llm_status, + llm_reasoning=llm_reasoning, + llm_error=err, + ) + + return build_horoscope_response( + today=today, + sign_key=sign_key, + source_provider=source_provider, + source_payload=source_payload, + weather=weather, + llm_status=llm_status, + llm_reasoning=llm_reasoning, + ) diff --git a/backend/services/recommendation.py b/backend/services/recommendation.py index 156966a..2c7241b 100644 --- a/backend/services/recommendation.py +++ b/backend/services/recommendation.py @@ -1,31 +1,280 @@ """ AI穿搭推荐服务 -基于天气和衣橱数据生成个性化推荐 +基于天气、星座运势和衣橱数据生成个性化推荐 """ import httpx -from typing import Optional +from typing import Any + +from domain.clothes import normalize_category_value +from services.horoscope import get_daily_horoscope +from services.weather import WeatherInfo from storage.config_store import load_config -from services.weather import WeatherInfo, get_season_from_weather from storage.db import get_all_clothes +SEASON_ALIASES = { + "春": {"春", "春季", "spring"}, + "夏": {"夏", "夏季", "summer"}, + "秋": {"秋", "秋季", "autumn", "fall"}, + "冬": {"冬", "冬季", "winter"}, +} + +ZODIAC_STYLE_HINTS = { + "aries": {"运动", "街头", "休闲", "sport", "casual"}, + "taurus": {"简约", "质感", "通勤", "minimal", "business"}, + "gemini": {"轻松", "层次", "日常", "casual", "daily"}, + "cancer": {"柔和", "舒适", "居家", "comfort", "daily"}, + "leo": {"亮眼", "时髦", "正式", "formal", "fashion"}, + "virgo": {"利落", "简约", "通勤", "minimal", "business"}, + "libra": {"平衡", "优雅", "约会", "elegant", "formal"}, + "scorpio": {"深色", "干练", "都市", "formal", "vintage"}, + "sagittarius": {"户外", "运动", "旅行", "sport", "casual"}, + "capricorn": {"通勤", "商务", "经典", "business", "formal"}, + "aquarius": {"个性", "创意", "街头", "street", "casual"}, + "pisces": {"柔和", "文艺", "轻盈", "vintage", "casual"}, +} + +ACCESSORY_KEYWORDS = ( + "帽", "帽子", "围巾", "项链", "耳环", "耳钉", "手链", "戒指", + "手表", "墨镜", "眼镜", "领带", "腰带", "cap", "hat", "scarf", + "necklace", "earring", "bracelet", "ring", "watch", "sunglasses", + "tie", "belt" +) + + +def normalize_seasons(raw_values: list[str]) -> set[str]: + normalized: set[str] = set() + for value in raw_values or []: + token = (value or "").strip().lower() + if not token: + continue + for canonical, aliases in SEASON_ALIASES.items(): + if token in aliases: + normalized.add(canonical) + break + return normalized + + +def build_temperature_profile(weather: WeatherInfo) -> dict[str, Any]: + feels_like = weather.feelsLike + + if feels_like <= 5: + return { + "label": "寒冷", + "allowed_seasons": {"冬"}, + "advice": "优先保暖,建议选择厚实上衣、长裤和防滑保暖鞋。", + "purchase_hints": { + "top": ["羽绒服", "保暖内搭", "羊毛衫"], + "bottom": ["加绒长裤", "保暖打底裤"], + "shoes": ["保暖短靴", "防滑鞋"], + "accessory": ["围巾", "针织帽", "手套"], + }, + } + if feels_like <= 14: + return { + "label": "偏凉", + "allowed_seasons": {"秋", "冬"}, + "advice": "建议轻量叠穿,外套和长裤优先,鞋履尽量包裹性更强。", + "purchase_hints": { + "top": ["风衣", "针织衫", "卫衣"], + "bottom": ["直筒长裤", "牛仔裤"], + "shoes": ["运动鞋", "乐福鞋"], + "accessory": ["薄围巾", "金属手表"], + }, + } + if feels_like <= 24: + return { + "label": "舒适", + "allowed_seasons": {"春", "秋"}, + "advice": "温度适中,保持透气与层次,注意早晚温差。", + "purchase_hints": { + "top": ["衬衫", "薄针织", "轻薄夹克"], + "bottom": ["休闲长裤", "九分裤"], + "shoes": ["小白鞋", "休闲鞋"], + "accessory": ["细链项链", "简约手链"], + }, + } + if feels_like <= 30: + return { + "label": "偏热", + "allowed_seasons": {"春", "夏"}, + "advice": "优先透气轻薄单品,避免厚重面料。", + "purchase_hints": { + "top": ["短袖T恤", "亚麻衬衫"], + "bottom": ["轻薄休闲裤", "短裤"], + "shoes": ["透气运动鞋", "凉鞋"], + "accessory": ["棒球帽", "太阳镜"], + }, + } + return { + "label": "炎热", + "allowed_seasons": {"夏"}, + "advice": "尽量选择吸汗速干和透气面料,减少叠穿。", + "purchase_hints": { + "top": ["速干短袖", "背心"], + "bottom": ["速干短裤", "轻薄短裤"], + "shoes": ["凉鞋", "网面运动鞋"], + "accessory": ["遮阳帽", "太阳镜"], + }, + } + + +def is_temperature_compatible(item: dict, allowed_seasons: set[str]) -> bool: + item_seasons = normalize_seasons(item.get("season_semantics", [])) + if not item_seasons: + return False + return bool(item_seasons & allowed_seasons) + + +def build_color_tokens(raw_color: str) -> list[str]: + base = (raw_color or "").strip().lower() + if not base: + return [] + compact = base.replace("色", "").replace("系", "").replace(" ", "") + tokens = {base, compact} + if len(compact) >= 2: + tokens.add(compact[:2]) + return [token for token in tokens if token] + + +def score_item( + item: dict, + category: str, + horoscope: dict, + weather: WeatherInfo, + temperature_profile: dict[str, Any], +) -> tuple[int, list[str]]: + score = 5 + reasons = [f"季节标签匹配{temperature_profile['label']}温度策略"] + + lucky_color = horoscope.get("lucky_color", "") + color_tokens = build_color_tokens(lucky_color) + searchable_text = " ".join([ + str(item.get("item", "")), + str(item.get("color_semantics", "")), + str(item.get("description", "")), + ]).lower() + + if color_tokens and any(token in searchable_text for token in color_tokens): + score += 4 + reasons.append(f"颜色接近今日幸运色「{lucky_color}」") + + sign_key = horoscope.get("zodiac_sign", "") + style_hints = ZODIAC_STYLE_HINTS.get(sign_key, set()) + style_values = { + str(v).strip().lower() + for v in item.get("style_semantics", []) + if str(v).strip() + } + if style_hints and (style_values & style_hints): + score += 3 + reasons.append("风格与今日星座运势倾向一致") + + if category == "shoes" and ("雨" in weather.condition or "雪" in weather.condition): + if any(keyword in searchable_text for keyword in ("防水", "短靴", "boot", "靴")): + score += 2 + reasons.append("天气有降水,鞋履更注重防滑/防水") + + return score, reasons + + +def pick_best_item( + candidates: list[dict], + category: str, + horoscope: dict, + weather: WeatherInfo, + temperature_profile: dict[str, Any], +) -> tuple[dict | None, str]: + if not candidates: + return None, "" + + best_item = None + best_score = -1 + best_reasons: list[str] = [] + + for item in candidates: + score, reasons = score_item(item, category, horoscope, weather, temperature_profile) + if score > best_score: + best_score = score + best_item = item + best_reasons = reasons + + return best_item, ";".join(best_reasons) + + +def build_purchase_suggestion( + category: str, + temperature_profile: dict[str, Any], + horoscope: dict, +) -> dict[str, Any]: + names = { + "top": "上装", + "bottom": "下装", + "shoes": "鞋履", + } + hints = temperature_profile["purchase_hints"].get(category, []) + zodiac_name = horoscope.get("zodiac_name", "今日运势") + lucky_color = horoscope.get("lucky_color", "中性色") + return { + "category": category, + "title": f"建议补充{names.get(category, category)}", + "reason": f"衣柜中暂无满足当前温度策略的{names.get(category, category)},建议优先补齐温度刚需单品。", + "keywords": hints, + "horoscope_hint": f"{zodiac_name}今日幸运色为{lucky_color},可优先选择该色系。", + } + + +def extract_wardrobe_accessories(all_clothes: list[dict]) -> list[dict]: + accessories = [] + for item in all_clothes: + if normalize_category_value(str(item.get("category", ""))) == "accessory": + accessories.append(item) + continue + text = f"{item.get('item', '')} {item.get('description', '')}".lower() + if any(keyword in text for keyword in ACCESSORY_KEYWORDS): + accessories.append(item) + return accessories + + +def build_purchase_accessories( + temperature_profile: dict[str, Any], + horoscope: dict, +) -> list[dict]: + lucky_color = horoscope.get("lucky_color", "中性色") + zodiac_name = horoscope.get("zodiac_name", "今日运势") + base_items = temperature_profile["purchase_hints"].get("accessory", ["简约手链", "通勤手表"]) + return [ + { + "name": accessory_name, + "reason": f"结合{zodiac_name}与天气体感,选择{lucky_color}或同色系点缀更协调。", + "from_wardrobe": False, + "should_buy": True, + } + for accessory_name in base_items[:2] + ] -async def get_ai_recommendation(weather: WeatherInfo) -> dict: + +def build_recommendation_summary( + selected: dict[str, dict | None], + purchase_suggestions: list[dict], +) -> str: + outfit_parts = [] + for category in ("top", "bottom", "shoes"): + item = selected.get(category) + if item: + outfit_parts.append(f"{category}: {item.get('item', '未命名')}") + + summary = ",".join(outfit_parts) if outfit_parts else "暂无可直接搭配的单品。" + if purchase_suggestions: + summary += f" 需要补充 {len(purchase_suggestions)} 类温度必需单品。" + return summary + + +async def get_ai_recommendation(weather: WeatherInfo, zodiac_sign: str | None = None) -> dict: """ - 根据天气获取AI穿搭推荐 - - Args: - weather: 天气信息 - - Returns: - 推荐信息(包含文本和推荐的衣物) + 根据天气和星座运势获取AI穿搭推荐。 + 温度约束为硬条件:衣柜单品必须满足温度策略,不满足时给出购买兜底。 """ - # 获取适合的季节 - seasons = get_season_from_weather(weather) - - # 从数据库获取所有衣物 all_clothes_items = await get_all_clothes() - - # 转换为字典格式 all_clothes = [ { "id": item.id, @@ -36,35 +285,88 @@ async def get_ai_recommendation(weather: WeatherInfo) -> dict: "usage_semantics": item.usage_semantics, "color_semantics": item.color_semantics, "description": item.description, - "image_url": item.image_url + "image_url": item.image_url, } for item in all_clothes_items ] - - # 过滤出适合当前季节的衣物 - suitable_tops = [ - c for c in all_clothes - if c.get("category") == "上衣" and any(s in c.get("season_semantics", []) for s in seasons) - ] - suitable_bottoms = [ - c for c in all_clothes - if c.get("category") == "裤子" and any(s in c.get("season_semantics", []) for s in seasons) - ] - - # 如果没有合适的衣物,就从全部中选择 - if not suitable_tops: - suitable_tops = [c for c in all_clothes if c.get("category") == "上衣"] - if not suitable_bottoms: - suitable_bottoms = [c for c in all_clothes if c.get("category") == "裤子"] - - # 向LLM请求推荐文本 - recommendation_text = await get_llm_recommendation(weather, seasons, suitable_tops, suitable_bottoms) - - # 随机选择一件上衣和裤子 - import random - suggested_top = random.choice(suitable_tops) if suitable_tops else None - suggested_bottom = random.choice(suitable_bottoms) if suitable_bottoms else None - + + horoscope = await get_daily_horoscope( + weather=weather, + zodiac_sign=zodiac_sign, + include_inference=True, + ) + temperature_profile = build_temperature_profile(weather) + + by_category: dict[str, list[dict]] = {"top": [], "bottom": [], "shoes": []} + for item in all_clothes: + category = normalize_category_value(str(item.get("category", ""))) + if category not in by_category: + continue + if is_temperature_compatible(item, temperature_profile["allowed_seasons"]): + by_category[category].append(item) + + selected: dict[str, dict | None] = {} + selection_reasons: dict[str, str] = {} + purchase_suggestions: list[dict] = [] + + for category in ("top", "bottom", "shoes"): + chosen, reason = pick_best_item( + by_category[category], + category=category, + horoscope=horoscope, + weather=weather, + temperature_profile=temperature_profile, + ) + selected[category] = chosen + selection_reasons[category] = reason + if chosen is None: + purchase_suggestions.append( + build_purchase_suggestion(category, temperature_profile, horoscope) + ) + + accessory_candidates = extract_wardrobe_accessories(all_clothes) + compatible_accessories = [] + for item in accessory_candidates: + # 饰品优先按季节匹配;无季节标签时保留可选。 + if is_temperature_compatible(item, temperature_profile["allowed_seasons"]) or not item.get("season_semantics"): + compatible_accessories.append(item) + + suggested_accessories: list[dict[str, Any]] = [] + if compatible_accessories: + scored = [] + for item in compatible_accessories: + score, reasons = score_item( + item, + category="accessory", + horoscope=horoscope, + weather=weather, + temperature_profile=temperature_profile, + ) + scored.append((score, item, ";".join(reasons))) + scored.sort(key=lambda value: value[0], reverse=True) + for _, item, reason in scored[:2]: + suggested_accessories.append( + { + "name": item.get("item", "饰品"), + "reason": reason or "与今日运势风格匹配", + "from_wardrobe": True, + "should_buy": False, + "item": item, + } + ) + else: + suggested_accessories = build_purchase_accessories(temperature_profile, horoscope) + + recommendation_text = await get_llm_recommendation( + weather=weather, + horoscope=horoscope, + temperature_profile=temperature_profile, + selected=selected, + selection_reasons=selection_reasons, + purchase_suggestions=purchase_suggestions, + suggested_accessories=suggested_accessories, + ) + return { "weather": { "temperature": weather.temperature, @@ -74,135 +376,198 @@ async def get_ai_recommendation(weather: WeatherInfo) -> dict: "humidity": weather.humidity, "windDir": weather.windDir, "windScale": weather.windScale, - "obsTime": weather.obsTime + "location": weather.location, + "obsTime": weather.obsTime, + }, + "horoscope": horoscope, + "temperature_rule": { + "label": temperature_profile["label"], + "allowed_seasons": sorted(list(temperature_profile["allowed_seasons"])), + "advice": temperature_profile["advice"], }, "recommendation_text": recommendation_text, - "suggested_top": suggested_top, - "suggested_bottom": suggested_bottom + "outfit_summary": build_recommendation_summary(selected, purchase_suggestions), + "selection_reasons": selection_reasons, + "suggested_top": selected.get("top"), + "suggested_bottom": selected.get("bottom"), + "suggested_shoes": selected.get("shoes"), + "suggested_accessories": suggested_accessories, + "purchase_suggestions": purchase_suggestions, } async def get_llm_recommendation( - weather: WeatherInfo, - seasons: list[str], - tops: list[dict], - bottoms: list[dict] + weather: WeatherInfo, + horoscope: dict, + temperature_profile: dict[str, Any], + selected: dict[str, dict | None], + selection_reasons: dict[str, str], + purchase_suggestions: list[dict], + suggested_accessories: list[dict], ) -> str: """ - 使用LLM生成个性化推荐文本 - - Args: - weather: 天气信息 - seasons: 适合的季节 - tops: 可用的上衣列表 - bottoms: 可用的裤子列表 - - Returns: - 推荐文本 + 使用 LLM 生成推荐文案,失败时回退到规则文本。 """ config = load_config() - if not config.api_key: - # 如果没有配置API,返回基础推荐 - return generate_basic_recommendation(weather, seasons) - - # 构建提示词 + return generate_basic_recommendation( + weather=weather, + horoscope=horoscope, + temperature_profile=temperature_profile, + selected=selected, + selection_reasons=selection_reasons, + purchase_suggestions=purchase_suggestions, + suggested_accessories=suggested_accessories, + ) + + def item_name(category: str) -> str: + item = selected.get(category) + return item["item"] if item else "缺失" + + purchase_lines = "\n".join( + [f"- {entry['title']}:{', '.join(entry.get('keywords', []))}" for entry in purchase_suggestions] + ) or "- 无需购买补充" + + accessory_lines = "\n".join( + [ + f"- {entry.get('name', '饰品')}({'衣柜已有' if entry.get('from_wardrobe') else '建议补购'}):{entry.get('reason', '')}" + for entry in suggested_accessories + ] + ) or "- 暂无饰品建议" + prompt = f""" -你是一位专业的时尚穿搭顾问。请根据以下天气信息,为用户提供穿搭建议: +你是一位务实的穿搭顾问。请基于以下信息输出一段 Markdown 推荐: + +天气: +- 温度 {weather.temperature}°C,体感 {weather.feelsLike}°C,{weather.condition} +- 湿度 {weather.humidity}% / 风力 {weather.windScale}级 -当前天气: -- 温度:{weather.temperature}°C -- 体感温度:{weather.feelsLike}°C -- 天气状况:{weather.condition} -- 湿度:{weather.humidity}% -- 风向风力:{weather.windDir} {weather.windScale}级 +星座运势: +- 星座:{horoscope.get('zodiac_name', '未设置')} +- 今日关键词:{horoscope.get('mood', '平稳')} +- 幸运色:{horoscope.get('lucky_color', '中性色')} +- 运势摘要:{horoscope.get('summary', '')} -适合的季节:{', '.join(seasons)} +温度策略: +- 档位:{temperature_profile['label']} +- 可用季节标签:{', '.join(sorted(list(temperature_profile['allowed_seasons'])))} +- 策略建议:{temperature_profile['advice']} -用户衣橱中有 {len(tops)} 件上衣和 {len(bottoms)} 件裤子可供选择。 +从衣柜挑选结果(仅保留温度匹配单品): +- 上装:{item_name('top')}({selection_reasons.get('top', '未命中')}) +- 下装:{item_name('bottom')}({selection_reasons.get('bottom', '未命中')}) +- 鞋履:{item_name('shoes')}({selection_reasons.get('shoes', '未命中')}) +缺失补购建议: +{purchase_lines} -请生成一段友好、实用的穿搭推荐(150词左右),使用 Markdown 格式以便于阅读: -1. **针对当前天气的穿搭建议**(使用粗体强调重点衣物) -2. **需要注意的事项**(如防晒、保暖、防雨等) -3. **穿搭风格建议** +饰品建议: +{accessory_lines} -请直接输出 Markdown 格式的文本,不要包含代码块标记(如 ```markdown)。 +输出要求: +1. 先给出今日穿搭结论(2-3句) +2. 明确指出哪些是衣柜现有、哪些需要补购 +3. 补充1条与星座运势相关的饰品搭配建议 +4. 不要输出代码块 """ - + try: - # 确保 api_base 格式正确 api_base = config.api_base.rstrip("/") if not api_base.endswith("/v1"): - api_base = api_base + "/v1" - - url = f"{api_base}/chat/completions" - + api_base = f"{api_base}/v1" + payload = { "model": config.model, "messages": [ - {"role": "system", "content": "你是一位专业的时尚穿搭顾问,擅长根据天气提供实用的穿搭建议。"}, - {"role": "user", "content": prompt} + { + "role": "system", + "content": "你是专业穿搭顾问,强调可执行建议和温度适配。", + }, + {"role": "user", "content": prompt}, ], - "temperature": 0.7 + "temperature": 0.6, } - - async with httpx.AsyncClient(timeout=30.0) as client: + + async with httpx.AsyncClient(timeout=20.0) as client: response = await client.post( - url, + f"{api_base}/chat/completions", headers={ "Authorization": f"Bearer {config.api_key}", - "Content-Type": "application/json" + "Content-Type": "application/json", }, - json=payload + json=payload, + ) + + if response.status_code != 200: + print(f"LLM API请求失败: {response.status_code}") + return generate_basic_recommendation( + weather=weather, + horoscope=horoscope, + temperature_profile=temperature_profile, + selected=selected, + selection_reasons=selection_reasons, + purchase_suggestions=purchase_suggestions, + suggested_accessories=suggested_accessories, ) - - if response.status_code == 200: - data = response.json() - return data["choices"][0]["message"]["content"].strip() - else: - print(f"LLM API请求失败: {response.status_code}") - return generate_basic_recommendation(weather, seasons) - - except Exception as e: - print(f"调用LLM失败: {e}") - return generate_basic_recommendation(weather, seasons) - - -def generate_basic_recommendation(weather: WeatherInfo, seasons: list[str]) -> str: + + data = response.json() + return data["choices"][0]["message"]["content"].strip() + except Exception as exc: + print(f"调用LLM失败: {exc}") + return generate_basic_recommendation( + weather=weather, + horoscope=horoscope, + temperature_profile=temperature_profile, + selected=selected, + selection_reasons=selection_reasons, + purchase_suggestions=purchase_suggestions, + suggested_accessories=suggested_accessories, + ) + + +def generate_basic_recommendation( + weather: WeatherInfo, + horoscope: dict, + temperature_profile: dict[str, Any], + selected: dict[str, dict | None], + selection_reasons: dict[str, str], + purchase_suggestions: list[dict], + suggested_accessories: list[dict], +) -> str: """ - 生成基础推荐(不使用LLM) - - Args: - weather: 天气信息 - seasons: 适合的季节 - - Returns: - 基础推荐文本 + 生成规则版推荐文本(不依赖 LLM)。 """ - temp = weather.feelsLike - condition = weather.condition - - # 基于温度的建议 - if temp < 0: - base_text = "🧥 今天非常寒冷,建议穿厚羽绒服、棉衣、毛衣等保暖衣物,搭配厚裤子和保暖鞋。" - elif temp < 10: - base_text = "🧥 今天比较冷,建议穿风衣、大衣、夹克等外套,内搭长袖衬衫或卫衣,搭配长裤。" - elif temp < 20: - base_text = "👔 今天温度适中,建议穿薄外套、长袖衬衫、卫衣等,可以采用叠穿搭配,方便调节。" - elif temp < 28: - base_text = "👕 今天天气舒适,建议穿短袖、薄长袖等轻便衣物,搭配休闲裤或牛仔裤。" - else: - base_text = "👕 今天天气炎热,建议穿短袖、短裤等夏季清凉衣物,选择透气吸汗的面料。" - - # 根据天气状况补充建议 - if "雨" in condition: - base_text += " 今天有雨,记得带伞,避免穿浅色衣物,选择防水鞋。☂️" - elif "雪" in condition: - base_text += " 今天有雪,注意防滑保暖,选择防水防滑的鞋子。❄️" - elif "晴" in condition and temp > 25: - base_text += " 今天阳光充足,外出注意防晒,可以搭配太阳镜和遮阳帽。☀️" - elif "阴" in condition or "云" in condition: - base_text += " 今天多云,建议准备一件薄外套以备不时之需。☁️" - - return base_text + lines = [ + f"### 今日穿搭结论", + f"体感温度约 **{weather.feelsLike}°C**,按 **{temperature_profile['label']}** 策略搭配:{temperature_profile['advice']}", + "", + "### 衣柜命中单品", + ] + + category_names = {"top": "上装", "bottom": "下装", "shoes": "鞋履"} + for category in ("top", "bottom", "shoes"): + item = selected.get(category) + if item: + lines.append( + f"- {category_names[category]}:**{item.get('item', '未命名')}**({selection_reasons.get(category, '温度匹配')})" + ) + else: + lines.append(f"- {category_names[category]}:暂无温度匹配单品") + + if purchase_suggestions: + lines.extend(["", "### 补购清单(兜底)"]) + for entry in purchase_suggestions: + keywords = "、".join(entry.get("keywords", [])) or "基础款" + lines.append(f"- {entry['title']}:{keywords}。{entry['horoscope_hint']}") + + if suggested_accessories: + lines.extend(["", "### 饰品建议(结合星座运势)"]) + for entry in suggested_accessories[:2]: + source = "衣柜已有" if entry.get("from_wardrobe") else "建议补购" + lines.append(f"- **{entry.get('name', '饰品')}**({source}):{entry.get('reason', '')}") + + horoscope_tip = horoscope.get("suggestion", "") + if horoscope_tip: + lines.extend(["", f"运势提醒:{horoscope_tip}"]) + + return "\n".join(lines) diff --git a/backend/services/weather.py b/backend/services/weather.py index 2e5f35c..d488697 100644 --- a/backend/services/weather.py +++ b/backend/services/weather.py @@ -1,97 +1,90 @@ """ -天气服务 - 和风天气 API 集成 -文档: https://dev.qweather.com/docs/api/weather/weather-now/ -GeoAPI: https://dev.qweather.com/docs/api/geoapi/city-lookup/ +天气服务 - Open-Meteo 免费全球天气接口(无需 API Key) +文档: https://open-meteo.com/ """ -import httpx -import os import re from typing import Optional, List + +import httpx from pydantic import BaseModel class CityInfo(BaseModel): """城市信息""" - name: str # 城市名称 - id: str # LocationID - adm1: str # 省份 - adm2: str # 市 - country: str # 国家 - lat: str # 纬度 - lon: str # 经度 + name: str + id: str + adm1: str + adm2: str + country: str + lat: str + lon: str class WeatherNow(BaseModel): - """实时天气数据""" - obsTime: str # 数据观测时间 - temp: str # 温度,默认单位:摄氏度 - feelsLike: str # 体感温度 - icon: str # 天气状况图标代码 - text: str # 天气状况的文字描述 - wind360: str # 风向360角度 - windDir: str # 风向 - windScale: str # 风力等级 - windSpeed: str # 风速,公里/小时 - humidity: str # 相对湿度,百分比数值 - precip: str # 过去1小时降水量,默认单位:毫米 - pressure: str # 大气压强,默认单位:百帕 - vis: str # 能见度,默认单位:公里 - cloud: Optional[str] = None # 云量,百分比数值 - dew: Optional[str] = None # 露点温度 + """实时天气数据(兼容旧响应结构)""" + obsTime: str + temp: str + feelsLike: str + icon: str + text: str + wind360: str + windDir: str + windScale: str + windSpeed: str + humidity: str + precip: str + pressure: str + vis: str + cloud: Optional[str] = None + dew: Optional[str] = None class WeatherResponse(BaseModel): - """和风天气 API 响应""" - code: str # 状态码 - updateTime: str # API最近更新时间 - fxLink: str # 响应式页面链接 - now: WeatherNow # 实时天气数据 + """天气 API 原始响应(兼容旧响应结构)""" + code: str + updateTime: str + fxLink: str + now: WeatherNow class WeatherInfo(BaseModel): """简化的天气信息(用于应用)""" - temperature: float # 温度 - feelsLike: float # 体感温度 - condition: str # 天气状况文字 - icon: str # 天气图标代码 - humidity: float # 湿度 - windDir: str # 风向 - windScale: str # 风力等级 - location: str # 位置 - obsTime: str # 观测时间 - - -# 常用城市列表(免费API降级方案) + temperature: float + feelsLike: float + condition: str + icon: str + humidity: float + windDir: str + windScale: str + location: str + obsTime: str + + +# 兼容老版本 LocationID 输入,并作为地理编码失败时的兜底城市列表 COMMON_CITIES = [ - {"name": "北京", "adm1": "北京市", "country": "中国", "id": "101010100", "keywords": ["beijing", "北京", "bj"]}, - {"name": "上海", "adm1": "上海市", "country": "中国", "id": "101020100", "keywords": ["shanghai", "上海", "sh"]}, - {"name": "广州", "adm1": "广东省", "country": "中国", "id": "101280101", "keywords": ["guangzhou", "广州", "gz"]}, - {"name": "深圳", "adm1": "广东省", "country": "中国", "id": "101280601", "keywords": ["shenzhen", "深圳", "sz"]}, - {"name": "杭州", "adm1": "浙江省", "country": "中国", "id": "101210101", "keywords": ["hangzhou", "杭州", "hz"]}, - {"name": "成都", "adm1": "四川省", "country": "中国", "id": "101270101", "keywords": ["chengdu", "成都", "cd"]}, - {"name": "重庆", "adm1": "重庆市", "country": "中国", "id": "101040100", "keywords": ["chongqing", "重庆", "cq"]}, - {"name": "武汉", "adm1": "湖北省", "country": "中国", "id": "101200101", "keywords": ["wuhan", "武汉", "wh"]}, - {"name": "西安", "adm1": "陕西省", "country": "中国", "id": "101110101", "keywords": ["xian", "西安", "xa"]}, - {"name": "南京", "adm1": "江苏省", "country": "中国", "id": "101190101", "keywords": ["nanjing", "南京", "nj"]}, - {"name": "天津", "adm1": "天津市", "country": "中国", "id": "101030100", "keywords": ["tianjin", "天津", "tj"]}, - {"name": "苏州", "adm1": "江苏省", "country": "中国", "id": "101190401", "keywords": ["suzhou", "苏州", "su"]}, - {"name": "长沙", "adm1": "湖南省", "country": "中国", "id": "101250101", "keywords": ["changsha", "长沙", "cs"]}, - {"name": "郑州", "adm1": "河南省", "country": "中国", "id": "101180101", "keywords": ["zhengzhou", "郑州", "zz"]}, - {"name": "济南", "adm1": "山东省", "country": "中国", "id": "101120101", "keywords": ["jinan", "济南", "jn"]}, - {"name": "青岛", "adm1": "山东省", "country": "中国", "id": "101120201", "keywords": ["qingdao", "青岛", "qd"]}, - {"name": "厦门", "adm1": "福建省", "country": "中国", "id": "101230201", "keywords": ["xiamen", "厦门", "xm"]}, - {"name": "大连", "adm1": "辽宁省", "country": "中国", "id": "101070201", "keywords": ["dalian", "大连", "dl"]}, - {"name": "沈阳", "adm1": "辽宁省", "country": "中国", "id": "101070101", "keywords": ["shenyang", "沈阳", "sy"]}, - {"name": "哈尔滨", "adm1": "黑龙江", "country": "中国", "id": "101050101", "keywords": ["haerbin", "哈尔滨", "heb"]}, + {"name": "北京", "adm1": "北京市", "country": "中国", "legacy_id": "101010100", "lat": "39.9042", "lon": "116.4074", "keywords": ["beijing", "北京", "bj"]}, + {"name": "上海", "adm1": "上海市", "country": "中国", "legacy_id": "101020100", "lat": "31.2304", "lon": "121.4737", "keywords": ["shanghai", "上海", "sh"]}, + {"name": "广州", "adm1": "广东省", "country": "中国", "legacy_id": "101280101", "lat": "23.1291", "lon": "113.2644", "keywords": ["guangzhou", "广州", "gz"]}, + {"name": "深圳", "adm1": "广东省", "country": "中国", "legacy_id": "101280601", "lat": "22.5431", "lon": "114.0579", "keywords": ["shenzhen", "深圳", "sz"]}, + {"name": "杭州", "adm1": "浙江省", "country": "中国", "legacy_id": "101210101", "lat": "30.2741", "lon": "120.1551", "keywords": ["hangzhou", "杭州", "hz"]}, + {"name": "成都", "adm1": "四川省", "country": "中国", "legacy_id": "101270101", "lat": "30.5728", "lon": "104.0668", "keywords": ["chengdu", "成都", "cd"]}, + {"name": "重庆", "adm1": "重庆市", "country": "中国", "legacy_id": "101040100", "lat": "29.5630", "lon": "106.5516", "keywords": ["chongqing", "重庆", "cq"]}, + {"name": "武汉", "adm1": "湖北省", "country": "中国", "legacy_id": "101200101", "lat": "30.5928", "lon": "114.3055", "keywords": ["wuhan", "武汉", "wh"]}, + {"name": "西安", "adm1": "陕西省", "country": "中国", "legacy_id": "101110101", "lat": "34.3416", "lon": "108.9398", "keywords": ["xian", "西安", "xa"]}, + {"name": "南京", "adm1": "江苏省", "country": "中国", "legacy_id": "101190101", "lat": "32.0603", "lon": "118.7969", "keywords": ["nanjing", "南京", "nj"]}, ] +LEGACY_CITY_BY_ID = {city["legacy_id"]: city for city in COMMON_CITIES} + LOCATION_SUFFIXES = ( "特别行政区", "自治区", "自治州", "地区", "盟", "省", "市", "区", "县" ) +LOCATION_PART_SEPARATOR_REGEX = re.compile(r"[,,]+") +DEFAULT_LOCATION_QUERY = "上海, 上海市, 中国" def normalize_location_query(query: str) -> str: - """归一化地区输入,提升省市区县等写法的匹配率。""" + """归一化地区输入,提升匹配率。""" normalized = (query or "").strip().lower() normalized = re.sub(r"[\s,,/·\-]+", "", normalized) @@ -102,17 +95,105 @@ def normalize_location_query(query: str) -> str: return normalized +def is_complete_text_location(location: str) -> bool: + """ + 判断文本地点是否足够完整(如:南京, 江苏, 中国)。 + """ + parts = [ + part.strip() + for part in LOCATION_PART_SEPARATOR_REGEX.split((location or "").strip()) + if part.strip() + ] + return len(parts) >= 3 + + +def validate_location_input(location: str) -> Optional[str]: + """ + 校验地点输入。返回 None 表示合法,否则返回错误文案。 + """ + raw_location = (location or "").strip() + if not raw_location: + return None + + if is_location_id(raw_location) or is_coordinate_location(raw_location): + return None + + if not is_complete_text_location(raw_location): + return "地点格式不完整,请使用“城市, 省/州, 国家”格式,例如:南京, 江苏, 中国" + + return None + + +def normalize_location_request( + location: Optional[str] = None, + city: Optional[str] = None, + state: Optional[str] = None, + country: Optional[str] = None, +) -> tuple[str, Optional[str]]: + """ + 统一处理地点请求: + - 优先使用分字段 city/state/country 组装查询 + - 否则回退到 location 文本 + 返回 (normalized_location, validation_error) + """ + raw_location = (location or "").strip() + city_value = (city or "").strip() + state_value = (state or "").strip() + country_value = (country or "").strip() + + has_structured_parts = any([city_value, state_value, country_value]) + if has_structured_parts: + if not city_value or not state_value or not country_value: + return "", "使用分字段查询时,请同时提供 city、state、country。" + + structured_location = ", ".join( + part for part in (city_value, state_value, country_value) if part + ) + return structured_location, validate_location_input(structured_location) + + if not raw_location: + return DEFAULT_LOCATION_QUERY, None + + return raw_location, validate_location_input(raw_location) + + def is_location_id(location: str) -> bool: - return bool(re.fullmatch(r"\d{9}", location.strip())) + return bool(re.fullmatch(r"\d{9}", (location or "").strip())) def is_coordinate_location(location: str) -> bool: - return bool(re.fullmatch(r"\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*", location.strip())) + return bool(re.fullmatch(r"\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*", (location or "").strip())) + + +def parse_coordinate_location(location: str) -> Optional[tuple[float, float]]: + """ + 解析输入坐标为 (纬度, 经度)。 + 兼容 "纬度,经度" 和老输入格式 "经度,纬度"。 + """ + if not is_coordinate_location(location): + return None + + first_raw, second_raw = [part.strip() for part in location.split(",", maxsplit=1)] + first = float(first_raw) + second = float(second_raw) + + # 优先按纬度,经度解析 + if -90 <= first <= 90 and -180 <= second <= 180: + return first, second + + # 兼容历史格式: 经度,纬度 + if -180 <= first <= 180 and -90 <= second <= 90: + return second, first + + return None + + +def format_coordinate_id(latitude: float, longitude: float) -> str: + return f"{longitude:.4f},{latitude:.4f}" def format_city_display_name(city: CityInfo) -> str: parts = [] - if city.name: parts.append(city.name) @@ -126,19 +207,19 @@ def format_city_display_name(city: CityInfo) -> str: return " · ".join(parts) -def city_matches_query(city: CityInfo, query: str) -> bool: - return city_match_score(city, query) > 0 - - def city_match_score(city: CityInfo, query: str) -> int: normalized_query = normalize_location_query(query) + if not normalized_query: + return 0 + candidates = [ city.name, city.adm1, city.adm2, + city.country, f"{city.adm1}{city.name}", f"{city.adm2}{city.name}", - f"{city.adm1}{city.adm2}{city.name}", + f"{city.country}{city.adm1}{city.name}", ] best_score = 0 @@ -146,6 +227,7 @@ def city_match_score(city: CityInfo, query: str) -> int: normalized_candidate = normalize_location_query(candidate or "") if not normalized_candidate: continue + if normalized_query == normalized_candidate: best_score = max(best_score, 100) elif normalized_query in normalized_candidate: @@ -163,86 +245,97 @@ def city_match_score(city: CityInfo, query: str) -> int: return best_score +def city_matches_query(city: CityInfo, query: str) -> bool: + return city_match_score(city, query) > 0 + + +def _city_from_common(city_data: dict) -> CityInfo: + return CityInfo( + name=city_data["name"], + id=format_coordinate_id(float(city_data["lat"]), float(city_data["lon"])), + adm1=city_data["adm1"], + adm2=city_data["name"], + country=city_data["country"], + lat=str(city_data["lat"]), + lon=str(city_data["lon"]), + ) + + async def search_city(query: str, limit: int = 10) -> List[CityInfo]: """ 搜索城市(支持模糊查询) - 优先使用和风天气GeoAPI,如果失败则使用预定义城市列表 - - Args: - query: 城市名称关键词(支持中文、拼音) - limit: 返回结果数量限制 - - Returns: - 城市信息列表 + 优先使用 Open-Meteo 地理编码 API,失败则回退到内置城市列表。 """ normalized_query = normalize_location_query(query) if not normalized_query: return [] - # 优先从配置系统读取 API Key + # 兼容老版 LocationID 输入 + if is_location_id(query): + legacy_city = LEGACY_CITY_BY_ID.get(query.strip()) + if legacy_city: + return [_city_from_common(legacy_city)] + + # 坐标输入直接返回一个虚拟城市项,便于前端复用现有流程 + parsed_coordinate = parse_coordinate_location(query) + if parsed_coordinate: + latitude, longitude = parsed_coordinate + return [ + CityInfo( + name="坐标定位", + id=format_coordinate_id(latitude, longitude), + adm1="", + adm2="", + country="", + lat=f"{latitude}", + lon=f"{longitude}", + ) + ] + + # Open-Meteo Geocoding try: - from storage.config_store import load_config - config = load_config() - api_key = config.qweather_api_key - api_host = config.qweather_api_host - except Exception: - api_key = os.getenv("QWEATHER_API_KEY") - api_host = os.getenv("QWEATHER_API_HOST", "devapi.qweather.com") - - # 如果有API Key,尝试使用GeoAPI - if api_key and api_key != "your_qweather_api_key_here": - try: - url = f"https://{api_host}/geo/v2/city/lookup" - params = { - "location": query.strip(), - "number": limit, - "lang": "zh", - "key": api_key - } - - async with httpx.AsyncClient() as client: - response = await client.get(url, params=params, timeout=10.0) - - if response.status_code == 200: - data = response.json() - if data.get("code") == "200" and data.get("location"): - cities = [] - for location in data.get("location", []): - city = CityInfo( - name=location.get("name"), - id=location.get("id"), - adm1=location.get("adm1"), - adm2=location.get("adm2"), - country=location.get("country"), - lat=location.get("lat"), - lon=location.get("lon") - ) - if city_matches_query(city, query): - cities.append(city) - - if cities: - cities.sort(key=lambda city: city_match_score(city, query), reverse=True) - return cities[:limit] - - # GeoAPI 返回为空或排序不理想时,退回原始结果 - cities = [] - for location in data.get("location", []): - cities.append(CityInfo( - name=location.get("name"), - id=location.get("id"), - adm1=location.get("adm1"), - adm2=location.get("adm2"), - country=location.get("country"), - lat=location.get("lat"), - lon=location.get("lon") - )) - return cities - except Exception as e: - print(f"⚠️ GeoAPI调用失败,使用预定义城市列表: {e}") - - # 降级方案:使用预定义城市列表进行模糊搜索 - matched_cities = [] - + async with httpx.AsyncClient() as client: + response = await client.get( + "https://geocoding-api.open-meteo.com/v1/search", + params={ + "name": query.strip(), + "count": min(max(limit, 1), 20), + "language": "zh", + "format": "json", + }, + timeout=10.0, + ) + response.raise_for_status() + payload = response.json() + + cities: List[CityInfo] = [] + for row in payload.get("results", []): + latitude = row.get("latitude") + longitude = row.get("longitude") + if latitude is None or longitude is None: + continue + + city = CityInfo( + name=str(row.get("name") or "未知地区"), + id=format_coordinate_id(float(latitude), float(longitude)), + adm1=str(row.get("admin1") or ""), + adm2=str(row.get("admin2") or ""), + country=str(row.get("country") or ""), + lat=str(latitude), + lon=str(longitude), + ) + + if city_matches_query(city, query): + cities.append(city) + + if cities: + cities.sort(key=lambda city: city_match_score(city, query), reverse=True) + return cities[:limit] + except Exception as e: + print(f"⚠️ Geocoding 查询失败,使用内置城市兜底: {e}") + + # 回退方案:内置城市模糊匹配 + matched_cities: List[CityInfo] = [] for city_data in COMMON_CITIES: keyword_matched = any( normalized_query in normalize_location_query(keyword) @@ -257,15 +350,7 @@ async def search_city(query: str, limit: int = 10) -> List[CityInfo]: ) if keyword_matched or region_matched: - matched_cities.append(CityInfo( - name=city_data["name"], - id=city_data["id"], - adm1=city_data["adm1"], - adm2=city_data["name"], - country=city_data["country"], - lat="0", # 预定义列表不包含坐标 - lon="0" - )) + matched_cities.append(_city_from_common(city_data)) matched_cities.sort(key=lambda city: city_match_score(city, query), reverse=True) return matched_cities[:limit] @@ -273,15 +358,33 @@ async def search_city(query: str, limit: int = 10) -> List[CityInfo]: async def resolve_location(location: str) -> tuple[str, str]: """ - 将用户输入解析为和风天气可识别的 location 参数,并返回显示名称。 + 解析用户输入为 "经度,纬度" 的坐标字符串,并返回展示用地区名。 """ raw_location = (location or "").strip() if not raw_location: - return "101020100", "上海" - - if is_location_id(raw_location) or is_coordinate_location(raw_location): + shanghai = LEGACY_CITY_BY_ID["101020100"] + shanghai_city = _city_from_common(shanghai) + return format_coordinate_id(float(shanghai["lat"]), float(shanghai["lon"])), format_city_display_name(shanghai_city) + + validation_error = validate_location_input(raw_location) + if validation_error: + raise ValueError(validation_error) + + if is_location_id(raw_location): + city_data = LEGACY_CITY_BY_ID.get(raw_location) + if city_data: + city = _city_from_common(city_data) + return ( + format_coordinate_id(float(city_data["lat"]), float(city_data["lon"])), + format_city_display_name(city), + ) return raw_location, raw_location + parsed_coordinate = parse_coordinate_location(raw_location) + if parsed_coordinate: + latitude, longitude = parsed_coordinate + return format_coordinate_id(latitude, longitude), raw_location + cities = await search_city(raw_location, limit=1) if cities: city = cities[0] @@ -290,74 +393,147 @@ async def resolve_location(location: str) -> tuple[str, str]: return raw_location, raw_location -async def get_qweather_now(location: str) -> Optional[WeatherResponse]: - """ - 调用和风天气 API 获取实时天气 - - Args: - location: LocationID 或 经纬度坐标(逗号分隔,如 "116.41,39.92") - - Returns: - WeatherResponse 或 None(失败时) - - 示例: - - location="101010100" (北京的LocationID) - - location="116.41,39.92" (经纬度坐标) - """ - # 优先从配置系统读取 API Key - try: - from storage.config_store import load_config - config = load_config() - api_key = config.qweather_api_key - api_host = config.qweather_api_host - except Exception: - # 回退到环境变量 - api_key = os.getenv("QWEATHER_API_KEY") - api_host = os.getenv("QWEATHER_API_HOST", "devapi.qweather.com") - - if not api_key or api_key == "your_qweather_api_key_here": - print("⚠️ 和风天气 API Key 未配置,请在前端设置界面或 .env 文件中配置") - return None - - url = f"https://{api_host}/v7/weather/now" +def wind_direction_text(degrees: float) -> str: + labels = ["北风", "东北风", "东风", "东南风", "南风", "西南风", "西风", "西北风"] + index = int(((degrees % 360) + 22.5) // 45) % 8 + return labels[index] + + +def wind_speed_to_beaufort(speed_kmh: float) -> int: + # Beaufort 风级阈值(km/h) + thresholds = [1, 6, 12, 20, 29, 39, 50, 62, 75, 89, 103, 118] + for level, threshold in enumerate(thresholds): + if speed_kmh < threshold: + return level + return 12 + + +def map_weather_code(code: int, is_day: int) -> tuple[str, str]: + # 复用前端既有的 QWeather 图标编码映射,避免改 UI。 + if code == 0: + return ("晴", "100" if is_day else "150") + if code == 1: + return ("晴间多云", "101" if is_day else "150") + if code == 2: + return ("多云", "102") + if code == 3: + return ("阴", "104") + if code in (45, 48): + return ("雾", "501") + if code in (51, 53, 55): + return ("毛毛雨", "305") + if code in (56, 57): + return ("冻雨", "314") + if code in (61, 63, 65, 66, 67): + return ("雨", "306") + if code in (71, 73, 75, 77): + return ("降雪", "400") + if code in (80, 81, 82): + return ("阵雨", "309") + if code in (85, 86): + return ("阵雪", "404") + if code in (95, 96, 99): + return ("雷暴", "302") + return ("未知", "999") + + +async def _fetch_open_meteo_now(latitude: float, longitude: float) -> Optional[WeatherResponse]: params = { - "location": location, - "key": api_key + "latitude": latitude, + "longitude": longitude, + "timezone": "auto", + "current": ( + "temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,is_day," + "wind_speed_10m,wind_direction_10m,precipitation,pressure_msl,cloud_cover,dew_point_2m" + ), } - + try: async with httpx.AsyncClient() as client: - response = await client.get(url, params=params, timeout=10.0) + response = await client.get( + "https://api.open-meteo.com/v1/forecast", + params=params, + timeout=10.0, + ) response.raise_for_status() - data = response.json() - - if data.get("code") != "200": - print(f"❌ 和风天气 API 错误: code={data.get('code')}") - return None - - return WeatherResponse(**data) - + payload = response.json() + + current = payload.get("current") or {} + + temperature = float(current.get("temperature_2m", 20.0)) + feels_like = float(current.get("apparent_temperature", temperature)) + humidity = float(current.get("relative_humidity_2m", 60.0)) + wind_speed = float(current.get("wind_speed_10m", 8.0)) + wind_degrees = float(current.get("wind_direction_10m", 180.0)) + precip = float(current.get("precipitation", 0.0)) + pressure = float(current.get("pressure_msl", 1013.0)) + cloud = current.get("cloud_cover") + dew = current.get("dew_point_2m") + obs_time = str(current.get("time") or "") + + weather_code = int(current.get("weather_code", 0)) + is_day = int(current.get("is_day", 1)) + condition_text, icon_code = map_weather_code(weather_code, is_day) + + wind_dir_text = wind_direction_text(wind_degrees) + wind_scale = str(wind_speed_to_beaufort(wind_speed)) + + now = WeatherNow( + obsTime=obs_time, + temp=str(round(temperature, 1)), + feelsLike=str(round(feels_like, 1)), + icon=icon_code, + text=condition_text, + wind360=str(round(wind_degrees, 1)), + windDir=wind_dir_text, + windScale=wind_scale, + windSpeed=str(round(wind_speed, 1)), + humidity=str(round(humidity, 1)), + precip=str(round(precip, 2)), + pressure=str(round(pressure, 1)), + vis="10", + cloud=str(cloud) if cloud is not None else None, + dew=str(round(float(dew), 1)) if dew is not None else None, + ) + + return WeatherResponse( + code="200", + updateTime=obs_time, + fxLink="https://open-meteo.com/", + now=now, + ) except Exception as e: - print(f"❌ 获取天气信息失败: {e}") + print(f"❌ 获取 Open-Meteo 天气信息失败: {e}") return None -async def get_weather(location: str = "101020100") -> Optional[WeatherInfo]: +async def get_qweather_now(location: str) -> Optional[WeatherResponse]: + """ + 兼容旧函数名:实际改为调用 Open-Meteo 免费天气接口。 + """ + resolved_location, _ = await resolve_location(location) + coordinate = parse_coordinate_location(resolved_location) + if not coordinate: + return None + + latitude, longitude = coordinate + return await _fetch_open_meteo_now(latitude, longitude) + + +async def get_weather(location: str = DEFAULT_LOCATION_QUERY) -> Optional[WeatherInfo]: """ 获取天气信息(简化版) - + Args: - location: LocationID 或 经纬度坐标 - 默认: 101020100 (上海) - + location: 城市名 / 经纬度坐标 / 历史 LocationID + Returns: WeatherInfo 或 None """ resolved_location, display_location = await resolve_location(location) weather_response = await get_qweather_now(resolved_location) - + if not weather_response: - # 返回模拟数据作为降级方案 print("⚠️ 使用模拟天气数据") return WeatherInfo( temperature=20.0, @@ -368,9 +544,9 @@ async def get_weather(location: str = "101020100") -> Optional[WeatherInfo]: windDir="南风", windScale="2", location=display_location, - obsTime="2024-01-01T12:00+08:00" + obsTime="2026-01-01T12:00", ) - + now = weather_response.now return WeatherInfo( temperature=float(now.temp), @@ -381,45 +557,26 @@ async def get_weather(location: str = "101020100") -> Optional[WeatherInfo]: windDir=now.windDir, windScale=now.windScale, location=display_location, - obsTime=now.obsTime + obsTime=now.obsTime, ) def get_season_from_weather(weather: WeatherInfo) -> list[str]: - """ - 根据天气推断适合的季节标签 - - Args: - weather: 天气信息 - - Returns: - 季节标签列表 - """ + """根据天气推断适合的季节标签。""" temp = weather.temperature - + if temp < 10: return ["冬"] - elif temp < 20: + if temp < 20: return ["春", "秋"] - else: - return ["夏"] + return ["夏"] def get_clothing_suggestion(weather: WeatherInfo) -> str: - """ - 根据天气推荐穿搭建议 - - Args: - weather: 天气信息 - - Returns: - 穿搭建议文字 - """ - temp = weather.temperature + """根据天气推荐穿搭建议。""" feels_like = weather.feelsLike condition = weather.condition - - # 基于温度的建议 + if feels_like < 0: suggestion = "🧥 建议穿厚羽绒服、棉衣等保暖衣物" elif feels_like < 10: @@ -430,13 +587,12 @@ def get_clothing_suggestion(weather: WeatherInfo) -> str: suggestion = "👕 建议穿短袖、薄长袖等轻便衣物" else: suggestion = "👕 建议穿短袖、短裤等夏季清凉衣物" - - # 根据天气状况补充建议 + if "雨" in condition: suggestion += ",记得带伞☂️" elif "雪" in condition: suggestion += ",注意防滑保暖❄️" elif "晴" in condition and feels_like > 25: suggestion += ",注意防晒☀️" - + return suggestion diff --git a/backend/storage/config_store.py b/backend/storage/config_store.py index d9aceff..7cb3e5f 100644 --- a/backend/storage/config_store.py +++ b/backend/storage/config_store.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import Optional from domain.config import LLMConfig +from services.weather import validate_location_input, DEFAULT_LOCATION_QUERY CONFIG_FILE = Path(__file__).parent / "llm_config.json" @@ -33,8 +34,7 @@ def update_config( model: Optional[str] = None, removebg_api_key: Optional[str] = None, bg_removal_method: Optional[str] = None, - qweather_api_key: Optional[str] = None, - qweather_api_host: Optional[str] = None, + weather_location: Optional[str] = None, zodiac_sign: Optional[str] = None ) -> LLMConfig: """更新配置""" @@ -50,10 +50,12 @@ def update_config( config.removebg_api_key = removebg_api_key.strip() if bg_removal_method is not None: config.bg_removal_method = bg_removal_method - if qweather_api_key is not None: - config.qweather_api_key = qweather_api_key.strip() - if qweather_api_host is not None: - config.qweather_api_host = qweather_api_host.strip() + if weather_location is not None: + normalized_location = weather_location.strip() or DEFAULT_LOCATION_QUERY + validation_error = validate_location_input(normalized_location) + if validation_error: + raise ValueError(validation_error) + config.weather_location = normalized_location if zodiac_sign is not None: config.zodiac_sign = zodiac_sign.strip().lower() @@ -73,6 +75,9 @@ def _mask_key(key: str) -> str: def get_masked_config() -> dict: """获取脱敏后的配置(隐藏 API Key)""" config = load_config() + weather_location = (config.weather_location or "").strip() or DEFAULT_LOCATION_QUERY + if validate_location_input(weather_location): + weather_location = DEFAULT_LOCATION_QUERY return { "api_base": config.api_base, @@ -82,8 +87,6 @@ def get_masked_config() -> dict: "removebg_api_key_masked": _mask_key(config.removebg_api_key), "has_removebg_key": bool(config.removebg_api_key), "bg_removal_method": config.bg_removal_method, - "qweather_api_key_masked": _mask_key(config.qweather_api_key), - "has_qweather_key": bool(config.qweather_api_key), - "qweather_api_host": config.qweather_api_host, + "weather_location": weather_location, "zodiac_sign": config.zodiac_sign } diff --git a/backend/storage/db.py b/backend/storage/db.py index 8a3f33e..4f4ca37 100644 --- a/backend/storage/db.py +++ b/backend/storage/db.py @@ -4,10 +4,15 @@ import aiosqlite import json from pathlib import Path -from typing import List, Optional +from typing import Any, List, Optional from datetime import datetime from domain.clothes import ClothesItem, ClothesCreate -from storage.models import CLOTHES_TABLE_SQL, CLOTHES_INDEX_SQL +from storage.models import ( + CLOTHES_TABLE_SQL, + CLOTHES_INDEX_SQL, + HOROSCOPE_RECORDS_TABLE_SQL, + HOROSCOPE_RECORDS_INDEX_SQL, +) # 数据库文件路径 # 优先使用环境变量,方便 Docker 挂载 volume @@ -25,6 +30,8 @@ async def init_db(): async with aiosqlite.connect(DB_PATH) as db: await db.execute(CLOTHES_TABLE_SQL) await db.execute(CLOTHES_INDEX_SQL) + await db.execute(HOROSCOPE_RECORDS_TABLE_SQL) + await db.execute(HOROSCOPE_RECORDS_INDEX_SQL) await db.commit() @@ -135,6 +142,98 @@ async def update_clothes(clothes_id: int, clothes: ClothesCreate) -> bool: return cursor.rowcount > 0 +async def get_horoscope_record(record_date: str, zodiac_sign: str) -> Optional[dict[str, Any]]: + """按日期和星座获取缓存的运势记录。""" + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + """ + SELECT * FROM horoscope_records + WHERE record_date = ? AND zodiac_sign = ? + LIMIT 1 + """, + (record_date, zodiac_sign), + ) + row = await cursor.fetchone() + if not row: + return None + return _row_to_horoscope_record(row) + + +async def upsert_horoscope_source( + record_date: str, + zodiac_sign: str, + zodiac_name: str, + source_provider: str, + source_payload: dict[str, Any], +) -> int: + """ + 写入或更新星座原始数据(aztro/fallback)。 + 已存在记录时,保留现有推理状态与推理内容。 + """ + payload_json = json.dumps(source_payload, ensure_ascii=False) + + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + """ + SELECT id FROM horoscope_records + WHERE record_date = ? AND zodiac_sign = ? + LIMIT 1 + """, + (record_date, zodiac_sign), + ) + existing = await cursor.fetchone() + + if existing: + await db.execute( + """ + UPDATE horoscope_records + SET zodiac_name = ?, + source_provider = ?, + source_payload = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (zodiac_name, source_provider, payload_json, existing["id"]), + ) + await db.commit() + return int(existing["id"]) + + cursor = await db.execute( + """ + INSERT INTO horoscope_records ( + record_date, zodiac_sign, zodiac_name, source_provider, source_payload, llm_status + ) VALUES (?, ?, ?, ?, ?, 'pending') + """, + (record_date, zodiac_sign, zodiac_name, source_provider, payload_json), + ) + await db.commit() + return int(cursor.lastrowid) + + +async def update_horoscope_inference( + record_id: int, + llm_status: str, + llm_reasoning: Optional[str] = None, + llm_error: Optional[str] = None, +) -> None: + """更新运势推理状态与结果。""" + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + """ + UPDATE horoscope_records + SET llm_status = ?, + llm_reasoning = ?, + llm_error = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (llm_status, llm_reasoning, llm_error, record_id), + ) + await db.commit() + + def _row_to_clothes_item(row: aiosqlite.Row) -> ClothesItem: """将数据库行转换为 ClothesItem""" return ClothesItem( @@ -149,3 +248,20 @@ def _row_to_clothes_item(row: aiosqlite.Row) -> ClothesItem: image_url=f"/uploads/{row['image_filename']}", created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else datetime.now() ) + + +def _row_to_horoscope_record(row: aiosqlite.Row) -> dict[str, Any]: + """将数据库行转换为星座记录字典。""" + return { + "id": int(row["id"]), + "record_date": row["record_date"], + "zodiac_sign": row["zodiac_sign"], + "zodiac_name": row["zodiac_name"], + "source_provider": row["source_provider"] or "unknown", + "source_payload": json.loads(row["source_payload"] or "{}"), + "llm_status": row["llm_status"] or "pending", + "llm_reasoning": row["llm_reasoning"] or "", + "llm_error": row["llm_error"] or "", + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } diff --git a/backend/storage/models.py b/backend/storage/models.py index 85e5c02..1d4371d 100644 --- a/backend/storage/models.py +++ b/backend/storage/models.py @@ -7,7 +7,7 @@ CLOTHES_TABLE_SQL = """ CREATE TABLE IF NOT EXISTS clothes ( id INTEGER PRIMARY KEY AUTOINCREMENT, - category TEXT NOT NULL, -- top, bottom, shoes + category TEXT NOT NULL, -- top, bottom, shoes, accessory item TEXT NOT NULL, style_semantics TEXT, -- JSON array season_semantics TEXT, -- JSON array @@ -23,3 +23,25 @@ CLOTHES_INDEX_SQL = """ CREATE INDEX IF NOT EXISTS idx_clothes_category ON clothes(category); """ + +# 星座运势缓存表 +HOROSCOPE_RECORDS_TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS horoscope_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + record_date TEXT NOT NULL, -- YYYY-MM-DD + zodiac_sign TEXT NOT NULL, + zodiac_name TEXT NOT NULL, + source_provider TEXT NOT NULL, -- aztro / fallback + source_payload TEXT NOT NULL, -- JSON + llm_status TEXT NOT NULL DEFAULT 'pending', -- pending / done / failed / skipped + llm_reasoning TEXT, + llm_error TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(record_date, zodiac_sign) +); +""" + +HOROSCOPE_RECORDS_INDEX_SQL = """ +CREATE INDEX IF NOT EXISTS idx_horoscope_date_sign ON horoscope_records(record_date, zodiac_sign); +""" diff --git a/backend/test_weather.py b/backend/test_weather.py index f265b0a..b5a0e3a 100644 --- a/backend/test_weather.py +++ b/backend/test_weather.py @@ -1,5 +1,5 @@ """ -测试和风天气 API 集成 +测试 Open-Meteo 天气 API 集成 """ import asyncio from services.weather import ( @@ -13,7 +13,7 @@ async def test_weather(): """测试天气服务""" print("=" * 50) - print("🌤️ 测试和风天气 API 集成") + print("🌤️ 测试 Open-Meteo 天气 API 集成") print("=" * 50) # 测试不同城市 @@ -52,7 +52,7 @@ async def test_weather(): print(f"❌ 获取 {city_name} 天气信息失败") print("\n" + "=" * 50) - print("🔍 测试和风天气原始数据") + print("🔍 测试天气原始数据(兼容结构)") print("=" * 50) # 测试原始数据 diff --git a/frontend/src/components/Settings.jsx b/frontend/src/components/Settings.jsx index 7901a63..56f911f 100644 --- a/frontend/src/components/Settings.jsx +++ b/frontend/src/components/Settings.jsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useTheme } from '../contexts/ThemeContext' -import { Sun, Moon, Globe, Sparkles } from 'lucide-react' +import { Sun, Moon, Globe, Sparkles, MapPin } from 'lucide-react' const LANGUAGES = [ { code: 'zh', label: '中文' }, @@ -24,6 +24,24 @@ const ZODIAC_SIGNS = [ 'pisces' ] +const DEFAULT_LOCATION = '上海, 上海市, 中国' +const LOCATION_ID_REGEX = /^\d{9}$/ +const COORDINATE_LOCATION_REGEX = /^\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*$/ +const LOCATION_PART_SEPARATOR_REGEX = /[,,]+/ + +const isCompleteLocationInput = (location) => { + const raw = (location || '').trim() + if (!raw) return false + if (LOCATION_ID_REGEX.test(raw) || COORDINATE_LOCATION_REGEX.test(raw)) return true + + const parts = raw + .split(LOCATION_PART_SEPARATOR_REGEX) + .map(part => part.trim()) + .filter(Boolean) + + return parts.length >= 3 +} + const Settings = ({ isOpen, onClose, onSave }) => { const { t, i18n } = useTranslation() const { theme, toggleTheme } = useTheme() @@ -33,8 +51,7 @@ const Settings = ({ isOpen, onClose, onSave }) => { model: 'gpt-4o', removebg_api_key: '', bg_removal_method: 'local', - qweather_api_key: '', - qweather_api_host: 'devapi.qweather.com', + weather_location: DEFAULT_LOCATION, zodiac_sign: '' }) const [models, setModels] = useState([]) @@ -43,7 +60,6 @@ const Settings = ({ isOpen, onClose, onSave }) => { const [testResult, setTestResult] = useState(null) const [hasExistingKey, setHasExistingKey] = useState(false) const [hasRemoveBgKey, setHasRemoveBgKey] = useState(false) - const [hasQweatherKey, setHasQweatherKey] = useState(false) const [showModelSelect, setShowModelSelect] = useState(false) const API_BASE = `http://${window.location.hostname}:8000/api` @@ -70,12 +86,11 @@ const Settings = ({ isOpen, onClose, onSave }) => { api_base: data.api_base || 'https://api.openai.com/v1', model: data.model || 'gpt-4o', bg_removal_method: data.bg_removal_method || 'local', - qweather_api_host: data.qweather_api_host || 'devapi.qweather.com', + weather_location: data.weather_location || DEFAULT_LOCATION, zodiac_sign: data.zodiac_sign || '' })) setHasExistingKey(data.has_api_key) setHasRemoveBgKey(data.has_removebg_key) - setHasQweatherKey(data.has_qweather_key) } } catch (error) { console.error('Failed to fetch config:', error) @@ -104,7 +119,11 @@ const Settings = ({ isOpen, onClose, onSave }) => { setTesting(true) setTestResult(null) - await handleSave(false) + const saveSuccess = await handleSave(false) + if (!saveSuccess) { + setTesting(false) + return + } try { const response = await fetch(`${API_BASE}/test-connection`, { @@ -128,11 +147,20 @@ const Settings = ({ isOpen, onClose, onSave }) => { const handleSave = async (closeAfter = true) => { try { + const normalizedLocation = (config.weather_location || '').trim() || DEFAULT_LOCATION + if (!isCompleteLocationInput(normalizedLocation)) { + setTestResult({ + success: false, + message: t('settings.defaultCityFormatError') + }) + return false + } + const payload = { api_base: config.api_base, model: config.model, bg_removal_method: config.bg_removal_method, - qweather_api_host: config.qweather_api_host, + weather_location: normalizedLocation, zodiac_sign: config.zodiac_sign } @@ -142,9 +170,6 @@ const Settings = ({ isOpen, onClose, onSave }) => { if (config.removebg_api_key) { payload.removebg_api_key = config.removebg_api_key } - if (config.qweather_api_key) { - payload.qweather_api_key = config.qweather_api_key - } const response = await fetch(`${API_BASE}/config`, { method: 'POST', @@ -159,9 +184,17 @@ const Settings = ({ isOpen, onClose, onSave }) => { onSave && onSave() onClose() } + return true } + const errorPayload = await response.json().catch(() => ({})) + setTestResult({ + success: false, + message: errorPayload.detail || t('settings.defaultCityFormatError') + }) + return false } catch (error) { console.error('Failed to save config:', error) + return false } } @@ -262,6 +295,25 @@ const Settings = ({ isOpen, onClose, onSave }) => { ))} + +
+ + { + setConfig(prev => ({ ...prev, weather_location: e.target.value })) + if (testResult?.success === false) { + setTestResult(null) + } + }} + placeholder={t('settings.defaultCityPlaceholder')} + /> +
@@ -456,46 +508,7 @@ const Settings = ({ isOpen, onClose, onSave }) => { )}
-
- - {/* Weather API Section */} -
-
{t('settings.weatherSection')}
- -
- - setConfig(prev => ({ ...prev, qweather_api_key: e.target.value }))} - placeholder={hasQweatherKey ? `••••••••(${t('settings.keepEmpty')})` : t('settings.removebgKeyPlaceholder')} - /> -
- -
- - setConfig(prev => ({ ...prev, qweather_api_host: e.target.value }))} - placeholder="devapi.qweather.com" - /> -
- - {t('settings.qweatherConsole')} - -
-
-
+
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index cdd27da..11d8aa5 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -21,6 +21,7 @@ "categoryTop": "Top", "categoryBottom": "Bottom", "categoryShoes": "Shoes", + "categoryAccessory": "Accessory", "color": "Color", "colorPlaceholder": "Main color", "style": "Style", @@ -48,6 +49,7 @@ "tops": "Tops", "bottoms": "Bottoms", "shoes": "Shoes", + "accessories": "Accessories", "loading": "Loading...", "noMatch": "No matching items", "deleteConfirm": "Are you sure you want to delete this item?", @@ -77,6 +79,7 @@ "top": "Top", "bottom": "Bottom", "shoes": "Shoes", + "accessory": "Accessory", "noItems": "No {{label}}" }, "recommendation": { @@ -87,7 +90,7 @@ "currentLocation": "Current Location", "searchCity": "Enter city, district, or province", "searchAction": "Check Weather", - "searchHint": "Supports city names, districts, province-city combinations, pinyin, or QWeather LocationID.", + "searchHint": "Supports city names, districts, province-city combinations, pinyin, or coordinates (e.g. 31.23,121.47).", "searching": "Searching locations...", "noCity": "No matching city found", "enterCity": "Enter a location to search", @@ -97,10 +100,24 @@ "suggestedCombo": "Suggested Combo", "topWear": "Top", "bottomWear": "Bottom", + "shoesWear": "Shoes", "humidity": "Humidity", "wind": "Wind", "windLevel": "", - "feelsLike": "Feels like" + "feelsLike": "Feels like", + "horoscopeSign": "Zodiac", + "mood": "Mood", + "luckyColor": "Lucky color", + "luckyNumber": "Lucky number", + "horoscopeSummary": "Horoscope summary", + "temperatureRule": "Temperature strategy", + "allowedSeasons": "Matched seasons", + "outfitSummary": "Outfit summary", + "accessories": "Accessory picks", + "fromWardrobe": "In wardrobe", + "needBuy": "Need to buy", + "purchaseFallback": "Shopping fallback", + "buyKeywords": "Suggested items" }, "home": { "today": "Today", @@ -115,6 +132,7 @@ "categoryTop": "Top", "categoryBottom": "Bottom", "categoryShoes": "Shoes", + "categoryAccessory": "Accessory", "viewAll": "View all", "emptyWardrobe": "Your wardrobe is empty. Add a few pieces first.", "goEntry": "Add Clothes", @@ -130,7 +148,10 @@ "unknownWeather": "Unknown", "unknownLocation": "Unknown location", "unknownZodiac": "No zodiac", - "horoscopeFallback": "Horoscope is temporarily unavailable. Please try again later." + "horoscopeFallback": "Horoscope is temporarily unavailable. Please try again later.", + "llmReasoningTitle": "LLM Reasoning", + "llmReasoningLoading": "Reasoning in progress...", + "llmReasoningFallback": "No reasoning result yet." }, "settings": { "title": "API Settings", @@ -167,6 +188,9 @@ "appSection": "App Settings", "zodiac": "Zodiac Sign", "zodiacPlaceholder": "Select your zodiac sign", + "defaultCity": "Default City", + "defaultCityPlaceholder": "e.g. Nanjing, Jiangsu, China", + "defaultCityFormatError": "Location is incomplete. Use \"City, State/Province, Country\", e.g. Nanjing, Jiangsu, China.", "zodiacOptions": { "aries": "Aries", "taurus": "Taurus", diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index ccca0b0..148f76a 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -21,6 +21,7 @@ "categoryTop": "トップス", "categoryBottom": "ボトムス", "categoryShoes": "シューズ", + "categoryAccessory": "アクセサリー", "color": "カラー", "colorPlaceholder": "メインカラー", "style": "スタイル", @@ -48,6 +49,7 @@ "tops": "トップス", "bottoms": "ボトムス", "shoes": "シューズ", + "accessories": "アクセサリー", "loading": "読み込み中...", "noMatch": "一致する衣類がありません", "deleteConfirm": "この衣類を削除しますか?", @@ -77,6 +79,7 @@ "top": "トップス", "bottom": "ボトムス", "shoes": "シューズ", + "accessory": "アクセサリー", "noItems": "{{label}}がありません" }, "recommendation": { @@ -87,7 +90,7 @@ "currentLocation": "現在地", "searchCity": "都市・地区・都道府県を入力", "searchAction": "天気を確認", - "searchHint": "都市名、地区名、都道府県との組み合わせ、ピンイン、QWeather LocationID に対応します。", + "searchHint": "都市名、地区名、都道府県の組み合わせ、ピンイン、または緯度経度(例: 31.23,121.47)に対応します。", "searching": "地点を検索中...", "noCity": "該当する都市が見つかりません", "enterCity": "地点名を入力して検索", @@ -97,10 +100,24 @@ "suggestedCombo": "おすすめ組合せ", "topWear": "トップス", "bottomWear": "ボトムス", + "shoesWear": "シューズ", "humidity": "湿度", "wind": "風力", "windLevel": "級", - "feelsLike": "体感" + "feelsLike": "体感", + "horoscopeSign": "今日の星座", + "mood": "今日の気分", + "luckyColor": "ラッキーカラー", + "luckyNumber": "ラッキーナンバー", + "horoscopeSummary": "運勢サマリー", + "temperatureRule": "気温コーデ戦略", + "allowedSeasons": "適用シーズン", + "outfitSummary": "コーデ結論", + "accessories": "アクセサリー提案", + "fromWardrobe": "手持ちあり", + "needBuy": "購入推奨", + "purchaseFallback": "不足分の購入提案", + "buyKeywords": "おすすめアイテム" }, "home": { "today": "今日", @@ -115,6 +132,7 @@ "categoryTop": "トップス", "categoryBottom": "ボトムス", "categoryShoes": "シューズ", + "categoryAccessory": "アクセサリー", "viewAll": "すべて表示", "emptyWardrobe": "ワードローブが空です。先に数点登録しましょう。", "goEntry": "登録へ", @@ -130,7 +148,10 @@ "unknownWeather": "不明", "unknownLocation": "場所不明", "unknownZodiac": "星座未設定", - "horoscopeFallback": "運勢を取得できませんでした。しばらくして再試行してください。" + "horoscopeFallback": "運勢を取得できませんでした。しばらくして再試行してください。", + "llmReasoningTitle": "LLM 推論", + "llmReasoningLoading": "推論中...", + "llmReasoningFallback": "推論結果はまだありません。" }, "settings": { "title": "API 設定", @@ -167,6 +188,9 @@ "appSection": "アプリ設定", "zodiac": "星座", "zodiacPlaceholder": "星座を選択してください", + "defaultCity": "デフォルト都市", + "defaultCityPlaceholder": "例:Nanjing, Jiangsu, China", + "defaultCityFormatError": "地点の形式が不完全です。「都市, 州/省, 国」の形式で入力してください(例:Nanjing, Jiangsu, China)。", "zodiacOptions": { "aries": "牡羊座", "taurus": "牡牛座", diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index 424b537..4f88514 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -21,6 +21,7 @@ "categoryTop": "上装 (Top)", "categoryBottom": "下装 (Bottom)", "categoryShoes": "鞋履 (Shoes)", + "categoryAccessory": "饰品 (Accessory)", "color": "颜色", "colorPlaceholder": "主要颜色", "style": "风格", @@ -48,6 +49,7 @@ "tops": "上装", "bottoms": "下装", "shoes": "鞋履", + "accessories": "饰品", "loading": "加载中...", "noMatch": "暂无匹配衣物", "deleteConfirm": "确定要删除这件衣物吗?", @@ -77,6 +79,7 @@ "top": "上衣", "bottom": "下装", "shoes": "鞋履", + "accessory": "饰品", "noItems": "暂无{{label}}" }, "recommendation": { @@ -87,7 +90,7 @@ "currentLocation": "当前位置", "searchCity": "输入城市、区县或省份", "searchAction": "查询天气并生成推荐", - "searchHint": "支持城市名、区县名、省市组合、拼音或和风天气 LocationID。", + "searchHint": "支持城市名、区县名、省市组合、拼音,或经纬度坐标(如 31.23,121.47)。", "searching": "正在搜索地区...", "noCity": "未找到匹配的城市", "enterCity": "请输入地区名称进行搜索", @@ -97,10 +100,24 @@ "suggestedCombo": "建议组合", "topWear": "上装", "bottomWear": "下装", + "shoesWear": "鞋履", "humidity": "湿度", "wind": "风力", "windLevel": "级", - "feelsLike": "体感" + "feelsLike": "体感", + "horoscopeSign": "今日星座", + "mood": "今日状态", + "luckyColor": "幸运色", + "luckyNumber": "幸运数字", + "horoscopeSummary": "运势摘要", + "temperatureRule": "温度穿搭策略", + "allowedSeasons": "适配季节", + "outfitSummary": "搭配结论", + "accessories": "饰品推荐", + "fromWardrobe": "衣柜已有", + "needBuy": "建议补购", + "purchaseFallback": "购买补充建议", + "buyKeywords": "推荐单品" }, "home": { "today": "今天", @@ -115,6 +132,7 @@ "categoryTop": "上装", "categoryBottom": "下装", "categoryShoes": "鞋履", + "categoryAccessory": "饰品", "viewAll": "查看全部", "emptyWardrobe": "你的衣柜还是空的,先录入几件单品吧。", "goEntry": "去录入", @@ -130,7 +148,10 @@ "unknownWeather": "未知天气", "unknownLocation": "未知位置", "unknownZodiac": "未设置星座", - "horoscopeFallback": "暂时无法获取运势,稍后再试。" + "horoscopeFallback": "暂时无法获取运势,稍后再试。", + "llmReasoningTitle": "LLM 推理", + "llmReasoningLoading": "正在推理中...", + "llmReasoningFallback": "暂时没有推理结果。" }, "settings": { "title": "API 设置", @@ -167,6 +188,9 @@ "appSection": "应用设置", "zodiac": "星座", "zodiacPlaceholder": "请选择你的星座", + "defaultCity": "默认城市", + "defaultCityPlaceholder": "例如:南京, 江苏, 中国", + "defaultCityFormatError": "地点格式不完整,请使用“城市, 省/州, 国家”,例如:南京, 江苏, 中国", "zodiacOptions": { "aries": "白羊座", "taurus": "金牛座", diff --git a/frontend/src/pages/Entry.jsx b/frontend/src/pages/Entry.jsx index 52031ec..94add7d 100644 --- a/frontend/src/pages/Entry.jsx +++ b/frontend/src/pages/Entry.jsx @@ -136,6 +136,7 @@ export default function Entry() { +
diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index 2c3c154..a6ef835 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -5,7 +5,7 @@ import { Settings as SettingsIcon, RefreshCw, Sparkles, CloudSun, Droplets, Wind import Settings from '../components/Settings' const API_BASE = `http://${window.location.hostname}:8000/api` -const DEFAULT_LOCATION = '101020100' +const FALLBACK_LOCATION = '上海, 上海市, 中国' const formatDate = (locale) => { const lang = locale?.startsWith('zh') @@ -25,8 +25,10 @@ export default function Home() { const navigate = useNavigate() const [weather, setWeather] = useState(null) - const [wardrobe, setWardrobe] = useState({ tops: [], bottoms: [], shoes: [] }) + const [wardrobe, setWardrobe] = useState({ tops: [], bottoms: [], shoes: [], accessories: [] }) const [horoscope, setHoroscope] = useState(null) + const [horoscopeInferenceLoading, setHoroscopeInferenceLoading] = useState(false) + const [defaultLocation, setDefaultLocation] = useState(FALLBACK_LOCATION) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [showSettings, setShowSettings] = useState(false) @@ -35,7 +37,8 @@ export default function Home() { const carouselItems = useMemo(() => ([ ...wardrobe.tops.map(item => ({ ...item, category: 'top' })), ...wardrobe.bottoms.map(item => ({ ...item, category: 'bottom' })), - ...wardrobe.shoes.map(item => ({ ...item, category: 'shoes' })) + ...wardrobe.shoes.map(item => ({ ...item, category: 'shoes' })), + ...wardrobe.accessories.map(item => ({ ...item, category: 'accessory' })) ]), [wardrobe]) useEffect(() => { @@ -51,7 +54,42 @@ export default function Home() { return () => clearInterval(timer) }, [carouselItems.length]) - const fetchDashboard = async (withLoading = true) => { + const fetchConfiguredLocation = async () => { + try { + const response = await fetch(`${API_BASE}/config`) + if (!response.ok) { + return FALLBACK_LOCATION + } + const data = await response.json() + return (data.weather_location || '').trim() || FALLBACK_LOCATION + } catch { + return FALLBACK_LOCATION + } + } + + const fetchHoroscope = async (location, includeInference = false) => { + const response = await fetch( + `${API_BASE}/horoscope/daily?location=${encodeURIComponent(location)}&include_inference=${includeInference}` + ) + if (!response.ok) return null + return response.json() + } + + const runHoroscopeInference = async (location) => { + setHoroscopeInferenceLoading(true) + try { + const inferred = await fetchHoroscope(location, true) + if (inferred) { + setHoroscope(inferred) + } + } catch (error) { + console.error('Failed to fetch horoscope inference:', error) + } finally { + setHoroscopeInferenceLoading(false) + } + } + + const fetchDashboard = async (withLoading = true, location = defaultLocation) => { if (withLoading) { setLoading(true) } else { @@ -59,10 +97,10 @@ export default function Home() { } try { - const [weatherRes, wardrobeRes, horoscopeRes] = await Promise.all([ - fetch(`${API_BASE}/weather?location=${DEFAULT_LOCATION}`), + const [weatherRes, wardrobeRes, horoscopeData] = await Promise.all([ + fetch(`${API_BASE}/weather?location=${encodeURIComponent(location)}`), fetch(`${API_BASE}/wardrobe`), - fetch(`${API_BASE}/horoscope/daily?location=${DEFAULT_LOCATION}`) + fetchHoroscope(location, false) ]) if (weatherRes.ok) { @@ -70,11 +108,23 @@ export default function Home() { } if (wardrobeRes.ok) { - setWardrobe(await wardrobeRes.json()) + const data = await wardrobeRes.json() + setWardrobe({ + tops: data.tops || [], + bottoms: data.bottoms || [], + shoes: data.shoes || [], + accessories: data.accessories || [] + }) } - if (horoscopeRes.ok) { - setHoroscope(await horoscopeRes.json()) + if (horoscopeData) { + setHoroscope(horoscopeData) + const shouldInfer = horoscopeData.llm_status === 'pending' + if (horoscopeData.is_configured && shouldInfer) { + void runHoroscopeInference(location) + } else { + setHoroscopeInferenceLoading(false) + } } } catch (error) { console.error('Failed to fetch home dashboard:', error) @@ -85,24 +135,35 @@ export default function Home() { } useEffect(() => { - fetchDashboard(true) + const initializeDashboard = async () => { + const location = await fetchConfiguredLocation() + setDefaultLocation(location) + await fetchDashboard(true, location) + } + + void initializeDashboard() }, []) + const handleSettingsSaved = async () => { + const location = await fetchConfiguredLocation() + setDefaultLocation(location) + await fetchDashboard(false, location) + } + const getCategoryLabel = (category) => { if (category === 'top') return t('home.categoryTop') if (category === 'bottom') return t('home.categoryBottom') - return t('home.categoryShoes') + if (category === 'shoes') return t('home.categoryShoes') + return t('home.categoryAccessory') } + const sectionTitleClass = 'text-sm font-semibold text-zinc-800 dark:text-zinc-100 flex items-center gap-2' + return (
-
-
-
-

{t('home.today')}

-

{t('home.title')}

+

{t('home.today')}

{formatDate(i18n.language)}

@@ -133,7 +194,7 @@ export default function Home() {
-

+

{t('home.weatherTitle')}

@@ -144,7 +205,7 @@ export default function Home() {
-
+
{weather ? `${Math.round(weather.temperature)}°` : '--'}
@@ -170,7 +231,7 @@ export default function Home() {
-

+

{t('home.carouselTitle')}

@@ -262,7 +323,7 @@ export default function Home() {
-

+

{t('home.horoscopeTitle')}

@@ -292,6 +353,20 @@ export default function Home() { {horoscope?.suggestion || t('home.horoscopeFallback')}
+
+
{t('home.llmReasoningTitle')}
+ {horoscopeInferenceLoading ? ( +
+
+ {t('home.llmReasoningLoading')} +
+ ) : ( +

+ {horoscope?.llm_reasoning || t('home.llmReasoningFallback')} +

+ )} +
+ {horoscope && !horoscope.is_configured && (
) diff --git a/frontend/src/pages/Outfit.jsx b/frontend/src/pages/Outfit.jsx index 7755b13..64728b2 100644 --- a/frontend/src/pages/Outfit.jsx +++ b/frontend/src/pages/Outfit.jsx @@ -67,14 +67,15 @@ const OutfitPart = ({ items, label, proportion, currentIndex, onPrev, onNext, em export default function Outfit() { const { t } = useTranslation() - const [wardrobe, setWardrobe] = useState({ tops: [], bottoms: [], shoes: [] }) + const [wardrobe, setWardrobe] = useState({ tops: [], bottoms: [], shoes: [], accessories: [] }) const [loading, setLoading] = useState(true) const [filterSeason, setFilterSeason] = useState('all') const [currentIndices, setCurrentIndices] = useState({ tops: 0, bottoms: 0, - shoes: 0 + shoes: 0, + accessories: 0 }) useEffect(() => { @@ -85,7 +86,13 @@ export default function Outfit() { try { const response = await fetch(`${API_BASE}/wardrobe`) if (response.ok) { - setWardrobe(await response.json()) + const data = await response.json() + setWardrobe({ + tops: data.tops || [], + bottoms: data.bottoms || [], + shoes: data.shoes || [], + accessories: data.accessories || [] + }) } } catch (error) { console.error(error) @@ -102,10 +109,17 @@ export default function Outfit() { const tops = filter(wardrobe.tops) const bottoms = filter(wardrobe.bottoms) const shoes = filter(wardrobe.shoes) + const accessories = filter(wardrobe.accessories) const handlePrev = (category) => { setCurrentIndices(prev => { - const items = category === 'tops' ? tops : category === 'bottoms' ? bottoms : shoes + const items = category === 'tops' + ? tops + : category === 'bottoms' + ? bottoms + : category === 'shoes' + ? shoes + : accessories const newIndex = prev[category] > 0 ? prev[category] - 1 : items.length - 1 return { ...prev, [category]: newIndex } }) @@ -113,7 +127,13 @@ export default function Outfit() { const handleNext = (category) => { setCurrentIndices(prev => { - const items = category === 'tops' ? tops : category === 'bottoms' ? bottoms : shoes + const items = category === 'tops' + ? tops + : category === 'bottoms' + ? bottoms + : category === 'shoes' + ? shoes + : accessories const newIndex = prev[category] < items.length - 1 ? prev[category] + 1 : 0 return { ...prev, [category]: newIndex } }) @@ -123,7 +143,8 @@ export default function Outfit() { setCurrentIndices({ tops: tops.length > 0 ? Math.floor(Math.random() * tops.length) : 0, bottoms: bottoms.length > 0 ? Math.floor(Math.random() * bottoms.length) : 0, - shoes: shoes.length > 0 ? Math.floor(Math.random() * shoes.length) : 0 + shoes: shoes.length > 0 ? Math.floor(Math.random() * shoes.length) : 0, + accessories: accessories.length > 0 ? Math.floor(Math.random() * accessories.length) : 0 }) } @@ -200,6 +221,15 @@ export default function Outfit() { onNext={() => handleNext('shoes')} emptyText={t('outfit.noItems', { label: t('outfit.shoes') })} /> + handlePrev('accessories')} + onNext={() => handleNext('accessories')} + emptyText={t('outfit.noItems', { label: t('outfit.accessory') })} + />
) diff --git a/frontend/src/pages/Recommendation.jsx b/frontend/src/pages/Recommendation.jsx index d2fc356..1284640 100644 --- a/frontend/src/pages/Recommendation.jsx +++ b/frontend/src/pages/Recommendation.jsx @@ -9,15 +9,22 @@ export default function Recommendation() { const { t } = useTranslation() const [loading, setLoading] = useState(false) const [weather, setWeather] = useState(null) + const [horoscope, setHoroscope] = useState(null) + const [temperatureRule, setTemperatureRule] = useState(null) const [recommendation, setRecommendation] = useState('') + const [outfitSummary, setOutfitSummary] = useState('') + const [selectionReasons, setSelectionReasons] = useState({}) const [suggestedTop, setSuggestedTop] = useState(null) const [suggestedBottom, setSuggestedBottom] = useState(null) + const [suggestedShoes, setSuggestedShoes] = useState(null) + const [suggestedAccessories, setSuggestedAccessories] = useState([]) + const [purchaseSuggestions, setPurchaseSuggestions] = useState([]) const [cityQuery, setCityQuery] = useState('') const [searchResults, setSearchResults] = useState([]) const [selectedCity, setSelectedCity] = useState({ - name: '上海', - id: '101020100' + name: '上海, 上海市, 中国', + id: '上海, 上海市, 中国' }) const [showCityPicker, setShowCityPicker] = useState(false) const [searchingCities, setSearchingCities] = useState(false) @@ -26,6 +33,32 @@ export default function Recommendation() { const [displayedRecommendation, setDisplayedRecommendation] = useState('') + useEffect(() => { + const fetchDefaultCity = async () => { + try { + const response = await fetch(`${API_BASE}/config`) + if (!response.ok) { + return + } + + const data = await response.json() + const location = (data.weather_location || '').trim() + if (!location) { + return + } + + setSelectedCity({ + name: location, + id: location + }) + } catch (error) { + console.error('Failed to fetch default city config:', error) + } + } + + void fetchDefaultCity() + }, []) + useEffect(() => { if (!recommendation) { setDisplayedRecommendation('') @@ -86,7 +119,6 @@ export default function Recommendation() { return } - // 使用 requestAnimationFrame,确保下拉渲染后再聚焦输入框 const rafId = requestAnimationFrame(() => { cityInputRef.current?.focus() }) @@ -105,7 +137,6 @@ export default function Recommendation() { } document.addEventListener('mousedown', handleOutsidePointerDown) - return () => { document.removeEventListener('mousedown', handleOutsidePointerDown) } @@ -142,10 +173,18 @@ export default function Recommendation() { const response = await fetch(`${API_BASE}/recommendation?location=${encodeURIComponent(location)}`) if (response.ok) { const data = await response.json() - setWeather(data.weather) - setRecommendation(data.recommendation_text) - setSuggestedTop(data.suggested_top) - setSuggestedBottom(data.suggested_bottom) + setWeather(data.weather || null) + setHoroscope(data.horoscope || null) + setTemperatureRule(data.temperature_rule || null) + setRecommendation(data.recommendation_text || '') + setOutfitSummary(data.outfit_summary || '') + setSelectionReasons(data.selection_reasons || {}) + setSuggestedTop(data.suggested_top || null) + setSuggestedBottom(data.suggested_bottom || null) + setSuggestedShoes(data.suggested_shoes || null) + setSuggestedAccessories(data.suggested_accessories || []) + setPurchaseSuggestions(data.purchase_suggestions || []) + if (preferredName) { setSelectedCity({ name: preferredName, @@ -208,9 +247,37 @@ export default function Recommendation() { return iconMap[icon] || '🌤️' } + const renderClothingCard = (item, label, reason) => { + if (!item) { + return null + } + return ( +
+
+ {label} +
+
+ {item.item} +
+
+
{item.item}
+ {item.description && ( +
{item.description}
+ )} + {reason && ( +
{reason}
+ )} +
+
+ ) + } + return (
- {/* Background Blur Elements */}
@@ -295,7 +362,7 @@ export default function Recommendation() {
-

{t('recommendation.getTitle')}
{t('recommendation.getSubtitle')}

+

{t('recommendation.getTitle')}
{t('recommendation.getSubtitle')}

{t('recommendation.description')}

+ {horoscope && ( +
+
+
{t('recommendation.horoscopeSign')}
+
{horoscope.zodiac_name || t('home.unknownZodiac')}
+
+
+
{t('recommendation.mood')}
+
{horoscope.mood}
+
+
+
{t('recommendation.luckyColor')}
+
{horoscope.lucky_color}
+
+
+
{t('recommendation.luckyNumber')}
+
{horoscope.lucky_number}
+
+
+
{t('recommendation.horoscopeSummary')}
+
{horoscope.summary}
+
+
+ )} + + {temperatureRule && ( +
+
{t('recommendation.temperatureRule')}
+
{temperatureRule.label}
+
{temperatureRule.advice}
+ {temperatureRule.allowed_seasons?.length > 0 && ( +
{t('recommendation.allowedSeasons')}: {temperatureRule.allowed_seasons.join(' / ')}
+ )} +
+ )} +
@@ -369,51 +472,73 @@ export default function Recommendation() {
- {(suggestedTop || suggestedBottom) && ( + {outfitSummary && ( +
+ {t('recommendation.outfitSummary')}: {outfitSummary} +
+ )} + + {(suggestedTop || suggestedBottom || suggestedShoes) && (

{t('recommendation.suggestedCombo')}

-
- {suggestedTop && ( -
-
- {t('recommendation.topWear')} -
-
- {suggestedTop.item} -
-
-
{suggestedTop.item}
- {suggestedTop.description && ( -
{suggestedTop.description}
- )} +
+ {renderClothingCard(suggestedTop, t('recommendation.topWear'), selectionReasons?.top)} + {renderClothingCard(suggestedBottom, t('recommendation.bottomWear'), selectionReasons?.bottom)} + {renderClothingCard(suggestedShoes, t('recommendation.shoesWear'), selectionReasons?.shoes)} +
+
+ )} + + {suggestedAccessories.length > 0 && ( +
+

{t('recommendation.accessories')}

+
+ {suggestedAccessories.map((accessory, index) => ( +
+
+
+
{accessory.name}
+
{accessory.reason}
+
+ + {accessory.from_wardrobe ? t('recommendation.fromWardrobe') : t('recommendation.needBuy')} +
+ {accessory.item?.image_url && ( +
+ {accessory.name} +
+ )}
- )} + ))} +
+
+ )} - {suggestedBottom && ( -
-
- {t('recommendation.bottomWear')} -
-
- {suggestedBottom.item} -
-
-
{suggestedBottom.item}
- {suggestedBottom.description && ( -
{suggestedBottom.description}
- )} -
+ {purchaseSuggestions.length > 0 && ( +
+

{t('recommendation.purchaseFallback')}

+
+ {purchaseSuggestions.map((suggestion, index) => ( +
+
{suggestion.title}
+
{suggestion.reason}
+ {suggestion.keywords?.length > 0 && ( +
{t('recommendation.buyKeywords')}: {suggestion.keywords.join(' / ')}
+ )} + {suggestion.horoscope_hint && ( +
{suggestion.horoscope_hint}
+ )}
- )} + ))}
)} diff --git a/frontend/src/pages/Wardrobe.jsx b/frontend/src/pages/Wardrobe.jsx index 0b43f63..2f14373 100644 --- a/frontend/src/pages/Wardrobe.jsx +++ b/frontend/src/pages/Wardrobe.jsx @@ -7,7 +7,7 @@ const API_BASE = `http://${window.location.hostname}:8000/api` export default function Wardrobe() { const { t } = useTranslation() - const [wardrobe, setWardrobe] = useState({ tops: [], bottoms: [], shoes: [] }) + const [wardrobe, setWardrobe] = useState({ tops: [], bottoms: [], shoes: [], accessories: [] }) const [loading, setLoading] = useState(true) const [filters, setFilters] = useState({ search: '', @@ -24,7 +24,12 @@ export default function Wardrobe() { const response = await fetch(`${API_BASE}/wardrobe`) if (response.ok) { const data = await response.json() - setWardrobe(data) + setWardrobe({ + tops: data.tops || [], + bottoms: data.bottoms || [], + shoes: data.shoes || [], + accessories: data.accessories || [] + }) } } catch (error) { console.error('Failed to fetch wardrobe:', error) @@ -75,7 +80,8 @@ export default function Wardrobe() { const sections = [ { title: t('wardrobe.tops'), items: filterItems(wardrobe.tops) }, { title: t('wardrobe.bottoms'), items: filterItems(wardrobe.bottoms) }, - { title: t('wardrobe.shoes'), items: filterItems(wardrobe.shoes) } + { title: t('wardrobe.shoes'), items: filterItems(wardrobe.shoes) }, + { title: t('wardrobe.accessories'), items: filterItems(wardrobe.accessories) } ] if (loading) return (