diff --git a/README.ja.md b/README.ja.md index 981b4cc..af113f6 100644 --- a/README.ja.md +++ b/README.ja.md @@ -2,6 +2,7 @@ # 👕 AI スマートワードローブ +[![GitHub Stars](https://img.shields.io/github/stars/leoz9/AIWardrobe?style=social)](https://github.com/leoz9/AIWardrobe/stargazers) [![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![Docker](https://img.shields.io/badge/Docker-Ready-blue?logo=docker)](https://ghcr.io/leoz9/aiwardrobe) [![Python](https://img.shields.io/badge/Python-3.10+-3776AB?logo=python&logoColor=white)](https://python.org) @@ -32,15 +33,21 @@ | **AI レコメンデーション** | Gemini や OpenAI 互換プロバイダーによるパーソナライズされたコーディネート生成 | | **レスポンシブ UI** | Tailwind CSS によるモダンなインターフェースで、デスクトップ・タブレット・モバイルに対応 | -## 📸 スクリーンショット +## 📸 スクリーンショット(新 UI) + +
+ ホーム/ランディング(新 UI) +
- - - - + + + + + +

アイテム登録

ワードローブ

AI レコメンド

コーデ詳細

アイテム登録

ワードローブ

AI レコメンド

衣類詳細
@@ -152,11 +159,11 @@ http://localhost:8000 でアクセス  |  API ドキュメント http: ## ⭐ Star History - + - Star History Chart + Star History Chart diff --git a/README.md b/README.md index 619f56f..2643aee 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ # 👕 AI Smart Wardrobe +[![GitHub Stars](https://img.shields.io/github/stars/leoz9/AIWardrobe?style=social)](https://github.com/leoz9/AIWardrobe/stargazers) [![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![Docker](https://img.shields.io/badge/Docker-Ready-blue?logo=docker)](https://ghcr.io/leoz9/aiwardrobe) [![Python](https://img.shields.io/badge/Python-3.10+-3776AB?logo=python&logoColor=white)](https://python.org) @@ -32,15 +33,21 @@ Upload clothing photos, remove backgrounds automatically, classify garments with | **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 | -## 📸 Screenshots +## 📸 Screenshots (New UI) + +
+ Home / Landing (New UI) +
- - - - + + + + + +

New Item Entry

Wardrobe View

AI Recommendation

Outfit Detail

New Item Entry

Wardrobe View

AI Recommendation

Clothes Detail
@@ -152,11 +159,11 @@ Data is persisted in `backend/data` and `backend/uploads`. ## ⭐ Star History - + - Star History Chart + Star History Chart diff --git a/README.zh-CN.md b/README.zh-CN.md index 35b081d..e143f0b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,6 +2,7 @@ # 👕 AI 智能衣柜 +[![GitHub Stars](https://img.shields.io/github/stars/leoz9/AIWardrobe?style=social)](https://github.com/leoz9/AIWardrobe/stargazers) [![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![Docker](https://img.shields.io/badge/Docker-Ready-blue?logo=docker)](https://ghcr.io/leoz9/aiwardrobe) [![Python](https://img.shields.io/badge/Python-3.10+-3776AB?logo=python&logoColor=white)](https://python.org) @@ -32,15 +33,21 @@ | **AI 推荐** | 支持 Gemini 和 OpenAI 风格接口,用于生成个性化穿搭方案 | | **响应式界面** | 基于 Tailwind CSS,适配桌面、平板和手机 | -## 📸 界面截图 +## 📸 界面截图(新版 UI) + +
+ 首页/落地页(新版 UI) +
- - - - + + + + + +

录入新衣

我的衣橱

AI 推荐

穿搭详情

录入新衣

我的衣橱

AI 推荐

