diff --git a/backend/api/config.py b/backend/api/config.py index 59801bd..2588c12 100644 --- a/backend/api/config.py +++ b/backend/api/config.py @@ -26,7 +26,8 @@ async def set_config(config_update: LLMConfigUpdate): 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 + qweather_api_host=config_update.qweather_api_host, + zodiac_sign=config_update.zodiac_sign ) return { "success": True, diff --git a/backend/api/horoscope.py b/backend/api/horoscope.py new file mode 100644 index 0000000..d002222 --- /dev/null +++ b/backend/api/horoscope.py @@ -0,0 +1,46 @@ +""" +星座运势 API +""" +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +from services.horoscope import get_daily_horoscope +from services.weather import get_weather + +router = APIRouter() + + +class HoroscopeResponse(BaseModel): + """今日星座运势响应""" + date: str + zodiac_sign: str + zodiac_name: str + is_configured: bool + summary: str + mood: str + lucky_color: str + lucky_number: int + suggestion: str + + +@router.get("/horoscope/daily", response_model=HoroscopeResponse) +async def get_today_horoscope( + location: str = Query( + default="101020100", + description="LocationID 或 经纬度坐标" + ), + zodiac_sign: Optional[str] = Query( + default=None, + description="可选,若传入会覆盖设置中的星座" + ) +): + """ + 获取今日星座运势 + """ + weather = await get_weather(location) + if not weather: + raise HTTPException(status_code=500, detail="获取天气信息失败") + + return await get_daily_horoscope(weather=weather, zodiac_sign=zodiac_sign) diff --git a/backend/domain/config.py b/backend/domain/config.py index a2a619e..7840f74 100644 --- a/backend/domain/config.py +++ b/backend/domain/config.py @@ -16,6 +16,8 @@ class LLMConfig(BaseModel): # 和风天气 API 配置 qweather_api_key: str = "baaa1bb687294acc949bf2a979e0084e" qweather_api_host: str = "devapi.qweather.com" # 免费版: devapi.qweather.com | 付费版: api.qweather.com + # 用户星座配置(用于首页运势) + zodiac_sign: str = "" class LLMConfigUpdate(BaseModel): @@ -27,6 +29,7 @@ class LLMConfigUpdate(BaseModel): bg_removal_method: Optional[Literal["local", "removebg"]] = None qweather_api_key: Optional[str] = None qweather_api_host: Optional[str] = None + zodiac_sign: Optional[str] = None class AvailableModel(BaseModel): diff --git a/backend/main.py b/backend/main.py index e0dffbe..f2b721b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -13,6 +13,7 @@ from api.config import router as config_router from api.weather import router as weather_router from api.recommendation import router as recommendation_router +from api.horoscope import router as horoscope_router from storage.db import init_db # 上传目录 @@ -56,6 +57,7 @@ async def lifespan(app: FastAPI): app.include_router(config_router, prefix="/api", tags=["配置"]) app.include_router(weather_router, prefix="/api", tags=["天气"]) app.include_router(recommendation_router, prefix="/api", tags=["AI推荐"]) +app.include_router(horoscope_router, prefix="/api", tags=["星座运势"]) @app.get("/api") @@ -72,7 +74,8 @@ async def api_info(): "delete_clothes": "DELETE /api/clothes/{id}", "weather": "GET /api/weather", "weather_suggestion": "GET /api/weather/suggestion", - "ai_recommendation": "GET /api/recommendation" + "ai_recommendation": "GET /api/recommendation", + "daily_horoscope": "GET /api/horoscope/daily" } } diff --git a/backend/services/horoscope.py b/backend/services/horoscope.py new file mode 100644 index 0000000..361343a --- /dev/null +++ b/backend/services/horoscope.py @@ -0,0 +1,278 @@ +""" +星座运势服务 +根据日期、天气和用户星座生成今日运势 +""" +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 services.weather import WeatherInfo + +ZODIAC_NAMES = { + "aries": "白羊座", + "taurus": "金牛座", + "gemini": "双子座", + "cancer": "巨蟹座", + "leo": "狮子座", + "virgo": "处女座", + "libra": "天秤座", + "scorpio": "天蝎座", + "sagittarius": "射手座", + "capricorn": "摩羯座", + "aquarius": "水瓶座", + "pisces": "双鱼座" +} + +ZODIAC_ALIASES = { + "白羊": "aries", + "白羊座": "aries", + "aries": "aries", + "金牛": "taurus", + "金牛座": "taurus", + "taurus": "taurus", + "双子": "gemini", + "双子座": "gemini", + "gemini": "gemini", + "巨蟹": "cancer", + "巨蟹座": "cancer", + "cancer": "cancer", + "狮子": "leo", + "狮子座": "leo", + "leo": "leo", + "处女": "virgo", + "处女座": "virgo", + "virgo": "virgo", + "天秤": "libra", + "天秤座": "libra", + "libra": "libra", + "天蝎": "scorpio", + "天蝎座": "scorpio", + "scorpio": "scorpio", + "射手": "sagittarius", + "射手座": "sagittarius", + "sagittarius": "sagittarius", + "摩羯": "capricorn", + "摩羯座": "capricorn", + "capricorn": "capricorn", + "水瓶": "aquarius", + "水瓶座": "aquarius", + "aquarius": "aquarius", + "双鱼": "pisces", + "双鱼座": "pisces", + "pisces": "pisces" +} + +ZODIAC_TRAITS = { + "aries": "行动力", + "taurus": "稳定感", + "gemini": "沟通力", + "cancer": "共情力", + "leo": "表现力", + "virgo": "细节力", + "libra": "平衡感", + "scorpio": "洞察力", + "sagittarius": "探索欲", + "capricorn": "执行力", + "aquarius": "创造力", + "pisces": "想象力" +} + +DEFAULT_COLORS = { + "aries": "珊瑚红", + "taurus": "苔藓绿", + "gemini": "柠檬黄", + "cancer": "珍珠白", + "leo": "琥珀金", + "virgo": "雾霾蓝", + "libra": "樱花粉", + "scorpio": "深酒红", + "sagittarius": "靛青蓝", + "capricorn": "岩石灰", + "aquarius": "电光蓝", + "pisces": "海盐蓝" +} + + +def normalize_zodiac_sign(sign: Optional[str]) -> Optional[str]: + """将用户输入的星座归一化为内部 key。""" + if not sign: + return None + + normalized = sign.strip().lower().replace(" ", "") + if not normalized: + return None + + return ZODIAC_ALIASES.get(normalized) + + +def build_weather_tip(weather: WeatherInfo) -> str: + """生成与天气相关的实用建议。""" + condition = weather.condition or "" + feels_like = weather.feelsLike + + if "雨" in condition: + return "今天可能有雨,建议准备轻便雨具并选择防滑鞋。" + if "雪" in condition: + return "今天偏冷且可能有雪,优先保暖并注意鞋底防滑。" + if feels_like >= 30: + return "体感偏热,建议选择透气面料并及时补水。" + if feels_like <= 8: + return "体感偏冷,建议叠穿并注意颈部和脚踝保暖。" + if "晴" in condition: + return "阳光较好,外出可搭配防晒配件提升舒适度。" + + return "整体体感平稳,穿搭上可兼顾舒适与层次感。" + + +def fallback_horoscope(sign_key: str, weather: WeatherInfo) -> dict: + """在未配置 LLM 或调用失败时提供基础运势。""" + today = datetime.now().strftime("%Y-%m-%d") + 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},把精力集中在一件最重要的事上,会有更稳定的收获。", + "mood": "稳中有进", + "lucky_color": DEFAULT_COLORS.get(sign_key, "浅蓝色"), + "lucky_number": lucky_number, + "suggestion": weather_tip + } + + +def sanitize_horoscope_payload(payload: dict, weather_tip: str) -> dict: + """清洗 LLM 输出,保证字段完整可用。""" + summary = str(payload.get("summary", "")).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) + + return { + "summary": summary, + "mood": mood, + "lucky_color": lucky_color, + "lucky_number": lucky_number, + "suggestion": suggestion + } + + +async def generate_llm_horoscope(sign_key: str, weather: WeatherInfo) -> Optional[dict]: + """调用 OpenAI 兼容接口生成运势文本。""" + config = load_config() + if not config.api_key: + return None + + 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,不要代码块,不要额外说明。 +""" + + payload = { + "model": config.model, + "messages": [ + { + "role": "system", + "content": "你输出结构化 JSON,内容友好、理性、可执行。" + }, + { + "role": "user", + "content": prompt + } + ], + "temperature": 0.7 + } + + try: + async with httpx.AsyncClient(timeout=12.0) as client: + response = await client.post( + f"{api_base}/chat/completions", + headers={ + "Authorization": f"Bearer {config.api_key}", + "Content-Type": "application/json" + }, + 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) + except Exception as exc: + print(f"LLM 星座运势调用异常: {exc}") + return None + + +async def get_daily_horoscope(weather: WeatherInfo, zodiac_sign: Optional[str] = None) -> 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, + "zodiac_sign": "", + "zodiac_name": "未设置", + "is_configured": False, + "summary": "你还没有设置星座,先去设置里选择后即可获得专属今日运势。", + "mood": "待设置", + "lucky_color": "云白色", + "lucky_number": 6, + "suggestion": build_weather_tip(weather) + } + + llm_result = await generate_llm_horoscope(sign_key, weather) + if not llm_result: + llm_result = fallback_horoscope(sign_key, weather) + + 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"] + } diff --git a/backend/services/weather.py b/backend/services/weather.py index 97497cf..2e5f35c 100644 --- a/backend/services/weather.py +++ b/backend/services/weather.py @@ -5,6 +5,7 @@ """ import httpx import os +import re from typing import Optional, List from pydantic import BaseModel @@ -84,6 +85,83 @@ class WeatherInfo(BaseModel): {"name": "哈尔滨", "adm1": "黑龙江", "country": "中国", "id": "101050101", "keywords": ["haerbin", "哈尔滨", "heb"]}, ] +LOCATION_SUFFIXES = ( + "特别行政区", "自治区", "自治州", "地区", "盟", "省", "市", "区", "县" +) + + +def normalize_location_query(query: str) -> str: + """归一化地区输入,提升省市区县等写法的匹配率。""" + normalized = (query or "").strip().lower() + normalized = re.sub(r"[\s,,/·\-]+", "", normalized) + + for suffix in LOCATION_SUFFIXES: + if normalized.endswith(suffix): + normalized = normalized[: -len(suffix)] + + return normalized + + +def is_location_id(location: str) -> bool: + return bool(re.fullmatch(r"\d{9}", location.strip())) + + +def is_coordinate_location(location: str) -> bool: + return bool(re.fullmatch(r"\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*", location.strip())) + + +def format_city_display_name(city: CityInfo) -> str: + parts = [] + + if city.name: + parts.append(city.name) + + for value in (city.adm2, city.adm1): + if value and value not in parts: + parts.append(value) + + if city.country and city.country not in parts: + parts.append(city.country) + + 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) + candidates = [ + city.name, + city.adm1, + city.adm2, + f"{city.adm1}{city.name}", + f"{city.adm2}{city.name}", + f"{city.adm1}{city.adm2}{city.name}", + ] + + 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) + + if normalize_location_query(city.name) and normalize_location_query(city.name) in normalized_query: + best_score += 20 + if normalize_location_query(city.adm2) and normalize_location_query(city.adm2) in normalized_query: + best_score += 10 + if normalize_location_query(city.adm1) and normalize_location_query(city.adm1) in normalized_query: + best_score += 5 + + return best_score + async def search_city(query: str, limit: int = 10) -> List[CityInfo]: """ @@ -97,6 +175,10 @@ async def search_city(query: str, limit: int = 10) -> List[CityInfo]: Returns: 城市信息列表 """ + normalized_query = normalize_location_query(query) + if not normalized_query: + return [] + # 优先从配置系统读取 API Key try: from storage.config_store import load_config @@ -111,15 +193,38 @@ async def search_city(query: str, limit: int = 10) -> List[CityInfo]: if api_key and api_key != "your_qweather_api_key_here": try: url = f"https://{api_host}/geo/v2/city/lookup" - params = {"location": query, "number": limit, "lang": "zh"} - headers = {"Authorization": f"Bearer {api_key}"} + params = { + "location": query.strip(), + "number": limit, + "lang": "zh", + "key": api_key + } async with httpx.AsyncClient() as client: - response = await client.get(url, params=params, headers=headers, timeout=10.0) + 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( @@ -136,25 +241,55 @@ async def search_city(query: str, limit: int = 10) -> List[CityInfo]: print(f"⚠️ GeoAPI调用失败,使用预定义城市列表: {e}") # 降级方案:使用预定义城市列表进行模糊搜索 - query_lower = query.lower() matched_cities = [] for city_data in COMMON_CITIES: - # 检查是否匹配任何关键词 - if any(query_lower in keyword.lower() for keyword in city_data["keywords"]): + keyword_matched = any( + normalized_query in normalize_location_query(keyword) + or normalize_location_query(keyword) in normalized_query + for keyword in city_data["keywords"] + ) + region_matched = any( + normalized_query in normalize_location_query(city_data.get(field, "")) + or normalize_location_query(city_data.get(field, "")) in normalized_query + for field in ("name", "adm1") + if city_data.get(field) + ) + + 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["adm1"], # 使用adm1作为adm2 + adm2=city_data["name"], country=city_data["country"], lat="0", # 预定义列表不包含坐标 lon="0" )) - + + matched_cities.sort(key=lambda city: city_match_score(city, query), reverse=True) return matched_cities[:limit] +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): + return raw_location, raw_location + + cities = await search_city(raw_location, limit=1) + if cities: + city = cities[0] + return city.id, format_city_display_name(city) + + return raw_location, raw_location + + async def get_qweather_now(location: str) -> Optional[WeatherResponse]: """ 调用和风天气 API 获取实时天气 @@ -218,7 +353,8 @@ async def get_weather(location: str = "101020100") -> Optional[WeatherInfo]: Returns: WeatherInfo 或 None """ - weather_response = await get_qweather_now(location) + resolved_location, display_location = await resolve_location(location) + weather_response = await get_qweather_now(resolved_location) if not weather_response: # 返回模拟数据作为降级方案 @@ -231,7 +367,7 @@ async def get_weather(location: str = "101020100") -> Optional[WeatherInfo]: humidity=60.0, windDir="南风", windScale="2", - location=location, + location=display_location, obsTime="2024-01-01T12:00+08:00" ) @@ -244,7 +380,7 @@ async def get_weather(location: str = "101020100") -> Optional[WeatherInfo]: humidity=float(now.humidity), windDir=now.windDir, windScale=now.windScale, - location=location, + location=display_location, obsTime=now.obsTime ) diff --git a/backend/storage/config_store.py b/backend/storage/config_store.py index b42cf32..d9aceff 100644 --- a/backend/storage/config_store.py +++ b/backend/storage/config_store.py @@ -34,7 +34,8 @@ def update_config( removebg_api_key: Optional[str] = None, bg_removal_method: Optional[str] = None, qweather_api_key: Optional[str] = None, - qweather_api_host: Optional[str] = None + qweather_api_host: Optional[str] = None, + zodiac_sign: Optional[str] = None ) -> LLMConfig: """更新配置""" config = load_config() @@ -53,6 +54,8 @@ def update_config( config.qweather_api_key = qweather_api_key.strip() if qweather_api_host is not None: config.qweather_api_host = qweather_api_host.strip() + if zodiac_sign is not None: + config.zodiac_sign = zodiac_sign.strip().lower() save_config(config) return config @@ -81,5 +84,6 @@ def get_masked_config() -> dict: "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 + "qweather_api_host": config.qweather_api_host, + "zodiac_sign": config.zodiac_sign } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7c85b09..df507aa 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,5 @@ import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom' +import Home from './pages/Home' import Entry from './pages/Entry' import Wardrobe from './pages/Wardrobe' import Outfit from './pages/Outfit' @@ -11,7 +12,8 @@ function App() {
- } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Settings.jsx b/frontend/src/components/Settings.jsx index 22425f0..7901a63 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 } from 'lucide-react' +import { Sun, Moon, Globe, Sparkles } from 'lucide-react' const LANGUAGES = [ { code: 'zh', label: '中文' }, @@ -9,6 +9,21 @@ const LANGUAGES = [ { code: 'ja', label: '日本語' } ] +const ZODIAC_SIGNS = [ + 'aries', + 'taurus', + 'gemini', + 'cancer', + 'leo', + 'virgo', + 'libra', + 'scorpio', + 'sagittarius', + 'capricorn', + 'aquarius', + 'pisces' +] + const Settings = ({ isOpen, onClose, onSave }) => { const { t, i18n } = useTranslation() const { theme, toggleTheme } = useTheme() @@ -19,7 +34,8 @@ const Settings = ({ isOpen, onClose, onSave }) => { removebg_api_key: '', bg_removal_method: 'local', qweather_api_key: '', - qweather_api_host: 'devapi.qweather.com' + qweather_api_host: 'devapi.qweather.com', + zodiac_sign: '' }) const [models, setModels] = useState([]) const [loading, setLoading] = useState(false) @@ -54,7 +70,8 @@ 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' + qweather_api_host: data.qweather_api_host || 'devapi.qweather.com', + zodiac_sign: data.zodiac_sign || '' })) setHasExistingKey(data.has_api_key) setHasRemoveBgKey(data.has_removebg_key) @@ -99,7 +116,7 @@ const Settings = ({ isOpen, onClose, onSave }) => { if (data.success) { fetchModels() } - } catch (error) { + } catch { setTestResult({ success: false, message: t('settings.testFailed') @@ -115,7 +132,8 @@ const Settings = ({ isOpen, onClose, onSave }) => { api_base: config.api_base, model: config.model, bg_removal_method: config.bg_removal_method, - qweather_api_host: config.qweather_api_host + qweather_api_host: config.qweather_api_host, + zodiac_sign: config.zodiac_sign } if (config.api_key) { @@ -225,6 +243,25 @@ const Settings = ({ isOpen, onClose, onSave }) => {
+ +
+ + +
diff --git a/frontend/src/components/TabBar.jsx b/frontend/src/components/TabBar.jsx index e2edfae..e15c64f 100644 --- a/frontend/src/components/TabBar.jsx +++ b/frontend/src/components/TabBar.jsx @@ -1,5 +1,5 @@ import { useNavigate, useLocation } from 'react-router-dom' -import { PlusCircle, Search, User, Sparkles } from 'lucide-react' +import { House, PlusCircle, Search, User, Sparkles } from 'lucide-react' import { useTranslation } from 'react-i18next' export default function TabBar() { @@ -8,7 +8,8 @@ export default function TabBar() { const { t } = useTranslation() const tabs = [ - { path: '/', icon: PlusCircle, label: t('tabs.entry') }, + { path: '/', icon: House, label: t('tabs.home') }, + { path: '/entry', icon: PlusCircle, label: t('tabs.entry') }, { path: '/wardrobe', icon: Search, label: t('tabs.wardrobe') }, { path: '/outfit', icon: User, label: t('tabs.outfit') }, { path: '/recommendation', icon: Sparkles, label: t('tabs.recommendation') } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 9b50898..cdd27da 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1,5 +1,6 @@ { "tabs": { + "home": "Home", "entry": "Entry", "wardrobe": "Wardrobe", "outfit": "Outfit", @@ -84,9 +85,12 @@ "description": "AI will analyze real-time weather and your wardrobe to create the perfect Look.", "generate": "Generate Today's Recommendation", "currentLocation": "Current Location", - "searchCity": "Search city", + "searchCity": "Enter city, district, or province", + "searchAction": "Check Weather", + "searchHint": "Supports city names, districts, province-city combinations, pinyin, or QWeather LocationID.", + "searching": "Searching locations...", "noCity": "No matching city found", - "enterCity": "Enter city name to search", + "enterCity": "Enter a location to search", "aiLoading": "AI is styling for you...", "aiTitle": "AI Recommendation", "regenerate": "Regenerate", @@ -98,6 +102,36 @@ "windLevel": "", "feelsLike": "Feels like" }, + "home": { + "today": "Today", + "title": "Smart Wardrobe Home", + "refresh": "Refresh", + "loading": "Loading home data...", + "weatherTitle": "Today's Weather", + "weatherFeelsLike": "Feels Like", + "weatherHumidity": "Humidity", + "weatherWind": "Wind", + "carouselTitle": "Wardrobe Carousel", + "categoryTop": "Top", + "categoryBottom": "Bottom", + "categoryShoes": "Shoes", + "viewAll": "View all", + "emptyWardrobe": "Your wardrobe is empty. Add a few pieces first.", + "goEntry": "Add Clothes", + "noDescription": "No description yet", + "previous": "Previous item", + "next": "Next item", + "slide": "Slide", + "horoscopeTitle": "Today's Horoscope", + "mood": "Mood", + "luckyColor": "Lucky Color", + "luckyNumber": "Lucky Number", + "setZodiac": "Set Zodiac Sign", + "unknownWeather": "Unknown", + "unknownLocation": "Unknown location", + "unknownZodiac": "No zodiac", + "horoscopeFallback": "Horoscope is temporarily unavailable. Please try again later." + }, "settings": { "title": "API Settings", "llmSection": "LLM Model Settings", @@ -130,6 +164,22 @@ "theme": "Theme", "themeLight": "Light", "themeDark": "Dark", - "appSection": "App Settings" + "appSection": "App Settings", + "zodiac": "Zodiac Sign", + "zodiacPlaceholder": "Select your zodiac sign", + "zodiacOptions": { + "aries": "Aries", + "taurus": "Taurus", + "gemini": "Gemini", + "cancer": "Cancer", + "leo": "Leo", + "virgo": "Virgo", + "libra": "Libra", + "scorpio": "Scorpio", + "sagittarius": "Sagittarius", + "capricorn": "Capricorn", + "aquarius": "Aquarius", + "pisces": "Pisces" + } } } diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index 9b6821c..ccca0b0 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -1,5 +1,6 @@ { "tabs": { + "home": "ホーム", "entry": "登録", "wardrobe": "衣装棚", "outfit": "コーデ", @@ -84,9 +85,12 @@ "description": "AIがリアルタイムの天気とワードローブを分析し、最適なコーデを提案します。", "generate": "今日のコーデを生成", "currentLocation": "現在地", - "searchCity": "都市を検索", + "searchCity": "都市・地区・都道府県を入力", + "searchAction": "天気を確認", + "searchHint": "都市名、地区名、都道府県との組み合わせ、ピンイン、QWeather LocationID に対応します。", + "searching": "地点を検索中...", "noCity": "該当する都市が見つかりません", - "enterCity": "都市名を入力して検索", + "enterCity": "地点名を入力して検索", "aiLoading": "AIがコーディネート中...", "aiTitle": "AI おすすめコーデ", "regenerate": "再生成", @@ -98,6 +102,36 @@ "windLevel": "級", "feelsLike": "体感" }, + "home": { + "today": "今日", + "title": "スマートワードローブ", + "refresh": "更新", + "loading": "ホームデータを読み込み中...", + "weatherTitle": "今日の天気", + "weatherFeelsLike": "体感", + "weatherHumidity": "湿度", + "weatherWind": "風力", + "carouselTitle": "ワードローブカルーセル", + "categoryTop": "トップス", + "categoryBottom": "ボトムス", + "categoryShoes": "シューズ", + "viewAll": "すべて表示", + "emptyWardrobe": "ワードローブが空です。先に数点登録しましょう。", + "goEntry": "登録へ", + "noDescription": "説明はまだありません", + "previous": "前のアイテム", + "next": "次のアイテム", + "slide": "スライド", + "horoscopeTitle": "今日の星座運勢", + "mood": "気分", + "luckyColor": "ラッキーカラー", + "luckyNumber": "ラッキーナンバー", + "setZodiac": "星座を設定", + "unknownWeather": "不明", + "unknownLocation": "場所不明", + "unknownZodiac": "星座未設定", + "horoscopeFallback": "運勢を取得できませんでした。しばらくして再試行してください。" + }, "settings": { "title": "API 設定", "llmSection": "LLM モデル設定", @@ -130,6 +164,22 @@ "theme": "テーマ", "themeLight": "ライト", "themeDark": "ダーク", - "appSection": "アプリ設定" + "appSection": "アプリ設定", + "zodiac": "星座", + "zodiacPlaceholder": "星座を選択してください", + "zodiacOptions": { + "aries": "牡羊座", + "taurus": "牡牛座", + "gemini": "双子座", + "cancer": "蟹座", + "leo": "獅子座", + "virgo": "乙女座", + "libra": "天秤座", + "scorpio": "蠍座", + "sagittarius": "射手座", + "capricorn": "山羊座", + "aquarius": "水瓶座", + "pisces": "魚座" + } } } diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index 6f16c8b..424b537 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -1,5 +1,6 @@ { "tabs": { + "home": "首页", "entry": "录入", "wardrobe": "衣柜", "outfit": "穿搭", @@ -84,9 +85,12 @@ "description": "AI 将分析实时气温与衣柜藏品为您打造最适合的 Look。", "generate": "生成今日穿搭推荐", "currentLocation": "当前位置", - "searchCity": "搜索城市(支持拼音)", + "searchCity": "输入城市、区县或省份", + "searchAction": "查询天气并生成推荐", + "searchHint": "支持城市名、区县名、省市组合、拼音或和风天气 LocationID。", + "searching": "正在搜索地区...", "noCity": "未找到匹配的城市", - "enterCity": "请输入城市名称进行搜索", + "enterCity": "请输入地区名称进行搜索", "aiLoading": "AI 正在为您配搭...", "aiTitle": "AI 推荐穿搭", "regenerate": "重新生成", @@ -98,6 +102,36 @@ "windLevel": "级", "feelsLike": "体感" }, + "home": { + "today": "今天", + "title": "智能衣橱首页", + "refresh": "刷新", + "loading": "正在加载首页数据...", + "weatherTitle": "今天天气", + "weatherFeelsLike": "体感", + "weatherHumidity": "湿度", + "weatherWind": "风力", + "carouselTitle": "衣柜滚动轮播", + "categoryTop": "上装", + "categoryBottom": "下装", + "categoryShoes": "鞋履", + "viewAll": "查看全部", + "emptyWardrobe": "你的衣柜还是空的,先录入几件单品吧。", + "goEntry": "去录入", + "noDescription": "暂无描述", + "previous": "上一件", + "next": "下一件", + "slide": "轮播", + "horoscopeTitle": "今日星座运势", + "mood": "状态", + "luckyColor": "幸运色", + "luckyNumber": "幸运数字", + "setZodiac": "去设置星座", + "unknownWeather": "未知天气", + "unknownLocation": "未知位置", + "unknownZodiac": "未设置星座", + "horoscopeFallback": "暂时无法获取运势,稍后再试。" + }, "settings": { "title": "API 设置", "llmSection": "LLM 模型设置", @@ -130,6 +164,22 @@ "theme": "主题", "themeLight": "浅色", "themeDark": "深色", - "appSection": "应用设置" + "appSection": "应用设置", + "zodiac": "星座", + "zodiacPlaceholder": "请选择你的星座", + "zodiacOptions": { + "aries": "白羊座", + "taurus": "金牛座", + "gemini": "双子座", + "cancer": "巨蟹座", + "leo": "狮子座", + "virgo": "处女座", + "libra": "天秤座", + "scorpio": "天蝎座", + "sagittarius": "射手座", + "capricorn": "摩羯座", + "aquarius": "水瓶座", + "pisces": "双鱼座" + } } } diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx new file mode 100644 index 0000000..2c3c154 --- /dev/null +++ b/frontend/src/pages/Home.jsx @@ -0,0 +1,314 @@ +import { useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { Settings as SettingsIcon, RefreshCw, Sparkles, CloudSun, Droplets, Wind, Thermometer, ChevronLeft, ChevronRight, Shirt, ArrowRight } from 'lucide-react' +import Settings from '../components/Settings' + +const API_BASE = `http://${window.location.hostname}:8000/api` +const DEFAULT_LOCATION = '101020100' + +const formatDate = (locale) => { + const lang = locale?.startsWith('zh') + ? 'zh-CN' + : locale?.startsWith('ja') + ? 'ja-JP' + : 'en-US' + return new Date().toLocaleDateString(lang, { + month: 'long', + day: 'numeric', + weekday: 'long' + }) +} + +export default function Home() { + const { t, i18n } = useTranslation() + const navigate = useNavigate() + + const [weather, setWeather] = useState(null) + const [wardrobe, setWardrobe] = useState({ tops: [], bottoms: [], shoes: [] }) + const [horoscope, setHoroscope] = useState(null) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [showSettings, setShowSettings] = useState(false) + const [activeIndex, setActiveIndex] = useState(0) + + 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]) + + useEffect(() => { + if (activeIndex < carouselItems.length) return + setActiveIndex(0) + }, [carouselItems.length, activeIndex]) + + useEffect(() => { + if (carouselItems.length <= 1) return undefined + const timer = setInterval(() => { + setActiveIndex(prev => (prev + 1) % carouselItems.length) + }, 3500) + return () => clearInterval(timer) + }, [carouselItems.length]) + + const fetchDashboard = async (withLoading = true) => { + if (withLoading) { + setLoading(true) + } else { + setRefreshing(true) + } + + try { + const [weatherRes, wardrobeRes, horoscopeRes] = await Promise.all([ + fetch(`${API_BASE}/weather?location=${DEFAULT_LOCATION}`), + fetch(`${API_BASE}/wardrobe`), + fetch(`${API_BASE}/horoscope/daily?location=${DEFAULT_LOCATION}`) + ]) + + if (weatherRes.ok) { + setWeather(await weatherRes.json()) + } + + if (wardrobeRes.ok) { + setWardrobe(await wardrobeRes.json()) + } + + if (horoscopeRes.ok) { + setHoroscope(await horoscopeRes.json()) + } + } catch (error) { + console.error('Failed to fetch home dashboard:', error) + } finally { + setLoading(false) + setRefreshing(false) + } + } + + useEffect(() => { + fetchDashboard(true) + }, []) + + const getCategoryLabel = (category) => { + if (category === 'top') return t('home.categoryTop') + if (category === 'bottom') return t('home.categoryBottom') + return t('home.categoryShoes') + } + + return ( +
+
+
+ +
+
+

{t('home.today')}

+

{t('home.title')}

+

{formatDate(i18n.language)}

+
+ +
+ + +
+
+ + {loading ? ( +
+
+

{t('home.loading')}

+
+ ) : ( +
+
+
+

+ + {t('home.weatherTitle')} +

+ + {weather?.icon || '--'} + +
+ +
+
+
+ {weather ? `${Math.round(weather.temperature)}°` : '--'} +
+
+ {weather?.location || t('home.unknownLocation')} · {weather?.condition || t('home.unknownWeather')} +
+
+
+
+ + {t('home.weatherFeelsLike')}: {weather ? `${Math.round(weather.feelsLike)}°` : '--'} +
+
+ + {t('home.weatherHumidity')}: {weather ? `${Math.round(weather.humidity)}%` : '--'} +
+
+ + {t('home.weatherWind')}: {weather?.windScale || '--'} +
+
+
+
+ +
+
+

+ + {t('home.carouselTitle')} +

+ +
+ + {carouselItems.length === 0 ? ( +
+

{t('home.emptyWardrobe')}

+ +
+ ) : ( +
+
+
+ {carouselItems.map(item => ( +
+
+
+ {item.item} +
+
+

{item.item}

+

{getCategoryLabel(item.category)}

+ {item.description ? ( +

{item.description}

+ ) : ( +

{t('home.noDescription')}

+ )} +
+
+
+ ))} +
+
+ + {carouselItems.length > 1 && ( + <> + + + + )} + + {carouselItems.length > 1 && ( +
+ {carouselItems.map((_, idx) => ( +
+ )} +
+ )} +
+ +
+
+

+ + {t('home.horoscopeTitle')} +

+ + {horoscope?.zodiac_name || t('home.unknownZodiac')} + +
+ +

{horoscope?.summary || t('home.horoscopeFallback')}

+ +
+
+
{t('home.mood')}
+
{horoscope?.mood || '--'}
+
+
+
{t('home.luckyColor')}
+
{horoscope?.lucky_color || '--'}
+
+
+
{t('home.luckyNumber')}
+
{horoscope?.lucky_number || '--'}
+
+
+ +
+ {horoscope?.suggestion || t('home.horoscopeFallback')} +
+ + {horoscope && !horoscope.is_configured && ( + + )} +
+
+ )} + + setShowSettings(false)} + onSave={() => fetchDashboard(false)} + /> +
+ ) +} diff --git a/frontend/src/pages/Recommendation.jsx b/frontend/src/pages/Recommendation.jsx index fd45b07..d2fc356 100644 --- a/frontend/src/pages/Recommendation.jsx +++ b/frontend/src/pages/Recommendation.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { Search, MapPin, Sparkles, RefreshCw } from 'lucide-react' import ReactMarkdown from 'react-markdown' @@ -20,6 +20,9 @@ export default function Recommendation() { id: '101020100' }) const [showCityPicker, setShowCityPicker] = useState(false) + const [searchingCities, setSearchingCities] = useState(false) + const cityPickerRef = useRef(null) + const cityInputRef = useRef(null) const [displayedRecommendation, setDisplayedRecommendation] = useState('') @@ -45,47 +48,115 @@ export default function Recommendation() { return () => clearInterval(timer) }, [recommendation]) - const searchCity = async (query) => { - if (!query || query.trim().length < 1) { + useEffect(() => { + if (!showCityPicker) { + return + } + + const trimmedQuery = cityQuery.trim() + if (!trimmedQuery) { setSearchResults([]) + setSearchingCities(false) return } - try { - const response = await fetch(`${API_BASE}/cities?query=${encodeURIComponent(query)}&limit=10`) - if (response.ok) { - const cities = await response.json() - setSearchResults(cities) - } else { + const timer = setTimeout(async () => { + setSearchingCities(true) + try { + const response = await fetch(`${API_BASE}/cities?query=${encodeURIComponent(trimmedQuery)}&limit=10`) + if (response.ok) { + const cities = await response.json() + setSearchResults(cities) + } else { + setSearchResults([]) + } + } catch (error) { + console.error('City search failed:', error) setSearchResults([]) + } finally { + setSearchingCities(false) } - } catch (error) { - console.error('City search failed:', error) - setSearchResults([]) + }, 250) + + return () => clearTimeout(timer) + }, [cityQuery, showCityPicker]) + + useEffect(() => { + if (!showCityPicker) { + return } + + // 使用 requestAnimationFrame,确保下拉渲染后再聚焦输入框 + const rafId = requestAnimationFrame(() => { + cityInputRef.current?.focus() + }) + + return () => cancelAnimationFrame(rafId) + }, [showCityPicker]) + + useEffect(() => { + const handleOutsidePointerDown = (event) => { + if (!showCityPicker) { + return + } + if (cityPickerRef.current && !cityPickerRef.current.contains(event.target)) { + setShowCityPicker(false) + } + } + + document.addEventListener('mousedown', handleOutsidePointerDown) + + return () => { + document.removeEventListener('mousedown', handleOutsidePointerDown) + } + }, [showCityPicker]) + + const formatCityName = (city) => { + const segments = [city.name] + + if (city.adm2 && city.adm2 !== city.name) { + segments.push(city.adm2) + } + if (city.adm1 && city.adm1 !== city.adm2 && city.adm1 !== city.name) { + segments.push(city.adm1) + } + + return segments.join(' · ') } const selectCity = (city) => { + const cityName = formatCityName(city) setSelectedCity({ - name: `${city.name}, ${city.adm1}`, + name: cityName, id: city.id }) setShowCityPicker(false) setCityQuery('') setSearchResults([]) - fetchRecommendation(city.id) + fetchRecommendation(city.id, cityName) } - const fetchRecommendation = async (locationId) => { + const fetchRecommendation = async (location, preferredName = null) => { setLoading(true) try { - const response = await fetch(`${API_BASE}/recommendation?location=${locationId}`) + 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) + if (preferredName) { + setSelectedCity({ + name: preferredName, + id: location + }) + } else if (data.weather?.location) { + setSelectedCity({ + name: data.weather.location, + id: location + }) + } } } catch (error) { console.error('Failed to fetch recommendation:', error) @@ -94,8 +165,30 @@ export default function Recommendation() { } } + const submitCityQuery = () => { + const trimmedQuery = cityQuery.trim() + if (!trimmedQuery) { + fetchRecommendation(selectedCity.id) + return + } + + const firstResult = searchResults[0] + if (firstResult) { + selectCity(firstResult) + return + } + + setSelectedCity({ + name: trimmedQuery, + id: trimmedQuery + }) + setShowCityPicker(false) + setSearchResults([]) + fetchRecommendation(trimmedQuery) + } + const refreshRecommendation = () => { - fetchRecommendation(selectedCity.id) + fetchRecommendation(selectedCity.id, selectedCity.name) } const getWeatherIcon = (icon) => { @@ -122,40 +215,70 @@ export default function Recommendation() {
-
setShowCityPicker(!showCityPicker)}> - - {selectedCity.name} - {showCityPicker ? '▲' : '▼'} +
+ {showCityPicker && ( -
e.stopPropagation()}> +
e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + >
{ - setCityQuery(e.target.value) - searchCity(e.target.value) + onChange={(e) => setCityQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + submitCityQuery() + } }} - autoFocus />
+ + +
+ {t('recommendation.searchHint')} +
+
{searchResults.length > 0 ? ( - searchResults.map((city) => ( -
( +
+ {formatCityName(city)} + {city.country} + )) + ) : searchingCities ? ( +
{t('recommendation.searching')}
) : cityQuery ? (
{t('recommendation.noCity')}
) : (