衣物详情
@@ -152,11 +159,11 @@ docker compose up --build -d ## ⭐ Star History - + - Star History Chart + Star History Chart diff --git a/backend/api/recommendation.py b/backend/api/recommendation.py index eb67ee7..a0bd763 100644 --- a/backend/api/recommendation.py +++ b/backend/api/recommendation.py @@ -24,6 +24,8 @@ class RecommendationResponse(BaseModel): suggested_shoes: Optional[dict] = None suggested_accessories: list[dict] = Field(default_factory=list) purchase_suggestions: list[dict] = Field(default_factory=list) + goal_raw: Optional[str] = None + goal_normalized: Optional[str] = None @router.get("/recommendation", response_model=RecommendationResponse) @@ -39,6 +41,7 @@ async def get_outfit_recommendation( default=None, description="可选,临时指定星座(会覆盖设置中的星座)" ), + goal: Optional[str] = Query(default=None, description="可选,用户本次穿搭目标/场景"), ): """ 获取AI穿搭推荐 @@ -64,6 +67,6 @@ async def get_outfit_recommendation( raise HTTPException(status_code=500, detail="获取天气信息失败") # 获取AI推荐 - recommendation = await get_ai_recommendation(weather, zodiac_sign=zodiac_sign) + recommendation = await get_ai_recommendation(weather, zodiac_sign=zodiac_sign, goal=goal) return recommendation diff --git a/backend/api/wardrobe.py b/backend/api/wardrobe.py index 7329319..7f11e93 100644 --- a/backend/api/wardrobe.py +++ b/backend/api/wardrobe.py @@ -7,6 +7,7 @@ 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 @@ -24,10 +25,21 @@ async def get_wardrobe(): """ all_clothes = await get_all_clothes() - 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"] + tops: list[ClothesItem] = [] + bottoms: list[ClothesItem] = [] + shoes: list[ClothesItem] = [] + accessories: list[ClothesItem] = [] + + for clothes in all_clothes: + category = normalize_category_value(clothes.category) + if category == "top": + tops.append(clothes) + elif category == "bottom": + bottoms.append(clothes) + elif category == "shoes": + shoes.append(clothes) + elif category == "accessory": + accessories.append(clothes) return WardrobeResponse( tops=tops, @@ -52,8 +64,7 @@ async def get_wardrobe_category(category: str): detail="类别必须是 top, bottom, shoes 或 accessory" ) - all_clothes = await get_all_clothes() - return [item for item in all_clothes if normalize_category_value(item.category) == category] + return await get_clothes_by_category(category) @router.get("/clothes/{clothes_id}", response_model=ClothesItem) diff --git a/backend/services/recommendation.py b/backend/services/recommendation.py index 2c7241b..0b79e31 100644 --- a/backend/services/recommendation.py +++ b/backend/services/recommendation.py @@ -33,13 +33,14 @@ "pisces": {"柔和", "文艺", "轻盈", "vintage", "casual"}, } -ACCESSORY_KEYWORDS = ( - "帽", "帽子", "围巾", "项链", "耳环", "耳钉", "手链", "戒指", - "手表", "墨镜", "眼镜", "领带", "腰带", "cap", "hat", "scarf", - "necklace", "earring", "bracelet", "ring", "watch", "sunglasses", - "tie", "belt" -) - +GOAL_ALIASES = { + "commute": {"commute", "work", "office", "通勤", "上班", "工作", "出勤"}, + "date": {"date", "dating", "约会", "聚会", "见面"}, + "sport": {"sport", "gym", "workout", "run", "运动", "健身", "跑步", "训练"}, + "formal": {"formal", "business", "meeting", "interview", "商务", "正式", "面试", "会议"}, + "daily": {"daily", "casual", "weekend", "日常", "休闲", "周末", "出街"}, + "travel": {"travel", "trip", "旅行", "出游", "旅游"}, +} def normalize_seasons(raw_values: list[str]) -> set[str]: normalized: set[str] = set() @@ -125,15 +126,35 @@ def is_temperature_compatible(item: dict, allowed_seasons: set[str]) -> bool: 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 normalize_goal(goal: str | None) -> tuple[str, str]: + raw_goal = (goal or "").strip() + if not raw_goal: + return "", "" + + lowered = raw_goal.lower() + for canonical, aliases in GOAL_ALIASES.items(): + if lowered in aliases: + return raw_goal, canonical + + for canonical, aliases in GOAL_ALIASES.items(): + if any(alias in lowered for alias in aliases): + return raw_goal, canonical + + return raw_goal, lowered + + +def usage_tokens(values: list[str]) -> set[str]: + normalized: set[str] = set() + for value in values or []: + token = (value or "").strip().lower() + if not token: + continue + normalized.add(token) + for canonical, aliases in GOAL_ALIASES.items(): + if token in aliases: + normalized.add(canonical) + break + return normalized def score_item( @@ -142,9 +163,14 @@ def score_item( horoscope: dict, weather: WeatherInfo, temperature_profile: dict[str, Any], + normalized_goal: str, ) -> tuple[int, list[str]]: score = 5 - reasons = [f"季节标签匹配{temperature_profile['label']}温度策略"] + if is_temperature_compatible(item, temperature_profile["allowed_seasons"]): + score += 3 + reasons = [f"季节标签匹配{temperature_profile['label']}温度策略"] + else: + reasons = [f"季节标签未完全命中{temperature_profile['label']}策略,作为兜底候选"] lucky_color = horoscope.get("lucky_color", "") color_tokens = build_color_tokens(lucky_color) @@ -174,6 +200,12 @@ def score_item( score += 2 reasons.append("天气有降水,鞋履更注重防滑/防水") + if normalized_goal: + item_usage = usage_tokens(item.get("usage_semantics", [])) + if normalized_goal in item_usage: + score += 4 + reasons.append(f"使用场景匹配本次目标「{normalized_goal}」") + return score, reasons @@ -183,6 +215,7 @@ def pick_best_item( horoscope: dict, weather: WeatherInfo, temperature_profile: dict[str, Any], + normalized_goal: str, ) -> tuple[dict | None, str]: if not candidates: return None, "" @@ -192,7 +225,14 @@ def pick_best_item( best_reasons: list[str] = [] for item in candidates: - score, reasons = score_item(item, category, horoscope, weather, temperature_profile) + score, reasons = score_item( + item, + category, + horoscope, + weather, + temperature_profile, + normalized_goal, + ) if score > best_score: best_score = score best_item = item @@ -223,10 +263,13 @@ def build_purchase_suggestion( } -def extract_wardrobe_accessories(all_clothes: list[dict]) -> list[dict]: +def extract_wardrobe_accessories( + all_clothes: list[dict], + categories: list[str], +) -> list[dict]: accessories = [] - for item in all_clothes: - if normalize_category_value(str(item.get("category", ""))) == "accessory": + for item, category in zip(all_clothes, categories): + if category == "accessory": accessories.append(item) continue text = f"{item.get('item', '')} {item.get('description', '')}".lower() @@ -269,7 +312,11 @@ def build_recommendation_summary( return summary -async def get_ai_recommendation(weather: WeatherInfo, zodiac_sign: str | None = None) -> dict: +async def get_ai_recommendation( + weather: WeatherInfo, + zodiac_sign: str | None = None, + goal: str | None = None, +) -> dict: """ 根据天气和星座运势获取AI穿搭推荐。 温度约束为硬条件:衣柜单品必须满足温度策略,不满足时给出购买兜底。 @@ -295,13 +342,18 @@ async def get_ai_recommendation(weather: WeatherInfo, zodiac_sign: str | None = zodiac_sign=zodiac_sign, include_inference=True, ) + goal_raw, goal_normalized = normalize_goal(goal) temperature_profile = build_temperature_profile(weather) by_category: dict[str, list[dict]] = {"top": [], "bottom": [], "shoes": []} + all_by_category: dict[str, list[dict]] = {"top": [], "bottom": [], "shoes": []} + normalized_categories: list[str] = [] for item in all_clothes: category = normalize_category_value(str(item.get("category", ""))) + normalized_categories.append(category) if category not in by_category: continue + all_by_category[category].append(item) if is_temperature_compatible(item, temperature_profile["allowed_seasons"]): by_category[category].append(item) @@ -316,15 +368,36 @@ async def get_ai_recommendation(weather: WeatherInfo, zodiac_sign: str | None = horoscope=horoscope, weather=weather, temperature_profile=temperature_profile, + normalized_goal=goal_normalized, ) + used_fallback = False + if chosen is None and category == "shoes" and all_by_category["shoes"]: + fallback_item, fallback_reason = pick_best_item( + all_by_category["shoes"], + category=category, + horoscope=horoscope, + weather=weather, + temperature_profile=temperature_profile, + normalized_goal=goal_normalized, + ) + if fallback_item is not None: + chosen = fallback_item + fallback_prefix = "衣柜暂无完全匹配当前温度策略的鞋履,已从现有鞋履中选择最合适的一双" + reason = f"{fallback_prefix};{fallback_reason}" if fallback_reason else fallback_prefix + used_fallback = True + selected[category] = chosen selection_reasons[category] = reason if chosen is None: purchase_suggestions.append( build_purchase_suggestion(category, temperature_profile, horoscope) ) + elif used_fallback: + purchase_suggestions.append( + build_purchase_suggestion(category, temperature_profile, horoscope) + ) - accessory_candidates = extract_wardrobe_accessories(all_clothes) + accessory_candidates = extract_wardrobe_accessories(all_clothes, normalized_categories) compatible_accessories = [] for item in accessory_candidates: # 饰品优先按季节匹配;无季节标签时保留可选。 @@ -341,6 +414,7 @@ async def get_ai_recommendation(weather: WeatherInfo, zodiac_sign: str | None = horoscope=horoscope, weather=weather, temperature_profile=temperature_profile, + normalized_goal=goal_normalized, ) scored.append((score, item, ";".join(reasons))) scored.sort(key=lambda value: value[0], reverse=True) @@ -365,6 +439,8 @@ async def get_ai_recommendation(weather: WeatherInfo, zodiac_sign: str | None = selection_reasons=selection_reasons, purchase_suggestions=purchase_suggestions, suggested_accessories=suggested_accessories, + goal_raw=goal_raw, + goal_normalized=goal_normalized, ) return { @@ -393,6 +469,8 @@ async def get_ai_recommendation(weather: WeatherInfo, zodiac_sign: str | None = "suggested_shoes": selected.get("shoes"), "suggested_accessories": suggested_accessories, "purchase_suggestions": purchase_suggestions, + "goal_raw": goal_raw, + "goal_normalized": goal_normalized, } @@ -404,6 +482,8 @@ async def get_llm_recommendation( selection_reasons: dict[str, str], purchase_suggestions: list[dict], suggested_accessories: list[dict], + goal_raw: str, + goal_normalized: str, ) -> str: """ 使用 LLM 生成推荐文案,失败时回退到规则文本。 @@ -418,6 +498,8 @@ async def get_llm_recommendation( selection_reasons=selection_reasons, purchase_suggestions=purchase_suggestions, suggested_accessories=suggested_accessories, + goal_raw=goal_raw, + goal_normalized=goal_normalized, ) def item_name(category: str) -> str: @@ -464,6 +546,10 @@ def item_name(category: str) -> str: 饰品建议: {accessory_lines} +用户目标: +- 原始目标:{goal_raw or '未指定'} +- 归一化场景:{goal_normalized or '未指定'} + 输出要求: 1. 先给出今日穿搭结论(2-3句) 2. 明确指出哪些是衣柜现有、哪些需要补购 @@ -508,6 +594,8 @@ def item_name(category: str) -> str: selection_reasons=selection_reasons, purchase_suggestions=purchase_suggestions, suggested_accessories=suggested_accessories, + goal_raw=goal_raw, + goal_normalized=goal_normalized, ) data = response.json() @@ -522,6 +610,8 @@ def item_name(category: str) -> str: selection_reasons=selection_reasons, purchase_suggestions=purchase_suggestions, suggested_accessories=suggested_accessories, + goal_raw=goal_raw, + goal_normalized=goal_normalized, ) @@ -533,6 +623,8 @@ def generate_basic_recommendation( selection_reasons: dict[str, str], purchase_suggestions: list[dict], suggested_accessories: list[dict], + goal_raw: str, + goal_normalized: str, ) -> str: """ 生成规则版推荐文本(不依赖 LLM)。 @@ -540,9 +632,15 @@ def generate_basic_recommendation( lines = [ f"### 今日穿搭结论", f"体感温度约 **{weather.feelsLike}°C**,按 **{temperature_profile['label']}** 策略搭配:{temperature_profile['advice']}", + ] + + if goal_raw or goal_normalized: + lines.append(f"本次目标:**{goal_raw or goal_normalized}**") + + lines.extend([ "", "### 衣柜命中单品", - ] + ]) category_names = {"top": "上装", "bottom": "下装", "shoes": "鞋履"} for category in ("top", "bottom", "shoes"): diff --git a/backend/services/weather.py b/backend/services/weather.py index d488697..782630a 100644 --- a/backend/services/weather.py +++ b/backend/services/weather.py @@ -77,16 +77,27 @@ class WeatherInfo(BaseModel): LEGACY_CITY_BY_ID = {city["legacy_id"]: city for city in COMMON_CITIES} LOCATION_SUFFIXES = ( - "特别行政区", "自治区", "自治州", "地区", "盟", "省", "市", "区", "县" + "特别行政区", "自治区", "自治州", "地区", "盟", "省", "市", "区", "县", "都" ) LOCATION_PART_SEPARATOR_REGEX = re.compile(r"[,,]+") +CHINESE_CHAR_REGEX = re.compile(r"[\u4e00-\u9fff]") +LOCATION_TOKEN_SEPARATOR_REGEX = re.compile(r"[;;/||]+") +TRADITIONAL_TO_SIMPLIFIED_MAP = str.maketrans({ + "東": "东", + "倫": "伦", + "臺": "台", + "紐": "纽", + "約": "约", +}) +NOMINATIM_USER_AGENT = "AIWardrobe/1.0 (city-search)" DEFAULT_LOCATION_QUERY = "上海, 上海市, 中国" def normalize_location_query(query: str) -> str: """归一化地区输入,提升匹配率。""" normalized = (query or "").strip().lower() - normalized = re.sub(r"[\s,,/·\-]+", "", normalized) + normalized = normalized.translate(TRADITIONAL_TO_SIMPLIFIED_MAP) + normalized = re.sub(r"[\s,,/·\-;;||]+", "", normalized) for suffix in LOCATION_SUFFIXES: if normalized.endswith(suffix): @@ -95,6 +106,69 @@ def normalize_location_query(query: str) -> str: return normalized +def split_location_tokens(value: str) -> List[str]: + raw_value = (value or "").strip() + if not raw_value: + return [] + + tokens = [raw_value] + for token in LOCATION_TOKEN_SEPARATOR_REGEX.split(raw_value): + stripped = token.strip() + if stripped and stripped not in tokens: + tokens.append(stripped) + return tokens + + +def build_geocoding_queries(query: str) -> List[str]: + """ + 构建通用地理编码查询词变体,提升不同地区命名习惯下的命中率。 + """ + raw_query = (query or "").strip() + if not raw_query: + return [] + + queries = [raw_query] + parts = [ + part.strip() + for part in LOCATION_PART_SEPARATOR_REGEX.split(raw_query) + if part.strip() + ] + primary_part = parts[0] if parts else raw_query + + if primary_part != raw_query: + queries.append(primary_part) + + if CHINESE_CHAR_REGEX.search(primary_part): + base_part = primary_part + for suffix in LOCATION_SUFFIXES: + if base_part.endswith(suffix) and len(base_part) > len(suffix): + base_part = base_part[: -len(suffix)] + queries.append(base_part) + break + + if not any(primary_part.endswith(suffix) for suffix in LOCATION_SUFFIXES): + queries.append(f"{primary_part}市") + + # 去重并保持顺序 + deduped: List[str] = [] + seen = set() + for value in queries: + if value in seen: + continue + seen.add(value) + deduped.append(value) + return deduped + + +def detect_geocoding_language(query: str) -> str: + """ + 根据用户输入粗略推断检索语言。 + - 包含 CJK 字符时使用中文返回 + - 其他情况使用英文返回 + """ + return "zh" if CHINESE_CHAR_REGEX.search(query or "") else "en" + + def is_complete_text_location(location: str) -> bool: """ 判断文本地点是否足够完整(如:南京, 江苏, 中国)。 @@ -224,22 +298,38 @@ def city_match_score(city: CityInfo, query: str) -> int: best_score = 0 for candidate in candidates: - 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: - best_score = max(best_score, 70) - elif normalized_candidate in normalized_query: - best_score = max(best_score, 55) + for token in split_location_tokens(candidate or ""): + normalized_candidate = normalize_location_query(token) + if not normalized_candidate: + continue - if normalize_location_query(city.name) and normalize_location_query(city.name) in normalized_query: + if normalized_query == normalized_candidate: + best_score = max(best_score, 100) + elif normalized_query in normalized_candidate: + best_score = max(best_score, 70) + elif normalized_candidate in normalized_query: + best_score = max(best_score, 55) + + city_name_tokens = split_location_tokens(city.name) + city_adm2_tokens = split_location_tokens(city.adm2) + city_adm1_tokens = split_location_tokens(city.adm1) + if any( + normalize_location_query(token) + and normalize_location_query(token) in normalized_query + for token in city_name_tokens + ): best_score += 20 - if normalize_location_query(city.adm2) and normalize_location_query(city.adm2) in normalized_query: + if any( + normalize_location_query(token) + and normalize_location_query(token) in normalized_query + for token in city_adm2_tokens + ): best_score += 10 - if normalize_location_query(city.adm1) and normalize_location_query(city.adm1) in normalized_query: + if any( + normalize_location_query(token) + and normalize_location_query(token) in normalized_query + for token in city_adm1_tokens + ): best_score += 5 return best_score @@ -261,10 +351,109 @@ def _city_from_common(city_data: dict) -> CityInfo: ) +def _merge_ranked_city( + ranked_cities: dict[str, tuple[CityInfo, int]], + city: CityInfo, + score: int, +) -> None: + existed = ranked_cities.get(city.id) + if not existed or score > existed[1]: + ranked_cities[city.id] = (city, score) + + +def _city_from_open_meteo_row(row: dict) -> Optional[tuple[CityInfo, int]]: + latitude = row.get("latitude") + longitude = row.get("longitude") + if latitude is None or longitude is None: + return None + + 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), + ) + + rank_bonus = 0 + feature_code = str(row.get("feature_code") or "") + if feature_code == "PPLC": + rank_bonus += 30 + elif feature_code.startswith("PPLA"): + rank_bonus += 22 + elif feature_code.startswith("PPL"): + rank_bonus += 10 + + population = row.get("population") + if isinstance(population, (int, float)): + rank_bonus += min(int(float(population) // 500000), 12) + + return city, rank_bonus + + +def _city_from_nominatim_row(row: dict) -> Optional[tuple[CityInfo, int]]: + try: + latitude = float(str(row.get("lat"))) + longitude = float(str(row.get("lon"))) + except (TypeError, ValueError): + return None + + address = row.get("address") if isinstance(row.get("address"), dict) else {} + name = str( + row.get("name") + or address.get("city") + or address.get("town") + or address.get("municipality") + or address.get("county") + or address.get("state") + or "未知地区" + ) + adm2 = str( + address.get("city") + or address.get("town") + or address.get("municipality") + or address.get("county") + or "" + ) + adm1 = str(address.get("state") or address.get("province") or address.get("region") or "") + country = str(address.get("country") or "") + + city = CityInfo( + name=name, + id=format_coordinate_id(latitude, longitude), + adm1=adm1, + adm2=adm2, + country=country, + lat=f"{latitude}", + lon=f"{longitude}", + ) + + rank_bonus = 0 + try: + importance = float(row.get("importance") or 0) + except (TypeError, ValueError): + importance = 0 + importance = max(0.0, min(importance, 1.0)) + rank_bonus += int(importance * 70) + + addresstype = str(row.get("addresstype") or "") + if addresstype in ("city", "town", "municipality"): + rank_bonus += 18 + elif addresstype in ("province", "state", "region"): + rank_bonus += 10 + + if str(row.get("category") or "") == "boundary": + rank_bonus += 4 + + return city, rank_bonus + + async def search_city(query: str, limit: int = 10) -> List[CityInfo]: """ 搜索城市(支持模糊查询) - 优先使用 Open-Meteo 地理编码 API,失败则回退到内置城市列表。 + 综合 Open-Meteo 与 Nominatim 地理编码结果,失败时回退到内置城市列表。 """ normalized_query = normalize_location_query(query) if not normalized_query: @@ -292,48 +481,87 @@ async def search_city(query: str, limit: int = 10) -> List[CityInfo]: ) ] - # Open-Meteo Geocoding + # 多源 Geocoding(Open-Meteo + Nominatim) + geocoding_failed = False + ranked_cities: dict[str, tuple[CityInfo, int]] = {} + geocoding_queries = build_geocoding_queries(query) + geocoding_language = detect_geocoding_language(query) try: - 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] + async with httpx.AsyncClient( + headers={"User-Agent": NOMINATIM_USER_AGENT} + ) as client: + for geocoding_query in geocoding_queries: + try: + response = await client.get( + "https://geocoding-api.open-meteo.com/v1/search", + params={ + "name": geocoding_query, + "count": min(max(limit, 1), 20), + "language": geocoding_language, + "format": "json", + }, + timeout=10.0, + ) + response.raise_for_status() + payload = response.json() + except Exception: + geocoding_failed = True + continue + + for row in payload.get("results", []): + city_with_bonus = _city_from_open_meteo_row(row) + if not city_with_bonus: + continue + city, rank_bonus = city_with_bonus + base_score = city_match_score(city, query) + if base_score <= 0: + continue + _merge_ranked_city(ranked_cities, city, base_score + rank_bonus) + + # Nominatim 更擅长多语种地名与别名检索,补充覆盖 + nominatim_limit = min(max(limit * 2, 10), 20) + for nominatim_query in geocoding_queries[:2]: + try: + response = await client.get( + "https://nominatim.openstreetmap.org/search", + params={ + "q": nominatim_query, + "format": "jsonv2", + "accept-language": geocoding_language, + "addressdetails": 1, + "limit": nominatim_limit, + }, + timeout=10.0, + ) + response.raise_for_status() + payload = response.json() + except Exception: + geocoding_failed = True + continue + + for row in payload: + city_with_bonus = _city_from_nominatim_row(row) + if not city_with_bonus: + continue + city, rank_bonus = city_with_bonus + base_score = city_match_score(city, query) + if base_score <= 0: + continue + _merge_ranked_city(ranked_cities, city, base_score + rank_bonus) except Exception as e: + geocoding_failed = True print(f"⚠️ Geocoding 查询失败,使用内置城市兜底: {e}") + if ranked_cities: + ranked_results = sorted( + ranked_cities.values(), + key=lambda item: item[1], + reverse=True, + ) + return [city for city, _ in ranked_results[:limit]] + if geocoding_failed: + print("⚠️ Geocoding 查询不可用,使用内置城市兜底") + # 回退方案:内置城市模糊匹配 matched_cities: List[CityInfo] = [] for city_data in COMMON_CITIES: diff --git a/backend/storage/config_store.py b/backend/storage/config_store.py index 7cb3e5f..59d52ea 100644 --- a/backend/storage/config_store.py +++ b/backend/storage/config_store.py @@ -8,25 +8,52 @@ from services.weather import validate_location_input, DEFAULT_LOCATION_QUERY CONFIG_FILE = Path(__file__).parent / "llm_config.json" +_CONFIG_CACHE: Optional[LLMConfig] = None +_CONFIG_MTIME: Optional[float] = None def load_config() -> LLMConfig: """加载 LLM 配置""" - if CONFIG_FILE.exists(): - try: - with open(CONFIG_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - return LLMConfig(**data) - except Exception: - pass - return LLMConfig() + global _CONFIG_CACHE, _CONFIG_MTIME + + if not CONFIG_FILE.exists(): + _CONFIG_CACHE = LLMConfig() + _CONFIG_MTIME = None + return _CONFIG_CACHE + + try: + mtime = CONFIG_FILE.stat().st_mtime + except Exception: + mtime = None + + if _CONFIG_CACHE is not None and _CONFIG_MTIME == mtime: + return _CONFIG_CACHE + + try: + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + _CONFIG_CACHE = LLMConfig(**data) + _CONFIG_MTIME = mtime + return _CONFIG_CACHE + except Exception: + _CONFIG_CACHE = LLMConfig() + _CONFIG_MTIME = mtime + return _CONFIG_CACHE def save_config(config: LLMConfig) -> None: """保存 LLM 配置""" + global _CONFIG_CACHE, _CONFIG_MTIME + with open(CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(config.model_dump(), f, indent=2, ensure_ascii=False) + _CONFIG_CACHE = config + try: + _CONFIG_MTIME = CONFIG_FILE.stat().st_mtime + except Exception: + _CONFIG_MTIME = None + def update_config( api_base: Optional[str] = None, diff --git a/docs/images/screenshot_input.jpg b/docs/images/screenshot_input.jpg index c1e306d..017763d 100644 Binary files a/docs/images/screenshot_input.jpg and b/docs/images/screenshot_input.jpg differ diff --git a/docs/images/screenshot_landing.jpg b/docs/images/screenshot_landing.jpg index 48e9c2d..d893c98 100644 Binary files a/docs/images/screenshot_landing.jpg and b/docs/images/screenshot_landing.jpg differ diff --git a/docs/images/screenshot_wardrobe.jpg b/docs/images/screenshot_wardrobe.jpg index b59da6e..f468bd0 100644 Binary files a/docs/images/screenshot_wardrobe.jpg and b/docs/images/screenshot_wardrobe.jpg differ diff --git a/frontend/README.md b/frontend/README.md index 18bc70e..c3426a1 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -14,3 +14,17 @@ The React Compiler is not enabled on this template because of its impact on dev ## Expanding the ESLint configuration If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. + +## Screenshots + +### Input + +![Input Screenshot](../docs/images/screenshot_input.jpg) + +### Landing + +![Landing Screenshot](../docs/images/screenshot_landing.jpg) + +### Wardrobe + +![Wardrobe Screenshot](../docs/images/screenshot_wardrobe.jpg) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index df507aa..bf0e093 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,6 +2,7 @@ import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-d import Home from './pages/Home' import Entry from './pages/Entry' import Wardrobe from './pages/Wardrobe' +import ClothesDetail from './pages/ClothesDetail' import Outfit from './pages/Outfit' import Recommendation from './pages/Recommendation' import TabBar from './components/TabBar' @@ -15,6 +16,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/FilterBar.jsx b/frontend/src/components/FilterBar.jsx index 25bac0e..a89bf90 100644 --- a/frontend/src/components/FilterBar.jsx +++ b/frontend/src/components/FilterBar.jsx @@ -1,9 +1,10 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Search } from 'lucide-react' import { useTranslation } from 'react-i18next' export default function FilterBar({ onSearch, onFilterChange }) { const { t } = useTranslation() + const [searchText, setSearchText] = useState('') const [selectedSeasons, setSelectedSeasons] = useState([]) const [selectedStyles, setSelectedStyles] = useState([]) @@ -25,6 +26,14 @@ export default function FilterBar({ onSearch, onFilterChange }) { { key: 'commute', label: t('filter.commute') } ] + useEffect(() => { + const timer = setTimeout(() => { + onSearch(searchText) + }, 200) + + return () => clearTimeout(timer) + }, [searchText, onSearch]) + const toggleSeason = (season) => { const newSeasons = selectedSeasons.includes(season) ? selectedSeasons.filter(s => s !== season) @@ -51,7 +60,8 @@ export default function FilterBar({ onSearch, onFilterChange }) { type="text" placeholder={t('wardrobe.searchPlaceholder')} className="w-full pl-10 pr-4 py-2.5 bg-zinc-100/80 dark:bg-zinc-800/80 border-transparent rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-accent/20 focus:bg-white dark:focus:bg-zinc-800 transition-all text-zinc-800 dark:text-zinc-200 placeholder:text-zinc-400" - onChange={(e) => onSearch(e.target.value)} + value={searchText} + onChange={(e) => setSearchText(e.target.value)} /> diff --git a/frontend/src/components/Settings.jsx b/frontend/src/components/Settings.jsx index 56f911f..1af3177 100644 --- a/frontend/src/components/Settings.jsx +++ b/frontend/src/components/Settings.jsx @@ -1,7 +1,8 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useTheme } from '../contexts/ThemeContext' import { Sun, Moon, Globe, Sparkles, MapPin } from 'lucide-react' +import { API_BASE } from '../utils/api' const LANGUAGES = [ { code: 'zh', label: '中文' }, @@ -28,6 +29,32 @@ 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 LOCATION_PRESETS = [ + '上海, 上海市, 中国', + '北京, 北京市, 中国', + '南京, 江苏, 中国', + '杭州, 浙江, 中国', + '广州, 广东, 中国', + '深圳, 广东, 中国' +] + +const formatLocationCandidate = (city) => { + const name = (city?.name || '').trim() + const state = (city?.adm1 || city?.adm2 || '').trim() + const country = (city?.country || '').trim() + if (!name || !state || !country) return null + + const value = `${name}, ${state}, ${country}` + const labelParts = [city.name] + if (city.adm2 && city.adm2 !== city.name) labelParts.push(city.adm2) + if (city.adm1 && city.adm1 !== city.adm2 && city.adm1 !== city.name) labelParts.push(city.adm1) + if (city.country) labelParts.push(city.country) + + return { + value, + label: labelParts.filter(Boolean).join(' · ') + } +} const isCompleteLocationInput = (location) => { const raw = (location || '').trim() @@ -61,24 +88,106 @@ const Settings = ({ isOpen, onClose, onSave }) => { const [hasExistingKey, setHasExistingKey] = useState(false) const [hasRemoveBgKey, setHasRemoveBgKey] = useState(false) const [showModelSelect, setShowModelSelect] = useState(false) - - const API_BASE = `http://${window.location.hostname}:8000/api` + const [locationSuggestions, setLocationSuggestions] = useState([]) + const [searchingLocations, setSearchingLocations] = useState(false) + const [showLocationDropdown, setShowLocationDropdown] = useState(false) + const locationPickerRef = useRef(null) useEffect(() => { if (isOpen) { - fetchConfig() + const controller = new AbortController() + void fetchConfig(controller.signal) document.body.style.overflow = 'hidden' - } else { - document.body.style.overflow = '' + return () => { + controller.abort() + document.body.style.overflow = '' + } } + + document.body.style.overflow = '' return () => { document.body.style.overflow = '' } }, [isOpen]) - const fetchConfig = async () => { + useEffect(() => { + if (!showLocationDropdown) { + return + } + + const handleOutsidePointerDown = (event) => { + if (locationPickerRef.current && !locationPickerRef.current.contains(event.target)) { + setShowLocationDropdown(false) + } + } + + document.addEventListener('mousedown', handleOutsidePointerDown) + return () => { + document.removeEventListener('mousedown', handleOutsidePointerDown) + } + }, [showLocationDropdown]) + + useEffect(() => { + if (!showLocationDropdown) { + return + } + + const query = (config.weather_location || '').trim() + const filteredPresets = LOCATION_PRESETS + .filter(location => !query || location.toLowerCase().includes(query.toLowerCase())) + .map(location => ({ value: location, label: location })) + + const controller = new AbortController() + const timer = setTimeout(async () => { + if (!query) { + setLocationSuggestions(filteredPresets) + setSearchingLocations(false) + return + } + + setSearchingLocations(true) + try { + const response = await fetch(`${API_BASE}/cities?query=${encodeURIComponent(query)}&limit=10`, { + signal: controller.signal + }) + if (!response.ok) { + setLocationSuggestions(filteredPresets) + return + } + const cities = await response.json() + const cityOptions = (cities || []) + .map(formatLocationCandidate) + .filter(Boolean) + + const deduped = [] + const seen = new Set() + for (const item of [...filteredPresets, ...cityOptions]) { + if (seen.has(item.value)) continue + seen.add(item.value) + deduped.push(item) + } + setLocationSuggestions(deduped) + } catch (error) { + if (error.name !== 'AbortError') { + console.error('Failed to search city suggestions:', error) + setLocationSuggestions(filteredPresets) + } + } finally { + if (!controller.signal.aborted) { + setSearchingLocations(false) + } + } + }, 250) + + return () => { + controller.abort() + clearTimeout(timer) + } + }, [config.weather_location, showLocationDropdown]) + + const fetchConfig = async (signal) => { try { - const response = await fetch(`${API_BASE}/config`) + const response = await fetch(`${API_BASE}/config`, { signal }) if (response.ok) { const data = await response.json() setConfig(prev => ({ @@ -93,7 +202,9 @@ const Settings = ({ isOpen, onClose, onSave }) => { setHasRemoveBgKey(data.has_removebg_key) } } catch (error) { - console.error('Failed to fetch config:', error) + if (error.name !== 'AbortError') { + console.error('Failed to fetch config:', error) + } } } @@ -301,18 +412,58 @@ const Settings = ({ isOpen, onClose, onSave }) => { {t('settings.defaultCity')} - { - setConfig(prev => ({ ...prev, weather_location: e.target.value })) - if (testResult?.success === false) { - setTestResult(null) - } - }} - placeholder={t('settings.defaultCityPlaceholder')} - /> +
+ setShowLocationDropdown(true)} + onChange={e => { + setConfig(prev => ({ ...prev, weather_location: e.target.value })) + setShowLocationDropdown(true) + if (testResult?.success === false) { + setTestResult(null) + } + }} + placeholder={t('settings.defaultCityPlaceholder')} + /> + + + {showLocationDropdown && ( +
+ {locationSuggestions.length > 0 ? ( + locationSuggestions.map(option => ( + + )) + ) : searchingLocations ? ( +
{t('recommendation.searching')}
+ ) : ( +
{t('recommendation.noCity')}
+ )} +
+ )} +
diff --git a/frontend/src/components/TabBar.jsx b/frontend/src/components/TabBar.jsx index e15c64f..eb2ffe7 100644 --- a/frontend/src/components/TabBar.jsx +++ b/frontend/src/components/TabBar.jsx @@ -16,22 +16,24 @@ export default function TabBar() { ] return ( -