فهرست منبع

feat: restore graduation project from PR #679

Original PR: https://github.com/datawhalechina/hello-agents/pull/679
Original commits:
162b633749318cb5c03a7d68b9a042a041384cc6
7d71c5cfaedf407d2a475fe735b755c6753eaaa7
2c7990d57efb8dd488a6a2cd16bacc0c3d5e2423
2b1b436764d5f66b649d40a854037f6d71eac77e
21c58c8ddb75c789ab309edeb0b9104c35fd7754
24d3168cac57f34788e71c4dcded4a51658fb9b7
ee155c9e1617bb711a842c1511b2dfc3b1f7af28
603e41d3ed7120b83d2a542aa12ab33498e7788b
3ca62680a917c70629827ef4e94198e12dd958e4
36da1c7143590d93247938dd1a875175fca6dcf2
0ad81cce0b0c31df8b222d1243cdbd8da04df24f
e89dab1f97f87f012722189786362ce1187a837e
5bf8e40cf6b0b62b35444e3ce19e7d620ee256aa
485da45260518134325409c7de19f478b48471c5
6586cb649a569c43c3d62830991e33a63367839f
96d595cb5ed9574d590d83d37a239c025f5048c0
238a35c94306a8c4a80dcfaff2c02de25def6eaa
51c0b1b65490f466e0e6287d931b8c8fbe5edf41
90905eba1970f3f9b6636d922387bfb73e40cc81
1189ceef266cd9a98ccee1816cda4a227c87c669
4023dd667d284ebd38c93bac0eed276152edf379
ba68cc4db58cdae77c5a4681bc25cf7edaea7504
35181d9436501dd603af2164df7a605a6eeb8e4c
e254640aa95f8766281ff12ea07f815aed7594e3
e4d2a13fc4867b885101cea3a00c0ace24eba6a3

2248652135 3 ماه پیش
والد
کامیت
c4e3beaa2f
58فایلهای تغییر یافته به همراه11877 افزوده شده و 0 حذف شده
  1. 65 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/.gitignore
  2. 114 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/CLAUDE.md
  3. 50 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/PROJECT.md
  4. 251 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/README.md
  5. 37 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/.claude/settings.local.json
  6. 35 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/.env.example
  7. 58 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/.gitignore
  8. 4 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/__init__.py
  9. 2 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/agents/__init__.py
  10. 255 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/agents/mcp_tool.py
  11. 81 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/agents/profile_extraction_agent.py
  12. 718 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/agents/trip_planner_agent.py
  13. 2 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/api/__init__.py
  14. 128 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/api/main.py
  15. 2 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/api/routes/__init__.py
  16. 216 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/api/routes/auth.py
  17. 189 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/api/routes/chat.py
  18. 71 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/api/routes/history.py
  19. 163 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/api/routes/map.py
  20. 129 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/api/routes/poi.py
  21. 86 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/api/routes/trip.py
  22. 133 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/config.py
  23. 391 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/database.py
  24. 106 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/jwt_utils.py
  25. 2 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/models/__init__.py
  26. 322 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/models/schemas.py
  27. 105 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/redis_service.py
  28. 88 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/rsa_service.py
  29. 2 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/services/__init__.py
  30. 715 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/services/amap_service.py
  31. 36 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/services/llm_service.py
  32. 102 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/services/travel_chat_service.py
  33. 86 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/services/unsplash_service.py
  34. 338 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/services/user_profile_service.py
  35. 129 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/user_context.py
  36. BIN
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/data/trip_planner.db
  37. 32 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/requirements.txt
  38. 28 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/run.py
  39. 24 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/scripts/generate_certs.bat
  40. 4 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/user_profiles/user_1.md
  41. 5 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/backend/user_profiles/user_3.md
  42. 7 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/.env.example
  43. 29 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/.gitignore
  44. 14 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/index.html
  45. 2244 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/package-lock.json
  46. 27 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/package.json
  47. 105 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/src/App.vue
  48. 49 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/src/main.ts
  49. 155 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/src/services/api.ts
  50. 88 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/src/services/crypto.ts
  51. 108 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/src/types/index.ts
  52. 993 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/src/views/Chat.vue
  53. 174 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/src/views/History.vue
  54. 737 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/src/views/Home.vue
  55. 142 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/src/views/Login.vue
  56. 1640 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/src/views/Result.vue
  57. 32 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/tsconfig.json
  58. 29 0
      Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/vite.config.ts

+ 65 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/.gitignore

@@ -0,0 +1,65 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Virtual environment
+.venv/
+venv/
+env/
+ENV/
+
+# Node
+node_modules/
+frontend/dist/
+frontend/dist-ssr/
+
+# Environment variables
+.env
+.env.local
+.env.*.local
+
+# Logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Test artifacts
+.pytest_cache/
+.coverage
+htmlcov/

+ 114 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/CLAUDE.md

@@ -0,0 +1,114 @@
+# CLAUDE.md
+
+本文件为 Claude Code(claude.ai/code)在此仓库中工作时提供指导。
+
+## 项目简介
+
+AI 驱动的智能旅行规划助手,后端 FastAPI,前端 Vue 3 + Vite,通过多智能体 LLM 编排和高德地图 MCP 协议集成实现行程规划。
+
+## 常用命令
+
+### 后端
+```bash
+cd backend
+python -m venv .venv && source .venv/bin/activate  # Windows: .venv\Scripts\activate
+pip install -r requirements.txt
+cp .env.example .env  # 填入 API 密钥
+python run.py          # 启动 uvicorn,端口 8000
+```
+
+### 前端
+```bash
+cd frontend
+npm install
+cp .env.example .env
+npm run dev    # Vite 开发服务器,端口 5173
+npm run build  # 生产构建(vue-tsc + vite)
+```
+
+### HTTPS(可选)
+```bash
+# 1. 生成自签名证书
+cd backend && openssl req -x509 -newkey rsa:2048 -keyout certs/key.pem -out certs/cert.pem -days 365 -nodes -subj "//CN=localhost"
+# 2. 在 backend/.env 中设置 SSL_ENABLED=true
+# 3. 重启后端 (python run.py)
+# 4. 前端代理目标和环境变量已指向 https://localhost:8000
+```
+
+## 架构说明
+
+### 后端(`backend/`)
+
+**FastAPI 应用** 在 `app/api/main.py` 中创建,配置了 CORS,注册了 6 组路由(前缀 `/api`):
+- `auth` — JWT Cookie 认证(HS256 access/refresh token),RSA 加密密码传输
+- `trip` — 多智能体系统生成旅行计划
+- `chat` — AI 旅游对话,SSE 流式输出
+- `history` — 历史行程 CRUD
+- `map` — POI 搜索、天气、路线规划
+- `poi` — POI 详情和图片
+
+**关键设计模式:**
+- **模块级单例**:每个服务暴露 `get_*()` 函数(如 `get_trip_planner_agent()`、`get_amap_service()`、`get_llm()`),惰性初始化并缓存全局实例。无依赖注入框架。
+- **MCP 子进程**:`amap-mcp-server` 以子进程(`uvx`)方式运行。`agents/mcp_tool.py` 中的 `MCPTool` 通过 JSON-RPC 协议在 stdin/stdout 上通信,每次批量调用都启动新进程。每次调用序列在同一子进程内发送 `initialize` + `tools/call`(或 `tools/list`)。
+- **多智能体流水线**(`agents/trip_planner_agent.py`):4 个顺序步骤——景点搜索 → 天气 → 酒店 → 规划 Agent,每个都是 `SimpleAgent`,共享一个 `MCPTool`。最后一步通过 HTTP 方式直接调用高德 API 获取真实路线数据(绕过 MCP 以提高性能)。
+- **认证**:双 JWT(access 30 分钟 + refresh 7 天),均通过 HttpOnly Cookie 传递。Refresh Token 存储在 Redis(jti → user_id 映射),刷新时轮换。不使用 `Authorization` 请求头。
+- **密码加密**:前端使用 Web Crypto API 进行 RSA-OAEP 加密,后端使用 `cryptography` 库解密。
+- **数据库**:原生 SQLite(`sqlite3` 模块),WAL 模式,无 ORM。表:users、auth_tokens、trip_history、chat_sessions、chat_messages。
+
+### 前端(`frontend/`)
+
+**Vue 3 + TypeScript + Vite + Ant Design Vue 4**,共 5 个视图:
+- `Home.vue` — 旅行表单(城市、日期、偏好)
+- `Result.vue` — 行程展示(地图、PDF/图片导出)
+- `Login.vue` — 登录/注册(RSA 加密密码)
+- `History.vue` — 历史行程列表
+- `Chat.vue` — AI 旅游对话(SSE 流式聊天)
+
+**API 层**:`services/api.ts` — Axios 客户端,`withCredentials: true`,401 自动刷新 Token(含重试锁)。使用原始 `fetch()` 的视图统一从环境变量 `VITE_API_BASE_URL` 读取后端地址。
+
+### 数据流:旅行计划生成
+
+```
+请求 → POST /api/trip/plan
+  → MultiAgentTripPlanner.plan_trip()
+    → attraction_agent.run()        [MCP: maps_text_search]
+    → weather_agent.run()           [MCP: maps_weather]
+    → hotel_agent.run()             [MCP: maps_text_search]
+    → planner_agent.run()           [仅 LLM,无工具]
+    → _enrich_with_real_routes()    [HTTP 高德 API: 路线规划]
+  → TripPlanResponse
+```
+
+### AI 聊天 SSE 流
+
+```
+POST /api/chat/sessions/{id}/messages
+  → 保存用户消息到数据库
+  → 加载用户画像上下文 → 注入系统提示词
+  → chat_stream() → 产出 SSE 事件 (type: token/error/done)
+  → 保存 AI 回复到数据库
+  → 异步从消息中提取用户画像
+```
+
+前端使用 `ReadableStream.getReader()` 读取流,解析 SSE 的 `data:` 行。
+
+## 配置说明
+
+后端 `.env`:
+- `LLM_MODEL_ID`、`LLM_API_KEY`、`LLM_BASE_URL` — LLM 提供商
+- `AMAP_API_KEY` — 高德地图 API 密钥(必填)
+- `REDIS_HOST`/`PORT`/`PASSWORD`/`DB` — Redis,用于 Refresh Token 持久化
+- `SSL_ENABLED`/`SSL_CERTFILE`/`SSL_KEYFILE` — 可选 HTTPS
+- `CORS_ORIGINS` — 逗号分隔的允许来源
+- `JWT_SECRET` — 首次运行自动生成(如未设置)
+
+前端 `.env`:
+- `VITE_API_BASE_URL` — 后端地址(默认 `https://localhost:8000`)
+- `VITE_AMAP_WEB_KEY`/`VITE_AMAP_WEB_JS_KEY` — 高德 JS API 密钥
+
+## 重要依赖
+
+- **hello-agents==1.0.2** — 自定义框架,从 `D:\learn-agent\hello-agents-1.0.2` 安装(`run.py` 中通过 `sys.path.append` 引入)。提供 `SimpleAgent`、`HelloAgentsLLM` 和 `Tool` 基类。
+- **amap-mcp-server** — 高德地图 MCP 服务器,通过 `uvx` 子进程运行。提供:`maps_text_search`、`maps_weather`、`maps_direction_*`、`maps_geo`、`maps_search_detail`。
+- **ant-design-vue 4** — UI 组件库。
+- **html2canvas + jspdf** — 导出行程为 PDF/图片。

+ 50 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/PROJECT.md

@@ -0,0 +1,50 @@
+# Trip Planner Pro — HelloAgents智能旅行助手
+
+## 项目信息
+
+- **项目名称**: Trip Planner Pro — HelloAgents智能旅行助手
+- **作者**: @shengyuantong
+- **项目类型**: 生活服务
+
+## 项目简介
+
+AI 驱动的智能旅行规划助手,后端 FastAPI + 前端 Vue 3,通过多智能体 LLM 编排和高德地图 MCP 协议集成,实现个性化的旅行计划生成。系统支持多智能体协作自动搜索景点、查询天气、推荐酒店并生成完整行程,同时提供 AI 旅游对话和行程管理功能。
+
+## 核心功能
+
+- [x] **AI 智能生成旅行计划** — 用户输入城市、日期、偏好等基本信息,系统自动调用多智能体流水线(景点搜索 → 天气查询 → 酒店推荐 → 行程规划 Agent)生成详尽的 PDF/图片可导出行程
+- [x] **流式 AI 旅游对话** — 基于 SSE 的流式聊天,具备用户画像自动提取、跨会话上下文记忆,提供智能旅行问答
+- [x] **用户认证系统** — 双 JWT(HttpOnly Cookie)+ Redis Refresh Token 持久化 + RSA 加密密码传输,支持无 Cookie 设备兼容
+- [x] **高德地图深度集成** — 通过 MCP 协议接入高德地图服务,支持 POI 搜索、天气查询、多种交通方式路线规划(公共交通 / 自驾 / 步行 / 混合)
+- [x] **出行人群定制** — 支持 7 种出行人群(独自旅行、情侣夫妻、朋友结伴、家庭亲子、公司团建、老年旅行、研学旅行)个性化行程定制
+- [x] **历史行程管理** — 历史行程 CRUD、PDF / 图片导出
+
+## 技术亮点
+
+- **多智能体流水线架构** — 4 个顺序/并行步骤(景点搜索 → 天气 → 酒店 → 规划),采用 `ThreadPoolExecutor` 并行执行无依赖任务,显著提升生成效率
+- **MCP 协议集成** — 通过 JSON-RPC 子进程方式接入高德地图 MCP 服务,每次批量调用独立启动子进程,实现 Agent 自动工具调用
+- **用户画像系统** — 异步提取用户意图和偏好,跨会话持久化缓存,首次对话时按需加载,作为上下文注入提示词
+- **双 JWT 认证** — Access Token(30 分钟)+ Refresh Token(7 天),均通过 HttpOnly Cookie 传递,Refresh Token 存储在 Redis 中支持轮换和撤销
+- **RSA 加密密码传输** — 前端使用 Web Crypto API 进行 RSA-OAEP 加密,后端使用 `cryptography` 库解密,保障密码安全
+- **SSE 流式输出** — AI 对话采用 Server-Sent Events 实时推送 Token,前端使用 `ReadableStream.getReader()` 解析流数据
+- **HTTPS 支持** — 内建自签名证书生成脚本,支持全站 HTTPS 访问
+- **无 ORM 数据库** — 使用原生 SQLite + WAL 模式,无 ORM 依赖,轻量高效
+
+## 演示效果
+
+(待补充截图或 GIF)
+
+## 自检清单
+
+- [x] 代码能够正常运行
+- [x] README 文档完整
+- [x] requirements.txt 完整
+- [x] 有清晰的使用示例
+- [x] 代码有适当的注释
+
+## 其他说明
+
+- **项目结构**:前后端分离,`backend/` 为 FastAPI 后端,`frontend/` 为 Vue 3 + Vite 前端
+- **框架依赖**:基于自定义 `hello-agents==1.0.2` 框架(提供 `SimpleAgent`、`HelloAgentsLLM` 和 `Tool` 基类)
+- **6 组 API 路由**:`auth`(认证)、`trip`(旅行规划)、`chat`(AI 对话)、`history`(历史行程)、`map`(地图服务)、`poi`(POI 详情)
+- **模块级单例模式**:每个服务暴露 `get_*()` 函数惰性初始化并缓存全局实例,无依赖注入框架

+ 251 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/README.md

@@ -0,0 +1,251 @@
+# Trip Planner Pro — HelloAgents 智能旅行助手 🌍✈️
+
+## 项目信息
+
+- **项目名称**: Trip Planner Pro — HelloAgents 智能旅行助手
+- **项目类型**: 生活服务
+
+## 项目简介
+
+AI 驱动的智能旅行规划助手,基于 HelloAgents 框架构建,后端 FastAPI + 前端 Vue 3 + TypeScript。通过多智能体 LLM 编排和高德地图 MCP 协议集成,实现个性化的旅行计划生成。系统支持多智能体协作自动搜索景点、查询天气、推荐酒店并生成完整行程,同时提供 AI 旅游对话和行程管理功能。
+
+## 核心功能
+
+- [x] **AI 智能生成旅行计划** — 基于多智能体流水线自动生成详细的多日旅程,涵盖景点、住宿、交通、餐饮推荐
+- [x] **流式 AI 旅游对话** — 基于 SSE 的流式聊天,具备用户画像自动提取、跨会话上下文记忆
+- [x] **高德地图深度集成** — 通过 MCP 协议接入高德地图服务,支持 POI 搜索、天气预报、路线规划(公共交通 / 自驾 / 步行 / 混合)
+- [x] **出行人群定制** — 支持 7 种出行人群(独自旅行、情侣夫妻、朋友结伴、家庭亲子、公司团建、老年旅行、研学旅行)个性化行程定制
+- [x] **用户认证系统** — 双 JWT + HttpOnly Cookie + Redis 持久化 + RSA 加密密码传输
+- [x] **历史行程管理** — 历史行程 CRUD,支持 PDF/图片导出
+
+## 🏗️ 技术栈
+
+### 后端
+- **框架**: HelloAgents (SimpleAgent) + FastAPI
+- **数据库**: 原生 SQLite(WAL 模式,无 ORM)
+- **缓存**: Redis(Refresh Token 持久化)
+- **MCP 工具**: amap-mcp-server(高德地图服务)
+- **LLM**: 支持多种 LLM 提供商(OpenAI、DeepSeek 等)
+- **认证**: 双 JWT(HS256)、HttpOnly Cookie、RSA-OAEP 密码加密
+
+### 前端
+- **框架**: Vue 3 + TypeScript
+- **构建工具**: Vite
+- **UI 组件库**: Ant Design Vue 4
+- **地图服务**: 高德地图 JavaScript API
+- **HTTP 客户端**: Axios(withCredentials 自动携带 Cookie)
+
+## 技术亮点
+
+- **多智能体流水线架构** — 4 个顺序/并行步骤(景点搜索 → 天气 → 酒店 → 规划),采用 `ThreadPoolExecutor` 并行执行无依赖任务,显著提升生成效率
+- **MCP 协议集成** — 通过 JSON-RPC 子进程方式接入高德地图 MCP 服务,Agent 自动调用地图工具获取实时数据
+- **用户画像系统** — 异步提取用户意图和偏好,跨会话持久化缓存,按需加载注入上下文
+- **双 JWT 认证** — Access Token(30 分钟)+ Refresh Token(7 天),HttpOnly Cookie 传递 + Redis 持久化,支持轮换和撤销
+- **SSE 流式输出** — AI 对话采用 Server-Sent Events 实时推送 Token,前端使用 `ReadableStream.getReader()` 解析流数据
+- **模块级单例模式** — 每个服务暴露 `get_*()` 函数惰性初始化并缓存全局实例,无依赖注入框架
+
+## 📁 项目结构
+
+```
+helloagents-trip-planner/
+├── backend/                    # 后端服务
+│   ├── app/
+│   │   ├── agents/            # Agent 实现
+│   │   │   ├── trip_planner_agent.py  # 多智能体旅行规划系统
+│   │   │   ├── mcp_tool.py           # MCP 工具封装
+│   │   │   └── profile_extraction_agent.py  # 用户画像提取
+│   │   ├── api/               # FastAPI 路由
+│   │   │   ├── main.py        # 应用入口,CORS、中间件、路由注册
+│   │   │   └── routes/
+│   │   │       ├── auth.py    # 用户认证
+│   │   │       ├── trip.py    # 旅行计划生成
+│   │   │       ├── chat.py    # AI 旅游对话(SSE)
+│   │   │       ├── history.py # 历史行程
+│   │   │       ├── map.py     # 地图服务
+│   │   │       └── poi.py     # POI 详情
+│   │   ├── services/          # 服务层
+│   │   │   ├── amap_service.py       # 高德地图 HTTP 封装
+│   │   │   ├── llm_service.py        # LLM 客户端
+│   │   │   ├── travel_chat_service.py # AI 聊天服务
+│   │   │   ├── user_profile_service.py # 用户画像管理
+│   │   │   └── unsplash_service.py   # 图片服务
+│   │   ├── models/
+│   │   │   └── schemas.py     # Pydantic 数据模型
+│   │   ├── config.py          # 配置管理
+│   │   ├── database.py        # SQLite 数据库
+│   │   ├── jwt_utils.py       # JWT 工具
+│   │   ├── redis_service.py   # Redis 客户端
+│   │   ├── rsa_service.py     # RSA 加解密
+│   │   └── user_context.py    # 用户上下文中间件
+│   ├── requirements.txt
+│   ├── .env.example
+│   └── run.py
+├── frontend/                   # 前端应用
+│   ├── src/
+│   │   ├── views/             # 页面视图
+│   │   │   ├── Home.vue       # 旅行表单首页
+│   │   │   ├── Result.vue     # 行程展示(地图、导出)
+│   │   │   ├── Login.vue      # 登录/注册
+│   │   │   ├── History.vue    # 历史行程列表
+│   │   │   └── Chat.vue       # AI 旅游对话
+│   │   ├── components/        # 公共组件
+│   │   ├── services/          # API 服务(Axios)
+│   │   ├── types/             # TypeScript 类型
+│   │   └── App.vue
+│   ├── package.json
+│   ├── .env.example
+│   └── vite.config.ts
+└── README.md
+```
+
+## 🚀 快速开始
+
+### 前提条件
+
+- Python 3.10+
+- Node.js 16+
+- 高德地图 API 密钥(Web 服务 API 和 Web 端 JS API)
+- LLM API 密钥(OpenAI / DeepSeek 等)
+- Redis 服务(可选,用于 Refresh Token 持久化)
+
+### 后端安装
+
+```bash
+cd backend
+python -m venv .venv
+# Windows: .venv\Scripts\activate | macOS/Linux: source .venv/bin/activate
+pip install -r requirements.txt
+cp .env.example .env  # 填入 API 密钥
+python run.py          # 启动 uvicorn,端口 8000
+```
+
+### 前端安装
+
+```bash
+cd frontend
+npm install
+cp .env.example .env  # 填入高德地图密钥
+npm run dev           # Vite 开发服务器,端口 5173
+npm run build         # 生产构建
+```
+
+### HTTPS(可选)
+
+```bash
+cd backend && openssl req -x509 -newkey rsa:2048 -keyout certs/key.pem -out certs/cert.pem -days 365 -nodes -subj "//CN=localhost"
+# 在 backend/.env 中设置 SSL_ENABLED=true,重启后端
+# 前端代理目标已指向 https://localhost:8000
+```
+
+## 📝 使用指南
+
+1. **注册/登录** — 在登录页面注册账号(密码经过 RSA 加密传输)
+2. **填写旅行信息** — 目的地城市、旅行日期、交通方式、住宿偏好、出行人群、旅行风格标签
+3. **生成旅行计划** — 点击"生成旅行计划",系统将:
+   - 并行搜索景点、查询天气、推荐酒店
+   - 整合信息生成完整行程
+   - 调用高德地图 API 获取真实交通路线数据
+4. **查看结果** — 每日详细行程、景点信息与地图标记、交通路线规划、天气预报、餐饮推荐、预算汇总
+5. **AI 旅游对话** — 针对行程进行智能问答,系统自动提取用户偏好
+
+## 🔧 核心实现
+
+### 多智能体流水线
+
+```python
+# 4 个 Agent 协同工作
+attraction_agent.run()   # [MCP] 搜索景点 POI
+weather_agent.run()      # [MCP] 查询天气
+hotel_agent.run()        # [MCP] 搜索酒店
+planner_agent.run()      # [LLM 仅] 整合生成行程
+_enrich_with_real_routes()  # [HTTP] 获取真实交通路线
+```
+
+其中景点搜索、天气查询、酒店推荐通过 `ThreadPoolExecutor` **并行执行**,行程规划 Agent 在并行结果上顺序执行。
+
+### MCP 工具调用
+
+Agent 自动调用高德地图 MCP 工具获取实时数据:
+
+- `maps_text_search` — 景点 / 酒店 POI 搜索
+- `maps_weather` — 天气查询
+- `maps_direction_*` — 步行 / 驾车 / 公共交通路线规划
+
+### AI 聊天 SSE 流
+
+```
+POST /api/chat/sessions/{id}/messages
+  → 保存用户消息 → 加载用户画像 → 注入系统提示词
+  → SSE 流式返回 Token (type: token/error/done)
+  → 保存 AI 回复 → 异步提取用户画像
+```
+
+## 📄 API 文档
+
+启动后端后访问 `http://localhost:8000/docs` 查看 Swagger 文档。
+
+6 组路由(前缀 `/api`):
+
+| 路由 | 功能 |
+|------|------|
+| `POST /api/auth/*` | 登录/注册/刷新 Token |
+| `POST /api/trip/plan` | 生成旅行计划 |
+| `POST /api/chat/sessions/{id}/messages` | AI 对话(SSE 流式) |
+| `GET /api/history/*` | 历史行程 CRUD |
+| `GET /api/map/*` | POI 搜索、天气、路线规划 |
+| `GET /api/poi/*` | POI 详情和图片 |
+
+## 数据流:旅行计划生成
+
+```
+请求 → POST /api/trip/plan
+  → MultiAgentTripPlanner.plan_trip()
+    ┌─ attraction_agent.run()    [MCP: maps_text_search]  ─┐
+    ├─ weather_agent.run()       [MCP: maps_weather]      ├─ 并行执行
+    └─ hotel_agent.run()         [MCP: maps_text_search]  ┘
+    → planner_agent.run()        [仅 LLM,无工具]
+    → _enrich_with_real_routes() [HTTP 高德 API]
+  → TripPlanResponse
+```
+
+## 配置说明
+
+后端 `.env` 主要配置项:
+
+- `LLM_MODEL_ID`、`LLM_API_KEY`、`LLM_BASE_URL` — LLM 提供商
+- `AMAP_API_KEY` — 高德地图 API 密钥(必填)
+- `REDIS_HOST`/`PORT`/`PASSWORD`/`DB` — Redis 配置
+- `JWT_SECRET` — 首次运行自动生成
+- `SSL_ENABLED`/`SSL_CERTFILE`/`SSL_KEYFILE` — 可选 HTTPS
+
+前端 `.env` 主要配置项:
+
+- `VITE_API_BASE_URL` — 后端地址(默认 `https://localhost:8000`)
+- `VITE_AMAP_WEB_KEY`/`VITE_AMAP_WEB_JS_KEY` — 高德 JS API 密钥
+
+## 自检清单
+
+- [x] 代码能够正常运行
+- [x] README 文档完整
+- [x] requirements.txt 完整
+- [x] 有清晰的使用示例
+- [x] 代码有适当的注释
+
+## 🤝 贡献指南
+
+欢迎提交 Pull Request 或 Issue!
+
+## 📜 开源协议
+
+CC BY-NC-SA 4.0
+
+## 🙏 致谢
+
+- [HelloAgents](https://github.com/datawhalechina/Hello-Agents) — 智能体教程
+- [HelloAgents 框架](https://github.com/jjyaoao/HelloAgents) — 智能体框架
+- [高德地图开放平台](https://lbs.amap.com/) — 地图服务
+- [amap-mcp-server](https://github.com/sugarforever/amap-mcp-server) — 高德地图 MCP 服务器
+
+---
+
+**Trip Planner Pro** — 让旅行计划变得简单而智能 🌈

+ 37 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/.claude/settings.local.json

@@ -0,0 +1,37 @@
+{
+  "permissions": {
+    "allow": [
+      "Bash(D:\\\\learn-agent\\\\hello-agents-1.0.2\\\\code\\\\chapter13\\\\helloagents-trip-planner\\\\backend\\\\.venv\\\\Scripts\\\\python *)",
+      "Bash(D:/learn-agent/hello-agents-1.0.2/code/chapter13/helloagents-trip-planner/backend/.venv/Scripts/python *)",
+      "Bash(D:/learn-agent/hello-agents-1.0.2/code/chapter13/helloagents-trip-planner/backend/.venv/Scripts/pip list *)",
+      "Bash(D:/learn-agent/hello-agents-1.0.2/code/chapter13/helloagents-trip-planner/backend/.venv/Scripts/pip install *)",
+      "Bash(uv --version)",
+      "Bash(pip --version)",
+      "Bash(uv pip *)",
+      "Bash(uv venv *)",
+      "WebSearch",
+      "Bash(\".venv/Scripts/python\" run.py)",
+      "Bash(pip install *)",
+      "Bash(\".venv/Scripts/python\" -c \"import fastmcp; print\\(fastmcp.__version__\\)\")",
+      "Bash(\".venv/Scripts/python\" -c \"from mcp import ClientSession, StdioServerParameters; print\\('MCP SDK available'\\)\")",
+      "Bash(.venv/Scripts/python -c ' *)",
+      "Bash(npx vite *)",
+      "Bash(curl -s http://localhost:8000/health)",
+      "Bash(curl -s http://127.0.0.1:8000/health)",
+      "Bash(curl *)",
+      "Bash(uvx amap-mcp-server *)",
+      "Bash(AMAP_MAPS_API_KEY=\"718376d3556c1afd1c874de4c749c379\" uvx amap-mcp-server)",
+      "Bash(kill %1)",
+      "Bash(wait)",
+      "Bash(timeout 15 .venv/Scripts/python -c ' *)",
+      "Bash(timeout 30 .venv/Scripts/python -c ' *)",
+      "Bash(timeout 25 .venv/Scripts/python -c ' *)",
+      "Bash(timeout 45 .venv/Scripts/python -u -c ' *)",
+      "Bash(timeout 30 .venv/Scripts/python -u -c ' *)",
+      "Bash(python -m json.tool)",
+      "Bash(.venv/Scripts/python -u -c ' *)",
+      "Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(f'成功: {d[\\\\\"success\\\\\"]}, 结果数: {len\\(d[\\\\\"data\\\\\"]\\)}'\\)\")",
+      "Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(f'成功: {d[\\\\\"success\\\\\"]}, 天数: {len\\(d[\\\\\"data\\\\\"]\\)}'\\)\")"
+    ]
+  }
+}

+ 35 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/.env.example

@@ -0,0 +1,35 @@
+
+# LLM配置 (从HelloAgents继承,如需覆盖可在此配置)
+# 模型名称
+LLM_MODEL_ID=your-model-name
+
+# API密钥
+LLM_API_KEY=your-api-key-here
+
+# 服务地址
+LLM_BASE_URL=your-api-base-url
+
+# 超时时间(可选,默认60秒)
+LLM_TIMEOUT=60
+
+# 服务器配置
+HOST=0.0.0.0
+PORT=8000
+
+# CORS配置
+CORS_ORIGINS=http://localhost:5173,http://localhost:3000,https://localhost:5173,https://localhost:3000
+
+# SSL/HTTPS配置 (设为true后,需提供证书路径)
+SSL_ENABLED=false
+SSL_CERTFILE=certs/cert.pem
+SSL_KEYFILE=certs/key.pem
+
+# 日志级别
+LOG_LEVEL=INFO
+
+# Unsplash API Credentials
+UNSPLASH_ACCESS_KEY=""
+UNSPLASH_SECRET_KEY=""
+
+# 高德地图API配置
+AMAP_API_KEY=your_amap_api_key_here

+ 58 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/.gitignore

@@ -0,0 +1,58 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# 虚拟环境
+venv/
+env/
+ENV/
+.venv
+
+# 环境变量
+.env
+.env.local
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# 日志
+*.log
+
+# 测试
+.pytest_cache/
+.coverage
+htmlcov/
+
+# 操作系统
+.DS_Store
+Thumbs.db
+
+# SSL证书(本地自签名,部署时使用真实证书)
+certs/
+
+# RSA密钥(含私钥,禁止提交)
+data/rsa_private_key.pem
+data/rsa_public_key.pem
+

+ 4 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/__init__.py

@@ -0,0 +1,4 @@
+"""HelloAgents智能旅行助手 - 后端应用"""
+
+__version__ = "1.0.0"
+

+ 2 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/agents/__init__.py

@@ -0,0 +1,2 @@
+"""智能体模块"""
+

+ 255 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/agents/mcp_tool.py

@@ -0,0 +1,255 @@
+"""MCPTool 本地实现 - 基于 subprocess 直接通信"""
+
+import json
+import os
+import subprocess
+from typing import Dict, Any, List, Optional
+
+from hello_agents.tools.base import Tool, ToolParameter
+
+
+class MCPTool(Tool):
+    """MCP (Model Context Protocol) 工具 - subprocess 实现"""
+
+    def __init__(self,
+                 name: str = "mcp",
+                 description: Optional[str] = None,
+                 server_command: Optional[List[str]] = None,
+                 env: Optional[Dict[str, str]] = None,
+                 auto_expand: bool = True):
+        self.server_command = server_command
+        self.server_env = env
+        self.auto_expand = auto_expand
+        self.prefix = f"{name}_" if auto_expand else ""
+        self._available_tools = []
+        self._request_id = 0
+
+        if description is None:
+            description = f"MCP工具服务器: {name}"
+
+        super().__init__(name=name, description=description, expandable=auto_expand)
+
+        if server_command:
+            self._discover_tools()
+
+    def _make_env(self) -> dict:
+        env = os.environ.copy()
+        if self.server_env:
+            env.update(self.server_env)
+        return env
+
+    def _batch_requests(self, requests: List[dict]) -> List[dict]:
+        """在同一个子进程中逐个发送 JSON-RPC 请求"""
+        from queue import Queue, Empty
+        import threading
+        import time
+
+        proc = subprocess.Popen(
+            self.server_command,
+            stdin=subprocess.PIPE,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,  # 捕获stderr,防止管道阻塞
+            env=self._make_env()
+        )
+
+        # 读取stderr避免阻塞
+        def stderr_reader():
+            for _ in iter(proc.stderr.readline, b""):
+                pass
+        st = threading.Thread(target=stderr_reader, daemon=True)
+        st.start()
+
+        out_queue = Queue()
+        def reader():
+            for line in iter(proc.stdout.readline, b""):
+                out_queue.put(line)
+            out_queue.put(None)
+        t = threading.Thread(target=reader, daemon=True)
+        t.start()
+
+        results = []
+        try:
+            for req in requests:
+                self._request_id += 1
+                req["id"] = self._request_id
+
+                proc.stdin.write((json.dumps(req) + "\n").encode())
+                proc.stdin.flush()
+
+                # 累积多行直到可解析(amap-mcp-server 长响应可能跨多行)
+                import ast
+                lines_buf = []
+                response = None
+                for _ in range(15):  # 最多拼15行
+                    try:
+                        line = out_queue.get(timeout=20)
+                    except Empty:
+                        raise RuntimeError("MCP响应超时(20s)")
+                    if line is None:
+                        raise RuntimeError("MCP连接提前关闭")
+                    raw = line.decode(errors="replace")
+                    lines_buf.append(raw)
+                    full_text = "".join(lines_buf).strip()
+                    if not full_text:
+                        continue
+                    # 尝试解析: 先 json, 再 ast.literal_eval
+                    try:
+                        response = json.loads(full_text)
+                        break  # 解析成功
+                    except json.JSONDecodeError:
+                        try:
+                            parsed = ast.literal_eval(full_text)
+                            response = json.loads(json.dumps(parsed))
+                            break  # 解析成功
+                        except (SyntaxError, ValueError):
+                            # 可能是截断了,继续读下一行
+                            continue
+                if response is None:
+                    print(f"  [MCP] 无法解析响应(共{len(lines_buf)}行): {repr(full_text[:200])}")
+                    raise RuntimeError("无法解析MCP响应")
+                if "error" in response:
+                    raise RuntimeError(f"MCP错误: {response['error']}")
+                results.append(response.get("result", {}))
+
+                # MCP 协议: initialize 后需发送 initialized 通知
+                if req.get("method") == "initialize":
+                    notif = {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}
+                    proc.stdin.write((json.dumps(notif) + "\n").encode())
+                    proc.stdin.flush()
+                    time.sleep(0.2)  # 给服务器短暂时间处理通知
+        finally:
+            try:
+                proc.stdin.close()
+            except Exception:
+                pass
+            proc.wait(timeout=10)
+
+        return results
+
+    def _discover_tools(self):
+        """发现 MCP 服务器的工具"""
+        try:
+            results = self._batch_requests([
+                {
+                    "jsonrpc": "2.0",
+                    "method": "initialize",
+                    "params": {
+                        "protocolVersion": "2024-11-05",
+                        "capabilities": {},
+                        "clientInfo": {"name": "helloagents-trip-planner", "version": "1.0"}
+                    }
+                },
+                {
+                    "jsonrpc": "2.0",
+                    "method": "tools/list",
+                    "params": {}
+                }
+            ])
+
+            if len(results) >= 2:
+                tool_list = results[1]
+                self._available_tools = [
+                    {
+                        "name": tool["name"],
+                        "description": tool.get("description", ""),
+                        "input_schema": tool.get("inputSchema", {})
+                    }
+                    for tool in tool_list.get("tools", [])
+                ]
+        except Exception as e:
+            print(f"  ⚠️ MCP工具发现失败: {e}")
+
+    def get_expanded_tools(self) -> List[Tool]:
+        if not self.auto_expand or not self._available_tools:
+            return []
+        return [MCPWrappedTool(self, info, self.prefix) for info in self._available_tools]
+
+    def run(self, parameters: Dict[str, Any]) -> str:
+        action = parameters.get("action", "").lower()
+        if not action and "tool_name" in parameters:
+            action = "call_tool"
+
+        try:
+            if action == "call_tool":
+                tool_name = parameters.get("tool_name")
+                arguments = parameters.get("arguments", {})
+                results = self._batch_requests([
+                    {
+                        "jsonrpc": "2.0",
+                        "method": "initialize",
+                        "params": {
+                            "protocolVersion": "2024-11-05",
+                            "capabilities": {},
+                            "clientInfo": {"name": "helloagents-trip-planner", "version": "1.0"}
+                        }
+                    },
+                    {
+                        "jsonrpc": "2.0",
+                        "method": "tools/call",
+                        "params": {"name": tool_name, "arguments": arguments}
+                    }
+                ])
+                if len(results) < 2:
+                    return "MCP调用无返回"
+                content = results[1].get("content", [])
+                text_parts = []
+                for c in content:
+                    if c.get("type") == "text":
+                        text_parts.append(c["text"])
+                    else:
+                        text_parts.append(str(c))
+                return "\n".join(text_parts) if text_parts else str(results[1])
+            elif action == "list_tools":
+                return f"找到 {len(self._available_tools)} 个工具:\n" + "\n".join(
+                    f"- {t['name']}: {t['description']}" for t in self._available_tools
+                )
+            else:
+                return f"不支持的操作: {action}"
+        except Exception as e:
+            return f"MCP 操作失败: {str(e)}"
+
+    def get_parameters(self) -> List[ToolParameter]:
+        return [
+            ToolParameter(name="action", type="string",
+                          description="操作类型: list_tools, call_tool", required=True),
+            ToolParameter(name="tool_name", type="string",
+                          description="工具名称", required=False),
+            ToolParameter(name="arguments", type="object",
+                          description="工具参数", required=False),
+        ]
+
+
+class MCPWrappedTool(Tool):
+    """MCP 工具包装器 - 单个 MCP 工具"""
+
+    def __init__(self, mcp_tool: MCPTool, tool_info: Dict[str, Any], prefix: str = ""):
+        self.mcp_tool = mcp_tool
+        self.tool_info = tool_info
+        self.mcp_tool_name = tool_info.get("name", "unknown")
+        tool_name = f"{prefix}{self.mcp_tool_name}" if prefix else self.mcp_tool_name
+        description = tool_info.get("description", f"MCP工具: {self.mcp_tool_name}")
+        self._parameters = self._parse_input_schema(tool_info.get("input_schema", {}))
+        super().__init__(name=tool_name, description=description)
+
+    def _parse_input_schema(self, input_schema: Dict[str, Any]) -> List[ToolParameter]:
+        params = []
+        properties = input_schema.get("properties", {})
+        required_fields = input_schema.get("required", [])
+        for name, info in properties.items():
+            params.append(ToolParameter(
+                name=name,
+                type=info.get("type", "string"),
+                description=info.get("description", ""),
+                required=name in required_fields
+            ))
+        return params
+
+    def get_parameters(self) -> List[ToolParameter]:
+        return self._parameters
+
+    def run(self, params: Dict[str, Any]) -> str:
+        return self.mcp_tool.run({
+            "action": "call_tool",
+            "tool_name": self.mcp_tool_name,
+            "arguments": params
+        })

+ 81 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/agents/profile_extraction_agent.py

@@ -0,0 +1,81 @@
+"""用户画像提取子Agent
+
+将用户画像提取逻辑封装为独立的 SimpleAgent 子类,使其成为多智能体系统中的
+一个专门子代理,而不是在服务层直接调用 LLM。
+"""
+
+from hello_agents import SimpleAgent, HelloAgentsLLM
+
+# ============ Agent 系统提示词 ============
+
+SYSTEM_PROMPT = """你是一个用户偏好分析专家。你的任务是根据用户的对话消息,提取该用户的旅行偏好。
+
+## 核心规则
+1. 只从「用户消息」中提取用户**自己表达**的偏好,不要提取AI助手的建议或推荐
+2. 如果用户消息需要结合对话历史才能理解(如"好的"、"这个不错"、"是的"),参考上下文来推断用户偏好
+3. 不要提取一次性信息(如"明天去故宫"),只提取稳定的偏好特征(如"喜欢历史文化景点")
+4. 每条控制在20字以内,总条目不超过8条
+5. 宁缺毋滥,只输出有明显依据的偏好
+
+## 冲突处理(重要)
+将新提取的偏好与「已有画像」逐条对比:
+- **冲突**:如果新消息表达的偏好与某条旧画像矛盾(如"喜欢安静" vs "喜欢热闹"),删除旧条目,用新条目替代
+- **一致**:如果新消息与旧画像一致,保留旧画像条目(不重复添加)
+- **新增**:如果新消息表达了旧画像中没有的偏好,作为新条目添加
+- **无关**:如果用户消息不包含偏好信息,跳过本轮提取
+
+## 输出格式
+只输出更新后的完整画像,每行一条,以"- "开头,不要输出任何其他内容:
+
+- 偏好1
+- 偏好2
+"""
+
+
+class ProfileExtractionAgent(SimpleAgent):
+    """用户画像提取子Agent
+
+    专门从用户对话消息中提取旅行偏好,与已有画像合并更新。
+    不需要工具调用,纯 LLM 文本分析任务。
+    """
+
+    def __init__(self, llm: HelloAgentsLLM):
+        """
+        初始化画像提取 Agent
+
+        Args:
+            llm: LLM 实例
+        """
+        super().__init__(
+            name="用户画像提取专家",
+            llm=llm,
+            system_prompt=SYSTEM_PROMPT,
+            enable_tool_calling=False,  # 纯文本分析,无需工具
+        )
+
+    def extract(
+        self,
+        existing_profile: str,
+        conversation_context: str,
+        user_message: str,
+    ) -> str:
+        """
+        从用户消息中提取偏好,与已有画像对比合并
+
+        Args:
+            existing_profile: 已有画像文本("- "开头的条目),无则传空字符串
+            conversation_context: 对话上下文文本(含历史会话摘要+当前会话最近消息)
+            user_message: 最新用户消息
+
+        Returns:
+            更新后的完整画像文本("- "开头的行),若无可提取内容则返回空字符串
+        """
+        input_text = (
+            f"已有画像:\n{existing_profile or '(无)'}\n\n"
+            f"对话历史(用于理解上下文):\n{conversation_context or '(无)'}\n\n"
+            f"最新用户消息:{user_message}\n\n"
+            f"请输出更新后的完整画像:"
+        )
+
+        result = self.run(input_text)
+        return result.strip()

+ 718 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/agents/trip_planner_agent.py

@@ -0,0 +1,718 @@
+"""多智能体旅行规划系统"""
+
+import json
+from concurrent.futures import ThreadPoolExecutor
+from typing import Dict, Any, List
+from hello_agents import SimpleAgent
+from .mcp_tool import MCPTool
+from ..services.llm_service import get_llm
+from ..services.amap_service import get_amap_service
+from ..models.schemas import TripRequest, TripPlan, DayPlan, Attraction, Meal, WeatherInfo, Location, Hotel, TransportSegment
+from ..config import get_settings
+
+# ============ Agent提示词 ============
+
+ATTRACTION_AGENT_PROMPT = """你是景点搜索专家。你的任务是根据城市和用户偏好搜索合适的景点。
+
+**重要提示:**
+你必须使用工具来搜索景点!不要自己编造景点信息!
+
+**工具调用格式:**
+使用maps_text_search工具时,必须严格按照以下格式:
+`[TOOL_CALL:amap_maps_text_search:keywords=景点关键词,city=城市名]`
+
+**示例:**
+用户: "搜索北京的历史文化景点"
+你的回复: [TOOL_CALL:amap_maps_text_search:keywords=历史文化,city=北京]
+
+用户: "搜索上海的公园"
+你的回复: [TOOL_CALL:amap_maps_text_search:keywords=公园,city=上海]
+
+**注意:**
+1. 必须使用工具,不要直接回答
+2. 格式必须完全正确,包括方括号和冒号
+3. 参数用逗号分隔
+"""
+
+WEATHER_AGENT_PROMPT = """你是天气查询专家。你的任务是查询指定城市的天气信息。
+
+**重要提示:**
+你必须使用工具来查询天气!不要自己编造天气信息!
+
+**工具调用格式:**
+使用maps_weather工具时,必须严格按照以下格式:
+`[TOOL_CALL:amap_maps_weather:city=城市名]`
+
+**示例:**
+用户: "查询北京天气"
+你的回复: [TOOL_CALL:amap_maps_weather:city=北京]
+
+用户: "上海的天气怎么样"
+你的回复: [TOOL_CALL:amap_maps_weather:city=上海]
+
+**注意:**
+1. 必须使用工具,不要直接回答
+2. 格式必须完全正确,包括方括号和冒号
+"""
+
+HOTEL_AGENT_PROMPT = """你是酒店推荐专家。你的任务是根据城市和景点位置推荐合适的酒店。
+
+**重要提示:**
+你必须使用工具来搜索酒店!不要自己编造酒店信息!
+
+**工具调用格式:**
+使用maps_text_search工具搜索酒店时,必须严格按照以下格式:
+`[TOOL_CALL:amap_maps_text_search:keywords=酒店,city=城市名]`
+
+**示例:**
+用户: "搜索北京的酒店"
+你的回复: [TOOL_CALL:amap_maps_text_search:keywords=酒店,city=北京]
+
+**注意:**
+1. 必须使用工具,不要直接回答
+2. 格式必须完全正确,包括方括号和冒号
+3. 关键词使用"酒店"或"宾馆"
+"""
+
+PLANNER_AGENT_PROMPT = """你是行程规划专家。你的任务是根据景点信息、天气信息和出行人群,生成个性化的旅行计划。
+
+请严格按照以下JSON格式返回旅行计划(**transportation_details字段由系统自动填充,你无需生成,但必须保证attractions和hotel的address字段真实准确**):
+```json
+{
+  "city": "城市名称",
+  "start_date": "YYYY-MM-DD",
+  "end_date": "YYYY-MM-DD",
+  "days": [
+    {
+      "date": "YYYY-MM-DD",
+      "day_index": 0,
+      "description": "第1天行程概述",
+      "transportation": "交通方式概览",
+      "accommodation": "住宿类型",
+      "hotel": {
+        "name": "酒店名称",
+        "address": "酒店地址",
+        "location": {"longitude": 116.397128, "latitude": 39.916527},
+        "price_range": "300-500元",
+        "rating": "4.5",
+        "distance": "距离景点2公里",
+        "type": "经济型酒店",
+        "estimated_cost": 400
+      },
+      "attractions": [
+        {
+          "name": "景点名称",
+          "address": "详细地址",
+          "location": {"longitude": 116.397128, "latitude": 39.916527},
+          "visit_duration": 120,
+          "description": "景点详细描述",
+          "category": "景点类别",
+          "ticket_price": 60
+        }
+      ],
+      "meals": [
+        {"type": "breakfast", "name": "早餐推荐", "description": "早餐描述", "estimated_cost": 30},
+        {"type": "lunch", "name": "午餐推荐", "description": "午餐描述", "estimated_cost": 50},
+        {"type": "dinner", "name": "晚餐推荐", "description": "晚餐描述", "estimated_cost": 80}
+      ]
+    }
+  ],
+  "weather_info": [
+    {
+      "date": "YYYY-MM-DD",
+      "day_weather": "晴",
+      "night_weather": "多云",
+      "day_temp": 25,
+      "night_temp": 15,
+      "wind_direction": "南风",
+      "wind_power": "1-3级"
+    }
+  ],
+  "overall_suggestions": "总体建议",
+  "budget": {
+    "total_attractions": 180,
+    "total_hotels": 1200,
+    "total_meals": 480,
+    "total_transportation": 200,
+    "total": 2060
+  }
+}
+```
+
+**出行人群定制指南:**
+根据不同的出行人群,调整行程安排风格:
+
+- **独自旅行**: 推荐经济型住宿(青旅/青舍),安排社交友好型活动,景点紧凑高效,推荐当地特色小吃,控制预算
+- **情侣夫妻**: 安排浪漫景点(日落观景台、情侣步道),推荐氛围好的餐厅,选择舒适型以上酒店,安排双人体验活动
+- **朋友结伴**: 安排集体互动性强的活动,推荐娱乐项目,住宿可选多人间或民宿,餐饮推荐适合聚会的场所
+- **家庭亲子**: 安排儿童友好的景点(科技馆、动物园、主题乐园),节奏要宽松,餐饮选择适合孩子的餐厅,住宿推荐家庭房
+- **公司团建**: 安排团队协作活动,推荐大型场地,兼顾会议讨论空间与休闲娱乐,住宿可选度假型酒店
+- **老年旅行**: 行程节奏舒缓,景点平坦少爬坡,步行距离短,推荐养生餐饮,住宿选择舒适型电梯房
+- **研学旅行**: 安排博物馆、科技馆、历史文化遗址等教育性景点,每个景点预留充足学习时间,可安排讲解服务
+
+**重要提示:**
+1. weather_info数组必须包含每一天的天气信息
+2. 温度必须是纯数字(不要带°C等单位)
+3. 每天安排2-3个景点
+4. 考虑景点之间的距离和游览时间
+5. 每天必须包含早中晚三餐
+6. 提供实用的旅行建议
+7. 行程安排必须符合用户选择的"出行人群"类型
+8. **必须包含预算信息**:
+   - 景点门票价格(ticket_price)
+   - 餐饮预估费用(estimated_cost)
+   - 酒店预估费用(estimated_cost)
+   - 预算汇总(budget)包含各项总费用
+"""
+
+
+class MultiAgentTripPlanner:
+    """多智能体旅行规划系统"""
+
+    def __init__(self):
+        """初始化多智能体系统"""
+        print("🔄 开始初始化多智能体旅行规划系统...")
+
+        try:
+            settings = get_settings()
+            self.llm = get_llm()
+
+            # 创建三个独立的MCP工具实例,每个Agent独享一个
+            # 这是并行化的前提:多个子进程同时调用高德MCP不会相互干扰
+            print("  - 创建MCP工具实例(景点搜索)...")
+            self.amap_tool_attraction = MCPTool(
+                name="amap",
+                description="高德地图服务(景点搜索)",
+                server_command=["uvx", "amap-mcp-server"],
+                env={"AMAP_MAPS_API_KEY": settings.amap_api_key},
+                auto_expand=True
+            )
+            print("  - 创建MCP工具实例(天气查询)...")
+            self.amap_tool_weather = MCPTool(
+                name="amap",
+                description="高德地图服务(天气查询)",
+                server_command=["uvx", "amap-mcp-server"],
+                env={"AMAP_MAPS_API_KEY": settings.amap_api_key},
+                auto_expand=True
+            )
+            print("  - 创建MCP工具实例(酒店推荐)...")
+            self.amap_tool_hotel = MCPTool(
+                name="amap",
+                description="高德地图服务(酒店推荐)",
+                server_command=["uvx", "amap-mcp-server"],
+                env={"AMAP_MAPS_API_KEY": settings.amap_api_key},
+                auto_expand=True
+            )
+
+            # 创建景点搜索Agent
+            print("  - 创建景点搜索Agent...")
+            self.attraction_agent = SimpleAgent(
+                name="景点搜索专家",
+                llm=self.llm,
+                system_prompt=ATTRACTION_AGENT_PROMPT
+            )
+            self.attraction_agent.add_tool(self.amap_tool_attraction)
+
+            # 创建天气查询Agent
+            print("  - 创建天气查询Agent...")
+            self.weather_agent = SimpleAgent(
+                name="天气查询专家",
+                llm=self.llm,
+                system_prompt=WEATHER_AGENT_PROMPT
+            )
+            self.weather_agent.add_tool(self.amap_tool_weather)
+
+            # 创建酒店推荐Agent
+            print("  - 创建酒店推荐Agent...")
+            self.hotel_agent = SimpleAgent(
+                name="酒店推荐专家",
+                llm=self.llm,
+                system_prompt=HOTEL_AGENT_PROMPT
+            )
+            self.hotel_agent.add_tool(self.amap_tool_hotel)
+
+            # 创建行程规划Agent(不需要工具)
+            print("  - 创建行程规划Agent...")
+            self.planner_agent = SimpleAgent(
+                name="行程规划专家",
+                llm=self.llm,
+                system_prompt=PLANNER_AGENT_PROMPT
+            )
+
+            print(f"✅ 多智能体系统初始化成功")
+            print(f"   景点搜索Agent: {len(self.attraction_agent.list_tools())} 个工具(独立实例)")
+            print(f"   天气查询Agent: {len(self.weather_agent.list_tools())} 个工具(独立实例)")
+            print(f"   酒店推荐Agent: {len(self.hotel_agent.list_tools())} 个工具(独立实例)")
+
+        except Exception as e:
+            print(f"❌ 多智能体系统初始化失败: {str(e)}")
+            import traceback
+            traceback.print_exc()
+            raise
+    
+    def plan_trip(self, request: TripRequest) -> TripPlan:
+        """
+        使用多智能体协作生成旅行计划
+
+        Args:
+            request: 旅行请求
+
+        Returns:
+            旅行计划
+        """
+        try:
+            print(f"\n{'='*60}")
+            print(f"🚀 开始多智能体协作规划旅行...")
+            print(f"目的地: {request.city}")
+            print(f"日期: {request.start_date} 至 {request.end_date}")
+            print(f"天数: {request.travel_days}天")
+            print(f"偏好: {', '.join(request.preferences) if request.preferences else '无'}")
+            print(f"出行人群: {request.traveler_group if request.traveler_group else '未指定'}")
+            print(f"{'='*60}\n")
+
+            # ── 阶段一:并行执行无依赖的搜索/查询任务 ──
+            print("🚀 阶段一:并行搜索景点、天气、酒店...")
+            with ThreadPoolExecutor(max_workers=3) as executor:
+                future_attractions = executor.submit(
+                    self.attraction_agent.run,
+                    self._build_attraction_query(request)
+                )
+                future_weather = executor.submit(
+                    self.weather_agent.run,
+                    f"请查询{request.city}的天气信息"
+                )
+                future_hotel = executor.submit(
+                    self.hotel_agent.run,
+                    f"请搜索{request.city}的{request.accommodation}酒店"
+                )
+
+                # 等待全部完成(屏障),按原始顺序获取结果
+                print("  ⏳ 等待三个并行任务完成...")
+                attraction_response = future_attractions.result()
+                print(f"📍 景点搜索完成: {attraction_response[:200]}...\n")
+
+                weather_response = future_weather.result()
+                print(f"🌤️  天气查询完成: {weather_response[:200]}...\n")
+
+                hotel_response = future_hotel.result()
+                print(f"🏨 酒店搜索完成: {hotel_response[:200]}...\n")
+
+            # ── 阶段二:依赖阶段一的结果,顺序执行 ──
+            print("📋 阶段二:生成行程计划...")
+            planner_query = self._build_planner_query(request, attraction_response, weather_response, hotel_response)
+            planner_response = self.planner_agent.run(planner_query)
+            print(f"行程规划结果: {planner_response[:300]}...\n")
+
+            # 解析最终计划
+            trip_plan = self._parse_response(planner_response, request)
+
+            # 步骤5: 调用高德地图MCP获取真实交通路线数据
+            print("🚗 步骤5: 获取真实交通路线数据...")
+            trip_plan = self._enrich_with_real_routes(trip_plan, request)
+            print(f"交通路线获取完成\n")
+
+            print(f"{'='*60}")
+            print(f"✅ 旅行计划生成完成!")
+            print(f"{'='*60}\n")
+
+            return trip_plan
+
+        except Exception as e:
+            print(f"❌ 生成旅行计划失败: {str(e)}")
+            import traceback
+            traceback.print_exc()
+            return self._create_fallback_plan(request)
+    
+    def _build_attraction_query(self, request: TripRequest) -> str:
+        """构建景点搜索查询 - 直接包含工具调用"""
+        keywords = []
+        if request.preferences:
+            # 如果用户有明确的偏好,使用偏好标签作为关键词
+            keywords = request.preferences
+        else:
+            keywords = "景点"
+
+        # 根据出行人群调整搜索关键词
+        group_keywords = {
+            "独自旅行": "景点",
+            "情侣夫妻": "浪漫景点",
+            "朋友结伴": "热门景点",
+            "家庭亲子": "亲子景点",
+            "公司团建": "景点",
+            "老年旅行": "公园",
+            "研学旅行": "博物馆"
+        }
+        if request.traveler_group and request.traveler_group in group_keywords:
+            # 如果用户没有明确偏好,使用人群推荐的关键词
+            if not request.preferences:
+                keywords = group_keywords[request.traveler_group]
+
+        # 直接返回工具调用格式
+        query = f"请使用amap_maps_text_search工具搜索{request.city}与{keywords}相关的景点。\n[TOOL_CALL:amap_maps_text_search:keywords={keywords},city={request.city}]"
+        return query
+
+    def _build_planner_query(self, request: TripRequest, attractions: str, weather: str, hotels: str = "") -> str:
+        """构建行程规划查询"""
+        # 出行人群定制指导
+        group_guidance = {
+            "独自旅行": "该用户是独自旅行:\n- 推荐经济型住宿(青旅/青舍),安排社交友好型活动\n- 景点紧凑高效,推荐当地特色小吃\n- 控制预算,推荐性价比高的选择",
+            "情侣夫妻": "该用户是情侣/夫妻出行:\n- 安排浪漫景点(日落观景台、情侣步道等)\n- 推荐氛围好的餐厅,选择舒适型以上酒店\n- 安排双人体验活动,注重私密性和舒适度",
+            "朋友结伴": "该用户是朋友结伴出行:\n- 安排集体互动性强的活动,推荐娱乐项目\n- 住宿可选多人间或民宿\n- 餐饮推荐适合聚会的场所,推荐热闹区域",
+            "家庭亲子": "该用户是家庭亲子出行(有儿童):\n- 安排儿童友好的景点(科技馆、动物园、主题乐园)\n- 行程节奏要宽松,避免安排过满\n- 餐饮选择适合孩子的餐厅,住宿推荐家庭房",
+            "公司团建": "该用户是公司团建:\n- 安排团队协作活动,推荐大型场地\n- 兼顾会议讨论空间与休闲娱乐\n- 住宿可选度假型酒店,推荐集体用餐",
+            "老年旅行": "该用户是老年旅行:\n- 行程节奏舒缓,景点平坦少爬坡\n- 步行距离短,每个景点预留充足休息时间\n- 推荐养生餐饮,住宿选择舒适型电梯房",
+            "研学旅行": "该用户是研学旅行:\n- 安排博物馆、科技馆、历史文化遗址等教育性景点\n- 每个景点预留充足学习时间\n- 可安排讲解服务,注重知识性"
+        }
+
+        traveler_note = ""
+        if request.traveler_group and request.traveler_group in group_guidance:
+            traveler_note = f"\n**出行人群:** {request.traveler_group}\n{group_guidance[request.traveler_group]}\n"
+
+        query = f"""请根据以下信息生成{request.city}的{request.travel_days}天旅行计划:
+
+**基本信息:**
+- 城市: {request.city}
+- 日期: {request.start_date} 至 {request.end_date}
+- 天数: {request.travel_days}天
+- 交通方式: {request.transportation}
+- 住宿: {request.accommodation}
+- 偏好: {', '.join(request.preferences) if request.preferences else '无'}
+{traveler_note}
+**景点信息:**
+{attractions}
+
+**天气信息:**
+{weather}
+
+**酒店信息:**
+{hotels}
+
+**要求:**
+1. 每天安排2-3个景点
+2. 每天必须包含早中晚三餐
+3. 每天推荐一个具体的酒店(从酒店信息中选择)
+3. 考虑景点之间的距离和交通方式(仅填写transportation概览字段即可)
+4. 返回完整的JSON格式数据
+5. 景点的经纬度坐标和地址(address)要真实准确
+6. 行程安排必须充分考虑"出行人群"的特点
+"""
+        if request.free_text_input:
+            query += f"\n**额外要求:** {request.free_text_input}"
+
+        return query
+
+    def _enrich_with_real_routes(self, plan: TripPlan, request: TripRequest) -> TripPlan:
+        """调用高德地图MCP获取真实交通数据,填充transportation_details"""
+        try:
+            amap = get_amap_service()
+            city = request.city
+
+            # 用户交通方式 → MCP route_type 映射
+            route_type_map = {
+                "公共交通": "transit",
+                "自驾": "driving",
+                "步行": "walking",
+                "混合": "transit",  # 默认用公共交通
+            }
+            route_type = route_type_map.get(request.transportation, "transit")
+            type_label = {
+                "transit": "公共交通",
+                "driving": "自驾",
+                "walking": "步行",
+            }
+
+            for day in plan.days:
+                details = []
+                waypoints = []  # (name, address)
+
+                # 起点: 酒店(如果有地址)
+                if day.hotel and day.hotel.address:
+                    waypoints.append((day.hotel.name, day.hotel.address))
+                elif day.hotel and day.hotel.location:
+                    waypoints.append((day.hotel.name, f"{city}市"))
+                else:
+                    waypoints.append(("酒店", f"{city}市区"))
+
+                # 中间点: 景点
+                for attr in day.attractions:
+                    addr = attr.address or f"{city}市"
+                    waypoints.append((attr.name, addr))
+
+                # 终点: 回酒店(如果酒店在起点后有地址)
+                if day.hotel and day.hotel.address and len(waypoints) > 1:
+                    waypoints.append((day.hotel.name, day.hotel.address))
+
+                # 逐个分段调MCP(带降级重试)
+                total_duration = 0
+                total_distance = 0
+                had_fallback = False
+                for i in range(len(waypoints) - 1):
+                    from_name, from_addr = waypoints[i]
+                    to_name, to_addr = waypoints[i + 1]
+
+                    # 按优先级尝试路线类型
+                    route_types_to_try = [route_type]
+                    if route_type == "transit":
+                        route_types_to_try = ["transit", "driving"]
+                    elif route_type == "driving":
+                        route_types_to_try = ["driving", "transit"]
+
+                    segments = []
+                    attempted_types = []
+                    success_type = None
+                    for try_route_type in route_types_to_try:
+                        attempted_types.append(try_route_type)
+                        segments = amap.get_route_via_http(
+                            origin_address=from_addr,
+                            destination_address=to_addr,
+                            origin_name=from_name,
+                            destination_name=to_name,
+                            origin_city=city,
+                            destination_city=city,
+                            route_type=try_route_type
+                        )
+                        if segments:
+                            success_type = try_route_type
+                            break
+
+                    is_fallback = success_type and success_type != route_type
+
+                    if segments:
+                        for seg in segments:
+                            seg_from = from_name if not details else seg.get("from_name", from_name)
+                            seg_to = to_name if i == len(waypoints) - 2 else seg.get("to_name", to_name)
+                            seg["from_name"] = seg_from
+                            seg["to_name"] = seg_to
+
+                            # 处理回退: 用户选公交但用了驾车数据
+                            if is_fallback:
+                                had_fallback = True
+                                seg["type"] = type_label.get(route_type, "公共交通")
+                                seg["route_detail"] = (seg.get("route_detail", "") + " · 驾车参考").strip(" ·")
+                                if "驾车" not in seg.get("instruction", ""):
+                                    seg["instruction"] += "(驾车参考路线)"
+
+                            details.append(seg)
+                            total_duration += seg.get("duration", 0)
+                            total_distance += seg.get("distance", 0)
+                    else:
+                        # 所有API都失败,尝试LLM估计路线
+                        llm_segments = self._estimate_routes_with_llm(
+                            from_name=from_name,
+                            to_name=to_name,
+                            city=city,
+                            route_type_label=type_label.get(route_type, "公共交通")
+                        )
+                        if llm_segments:
+                            for seg in llm_segments:
+                                seg["from_name"] = from_name
+                                seg["to_name"] = to_name
+                                details.append(seg)
+                                total_duration += seg.get("duration", 0)
+                                total_distance += seg.get("distance", 0)
+                        else:
+                            # LLM也失败,生成占位段
+                            road_type_cn = type_label.get(route_type, "公共交通")
+                            details.append({
+                                "type": road_type_cn,
+                                "instruction": f"从{from_name}前往{to_name}",
+                                "from_name": from_name,
+                                "to_name": to_name,
+                                "departure_time": "08:00",
+                                "duration": 30,
+                                "distance": 2000,
+                                "route_detail": "路线规划暂不可用",
+                            })
+                            total_duration += 30
+                        total_distance += 2000
+
+                # 回填详细交通数据到DayPlan(dict -> TransportSegment)
+                day.transportation_details = [
+                    TransportSegment(**seg) for seg in details
+                ]
+
+                # 更新概要transportation字段
+                if total_duration > 0:
+                    road_type_cn = type_label.get(route_type, "公共交通")
+                    dist_km = round(total_distance / 1000, 1)
+                    fallback_note = "(部分路段为驾车参考)" if had_fallback else ""
+                    day.transportation = f"{road_type_cn} · 共{dist_km}公里 · 约{total_duration}分钟{fallback_note}"
+
+                print(f"  ✅ 第{day.day_index + 1}天交通: {len(details)}段, {day.transportation}")
+
+        except Exception as e:
+            print(f"⚠️ 获取真实路线数据失败: {e}")
+
+        return plan
+
+    def _estimate_routes_with_llm(
+        self,
+        from_name: str,
+        to_name: str,
+        city: str,
+        route_type_label: str = "公共交通"
+    ) -> List[Dict]:
+        """当高德API路线获取失败时,用LLM估计交通路线"""
+        prompt = f"""请估计从"{from_name}"到"{to_name}"(位于{city})的{route_type_label}路线。
+
+根据你对{city}的了解,生成合理的路线分段信息。只返回JSON数组,不要其他文字:
+[
+  {{
+    "type": "步行/公交/地铁",
+    "instruction": "具体乘坐指引(如'乘坐1路公交车从火车站到市中心')",
+    "from_name": "起点站名或地点",
+    "to_name": "终点站名或地点",
+    "duration": 15,
+    "distance": 2000,
+    "route_detail": "线路详情(如'经过5站'或'约2公里')"
+  }}
+]
+
+要求:
+1. type取值 "步行"/"公交"/"地铁",可组合多个分段
+2. duration单位分钟,distance单位米,数值要合理
+3. 根据{city}实际公交/地铁线路命名习惯来写
+4. 仅返回JSON数组,不要markdown标记"""
+        try:
+            from hello_agents import SimpleAgent
+            estimator = SimpleAgent(
+                name="route_estimator",
+                llm=self.llm,
+                system_prompt="你是城市交通专家,根据起点终点和城市信息合理估计路线。只返回JSON。"
+            )
+            response = estimator.run(prompt)
+
+            # 提取JSON
+            json_str = response.strip()
+            if "```json" in json_str:
+                json_str = json_str.split("```json")[1].split("```")[0]
+            elif "```" in json_str:
+                json_str = json_str.split("```")[1].split("```")[0]
+
+            import re
+            match = re.search(r'\[.*?\]', json_str, re.DOTALL)
+            if match:
+                data = json.loads(match.group())
+                if isinstance(data, list):
+                    print(f"  ✅ LLM路线估计成功: {len(data)}段")
+                    return data
+        except Exception as e:
+            print(f"  ⚠️ LLM路线估计失败: {e}")
+        return []
+
+    def _parse_response(self, response: str, request: TripRequest) -> TripPlan:
+        """
+        解析Agent响应
+        
+        Args:
+            response: Agent响应文本
+            request: 原始请求
+            
+        Returns:
+            旅行计划
+        """
+        try:
+            # 尝试从响应中提取JSON
+            # 查找JSON代码块
+            if "```json" in response:
+                json_start = response.find("```json") + 7
+                json_end = response.find("```", json_start)
+                json_str = response[json_start:json_end].strip()
+            elif "```" in response:
+                json_start = response.find("```") + 3
+                json_end = response.find("```", json_start)
+                json_str = response[json_start:json_end].strip()
+            elif "{" in response and "}" in response:
+                # 直接查找JSON对象
+                json_start = response.find("{")
+                json_end = response.rfind("}") + 1
+                json_str = response[json_start:json_end]
+            else:
+                raise ValueError("响应中未找到JSON数据")
+            
+            # 解析JSON
+            data = json.loads(json_str)
+            
+            # 转换为TripPlan对象
+            trip_plan = TripPlan(**data)
+            
+            return trip_plan
+            
+        except Exception as e:
+            print(f"⚠️  解析响应失败: {str(e)}")
+            print(f"   将使用备用方案生成计划")
+            return self._create_fallback_plan(request)
+    
+    def _create_fallback_plan(self, request: TripRequest) -> TripPlan:
+        """创建备用计划(当Agent失败时)"""
+        from datetime import datetime, timedelta
+
+        # 解析日期
+        start_date = datetime.strptime(request.start_date, "%Y-%m-%d")
+
+        # 出行人群描述
+        group_desc = {
+            "独自旅行": "适合独自旅行者",
+            "情侣夫妻": "适合情侣/夫妻浪漫之旅",
+            "朋友结伴": "适合朋友结伴游玩",
+            "家庭亲子": "适合家庭亲子活动",
+            "公司团建": "适合公司团建活动",
+            "老年旅行": "适合老年休闲之旅",
+            "研学旅行": "适合研学教育之旅"
+        }
+        traveler_desc = group_desc.get(request.traveler_group, "")
+
+        # 创建每日行程
+        days = []
+        for i in range(request.travel_days):
+            current_date = start_date + timedelta(days=i)
+
+            group_note = f"({traveler_desc}) " if traveler_desc else ""
+            day_plan = DayPlan(
+                date=current_date.strftime("%Y-%m-%d"),
+                day_index=i,
+                description=f"第{i+1}天行程{group_note}- 探索{request.city}",
+                transportation=request.transportation,
+                accommodation=request.accommodation,
+                attractions=[
+                    Attraction(
+                        name=f"{request.city}景点{j+1}",
+                        address=f"{request.city}市",
+                        location=Location(longitude=116.4 + i*0.01 + j*0.005, latitude=39.9 + i*0.01 + j*0.005),
+                        visit_duration=120,
+                        description=f"这是{request.city}的著名景点",
+                        category="景点"
+                    )
+                    for j in range(2)
+                ],
+                meals=[
+                    Meal(type="breakfast", name=f"第{i+1}天早餐", description="当地特色早餐"),
+                    Meal(type="lunch", name=f"第{i+1}天午餐", description="午餐推荐"),
+                    Meal(type="dinner", name=f"第{i+1}天晚餐", description="晚餐推荐")
+                ]
+            )
+            days.append(day_plan)
+        
+        return TripPlan(
+            city=request.city,
+            start_date=request.start_date,
+            end_date=request.end_date,
+            days=days,
+            weather_info=[],
+            overall_suggestions=f"这是为您规划的{request.city}{request.travel_days}日游行程{'(适合' + traveler_desc + ')' if traveler_desc else ''}。建议提前查看各景点的开放时间。"
+        )
+
+
+# 全局多智能体系统实例
+_multi_agent_planner = None
+
+
+def get_trip_planner_agent() -> MultiAgentTripPlanner:
+    """获取多智能体旅行规划系统实例(单例模式)"""
+    global _multi_agent_planner
+
+    if _multi_agent_planner is None:
+        _multi_agent_planner = MultiAgentTripPlanner()
+
+    return _multi_agent_planner
+

+ 2 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/api/__init__.py

@@ -0,0 +1,2 @@
+"""API模块"""
+

+ 128 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/api/main.py

@@ -0,0 +1,128 @@
+"""FastAPI主应用"""
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from ..config import get_settings, validate_config, print_config
+from ..database import init_db
+from ..rsa_service import init_rsa_keys
+from ..user_context import UserContextMiddleware
+from .routes import trip, poi, map as map_routes, auth, history, chat
+
+# 获取配置
+settings = get_settings()
+
+# 创建FastAPI应用
+app = FastAPI(
+    title=settings.app_name,
+    version=settings.app_version,
+    description="基于HelloAgents框架的智能旅行规划助手API",
+    docs_url="/docs",
+    redoc_url="/redoc"
+)
+
+# 配置CORS
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=settings.get_cors_origins_list(),
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+# 注册用户上下文中间件(在每个请求中解析 JWT Cookie,注入 current_user)
+app.add_middleware(UserContextMiddleware)
+
+# 注册路由
+app.include_router(trip.router, prefix="/api")
+app.include_router(poi.router, prefix="/api")
+app.include_router(map_routes.router, prefix="/api")
+app.include_router(auth.router, prefix="/api")
+app.include_router(history.router, prefix="/api")
+app.include_router(chat.router, prefix="/api")
+
+
+@app.on_event("startup")
+async def startup_event():
+    """应用启动事件"""
+    print("\n" + "="*60)
+    print(f"🚀 {settings.app_name} v{settings.app_version}")
+    print("="*60)
+    
+    # 打印配置信息
+    print_config()
+
+    # 初始化数据库
+    try:
+        init_db()
+        print("✅ 数据库初始化成功")
+    except Exception as e:
+        print(f"⚠️ 数据库初始化失败: {e}")
+
+    # 初始化RSA密钥
+    try:
+        init_rsa_keys()
+    except Exception as e:
+        print(f"⚠️ RSA密钥初始化失败: {e}")
+
+    # 验证配置
+    try:
+        validate_config()
+        print("\n✅ 配置验证通过")
+    except ValueError as e:
+        print(f"\n❌ 配置验证失败:\n{e}")
+        print("\n请检查.env文件并确保所有必要的配置项都已设置")
+        raise
+
+    protocol = "https" if settings.ssl_enabled else "http"
+    print("\n" + "="*60)
+    print(f"📚 API文档: {protocol}://localhost:{settings.port}/docs")
+    print(f"📖 ReDoc文档: {protocol}://localhost:{settings.port}/redoc")
+    print("="*60 + "\n")
+
+
+@app.on_event("shutdown")
+async def shutdown_event():
+    """应用关闭事件"""
+    print("\n" + "="*60)
+    print("👋 应用正在关闭...")
+    print("="*60 + "\n")
+
+
+@app.get("/")
+async def root():
+    """根路径"""
+    return {
+        "name": settings.app_name,
+        "version": settings.app_version,
+        "status": "running",
+        "docs": "/docs",
+        "redoc": "/redoc"
+    }
+
+
+@app.get("/health")
+async def health():
+    """健康检查"""
+    return {
+        "status": "healthy",
+        "service": settings.app_name,
+        "version": settings.app_version
+    }
+
+
+if __name__ == "__main__":
+    import uvicorn
+
+    ssl_kwargs = {}
+    if settings.ssl_enabled:
+        ssl_kwargs["ssl_certfile"] = settings.get_ssl_certfile()
+        ssl_kwargs["ssl_keyfile"] = settings.get_ssl_keyfile()
+
+    uvicorn.run(
+        "app.api.main:app",
+        host=settings.host,
+        port=settings.port,
+        reload=True,
+        **ssl_kwargs
+    )
+

+ 2 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/api/routes/__init__.py

@@ -0,0 +1,2 @@
+"""API路由模块"""
+

+ 216 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/api/routes/auth.py

@@ -0,0 +1,216 @@
+"""用户认证API路由 - 标准JWT(HttpOnly Cookie) + Redis持久化Refresh Token"""
+from fastapi import APIRouter, HTTPException, Request, Response
+from ...models.schemas import LoginRequest, RegisterRequest
+from ...database import create_user, verify_user, get_user_by_id
+from ...jwt_utils import (
+    create_access_token, create_refresh_token,
+    verify_access_token, verify_refresh_token,
+)
+from ...redis_service import store_refresh_token, validate_refresh_token, revoke_refresh_token
+from ...config import get_settings
+from ...rsa_service import get_public_key_pem, decrypt_data
+from ...user_context import get_current_user
+
+router = APIRouter(prefix="/auth", tags=["用户认证"])
+
+COOKIE_ACCESS_KEY = "access_token"
+COOKIE_REFRESH_KEY = "refresh_token"
+COOKIE_USERNAME_KEY = "auth_username"  # 前端可读,零请求获取用户名
+COOKIE_PATH = "/"
+COOKIE_REFRESH_PATH = "/api/auth"  # refresh_token 仅随认证请求携带
+COOKIE_SAMESITE = "lax"
+# 根据是否启用SSL自动设置Secure标志
+COOKIE_SECURE = get_settings().ssl_enabled
+
+
+def _set_auth_cookies(response: Response, access_token: str):
+    """设置 Access Token HttpOnly Cookie"""
+    response.set_cookie(
+        key=COOKIE_ACCESS_KEY, value=access_token,
+        httponly=True, secure=COOKIE_SECURE, samesite=COOKIE_SAMESITE,
+        max_age=1800, path=COOKIE_PATH,
+    )
+
+
+def _clear_auth_cookies(response: Response):
+    """清除认证Cookie(同时清理新旧路径的 refresh_token,兼容升级前残留)"""
+    response.delete_cookie(COOKIE_ACCESS_KEY, path=COOKIE_PATH)
+    response.delete_cookie(COOKIE_USERNAME_KEY, path=COOKIE_PATH)
+    response.delete_cookie(COOKIE_REFRESH_KEY, path=COOKIE_PATH)       # 旧:path="/" 的残留
+    response.delete_cookie(COOKIE_REFRESH_KEY, path=COOKIE_REFRESH_PATH)  # 新:path="/api/auth"
+
+
+def require_auth(request: Request = None) -> dict:
+    """
+    从请求上下文获取当前用户(中间件已统一处理 Cookie 和 Header)。
+    返回 {"id": user_id},未登录时抛 401。
+    """
+    user = get_current_user()
+    if not user:
+        raise HTTPException(status_code=401, detail="未登录")
+    return {"id": user["id"]}
+
+
+@router.post("/register", summary="用户注册")
+async def register(req: RegisterRequest, request: Request, response: Response):
+    """注册新用户(密码经RSA加密),设置Access Token Cookie + Refresh Token存入Redis"""
+    if len(req.username) < 2:
+        raise HTTPException(status_code=400, detail="用户名至少2个字符")
+
+    # RSA解密密码
+    try:
+        password = decrypt_data(req.encrypted_password)
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=f"密码解密失败: {e}")
+
+    if len(password) < 4:
+        raise HTTPException(status_code=400, detail="密码至少4个字符")
+    try:
+        user = create_user(req.username.strip(), password)
+        user_agent = request.headers.get("User-Agent", "")
+        access_token, refresh_token = _issue_tokens(response, user["id"], username=user["username"], user_agent=user_agent)
+        return {
+            "success": True, "message": "注册成功",
+            "username": user["username"],
+            "access_token": access_token,
+            "refresh_token": refresh_token,
+        }
+    except ValueError as e:
+        raise HTTPException(status_code=409, detail=str(e))
+
+
+@router.post("/login", summary="用户登录")
+async def login(req: LoginRequest, request: Request, response: Response):
+    """登录(密码经RSA加密),设置Access Token Cookie + Refresh Token存入Redis"""
+    # RSA解密密码
+    try:
+        password = decrypt_data(req.encrypted_password)
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=f"密码解密失败: {e}")
+
+    user = verify_user(req.username.strip(), password)
+    if not user:
+        raise HTTPException(status_code=401, detail="用户名或密码错误")
+    user_agent = request.headers.get("User-Agent", "")
+    access_token, refresh_token = _issue_tokens(response, user["id"], username=user["username"], user_agent=user_agent)
+    return {
+        "success": True, "message": "登录成功",
+        "username": user["username"],
+        "access_token": access_token,
+        "refresh_token": refresh_token,
+    }
+
+
+@router.get("/public-key", summary="获取RSA公钥")
+async def public_key():
+    """获取RSA公钥(PEM格式),用于前端加密密码"""
+    return {
+        "success": True,
+        "public_key": get_public_key_pem(),
+    }
+
+
+def _issue_tokens(response: Response, user_id: int, username: str = "", user_agent: str = "") -> tuple[str, str]:
+    """签发双Token + 前端可读用户名Cookie
+    - access_token   (HttpOnly,  /)           → JWT认证
+    - refresh_token  (HttpOnly,  /api/auth)   → 刷新Token (Redis存设备信息)
+    - auth_username  (可读,      /)           → 前端直接读,零请求
+    返回: (access_token, refresh_token) — 非浏览器设备可拿到令牌
+    """
+    # Access Token -> HttpOnly Cookie (全路径携带)
+    access_token = create_access_token(user_id)
+    _set_auth_cookies(response, access_token)
+
+    # Refresh Token -> JWT + Redis (含设备信息)
+    refresh_token, jti = create_refresh_token(user_id)
+    store_refresh_token(user_id, jti, user_agent=user_agent)
+
+    # 先清除旧路径的 refresh_token(兼容升级前 path="/" 的残留 cookie)
+    response.delete_cookie(COOKIE_REFRESH_KEY, path=COOKIE_PATH)
+
+    # Refresh Token 仅随 /api/auth/* 路径请求携带
+    response.set_cookie(
+        key=COOKIE_REFRESH_KEY, value=refresh_token,
+        httponly=True, secure=COOKIE_SECURE, samesite=COOKIE_SAMESITE,
+        max_age=604800, path=COOKIE_REFRESH_PATH,
+    )
+
+    # 前端可读的用户名 Cookie(非 HttpOnly,JS 可直接读取,无需调 profile API)
+    if username:
+        response.set_cookie(
+            key=COOKIE_USERNAME_KEY, value=username,
+            httponly=False, secure=COOKIE_SECURE, samesite=COOKIE_SAMESITE,
+            max_age=1800, path=COOKIE_PATH,
+        )
+
+    return access_token, refresh_token
+
+
+@router.post("/refresh", summary="刷新Token")
+async def refresh(request: Request, response: Response):
+    """用 Refresh Token 换取新的双Token,支持 Cookie 或 X-Refresh-Token Header"""
+    # 先从 Cookie 取,再尝试 Header(兼容非浏览器设备)
+    refresh_token = request.cookies.get(COOKIE_REFRESH_KEY) or request.headers.get("X-Refresh-Token", "")
+    if not refresh_token:
+        raise HTTPException(status_code=401, detail="未登录,缺少Refresh Token")
+
+    # 1. JWT签名验证
+    try:
+        payload = verify_refresh_token(refresh_token)
+    except Exception:
+        _clear_auth_cookies(response)
+        raise HTTPException(status_code=401, detail="Refresh Token已过期或无效")
+
+    # 2. Redis验证:jti是否有效 + 获取绑定的设备信息
+    stored = validate_refresh_token(payload["jti"])
+    if stored is None or stored["user_id"] != payload["id"]:
+        _clear_auth_cookies(response)
+        raise HTTPException(status_code=401, detail="Refresh Token已被吊销")
+
+    # 3. 设备信息校验(User-Agent不匹配时拒绝刷新,防跨设备盗用)
+    current_ua = request.headers.get("User-Agent", "")
+    if stored.get("user_agent") and stored["user_agent"] != current_ua:
+        _clear_auth_cookies(response)
+        raise HTTPException(status_code=401, detail="设备不匹配,请重新登录")
+
+    # 4. 吊销旧 Refresh Token(轮换)
+    revoke_refresh_token(payload["jti"])
+
+    # 5. 签发新双Token(绑定当前设备信息 + 用户名Cookie)
+    user_info = get_user_by_id(payload["id"])
+    new_access, new_refresh = _issue_tokens(response, payload["id"],
+                                            username=user_info["username"] if user_info else "",
+                                            user_agent=current_ua)
+
+    return {
+        "success": True,
+        "message": "Token刷新成功",
+        "access_token": new_access,
+        "refresh_token": new_refresh,
+        "username": user_info["username"] if user_info else "",
+    }
+
+
+@router.post("/logout", summary="用户登出")
+async def logout(request: Request, response: Response):
+    """登出:清除Cookie + 吊销Redis中的Refresh Token(支持Cookie或Header)"""
+    # 优先取 Cookie,再尝试 Header(兼容非浏览器设备)
+    refresh_token = request.cookies.get(COOKIE_REFRESH_KEY) or request.headers.get("X-Refresh-Token", "")
+    if refresh_token:
+        try:
+            payload = verify_refresh_token(refresh_token)
+            revoke_refresh_token(payload["jti"])
+        except Exception:
+            pass
+
+    _clear_auth_cookies(response)
+    return {"success": True, "message": "已退出登录"}
+
+
+@router.get("/profile", summary="获取用户信息")
+async def profile(request: Request):
+    """获取当前登录用户信息(从上下文直接读取,无需查DB)"""
+    user = get_current_user()
+    if not user:
+        raise HTTPException(status_code=401, detail="未登录")
+    return {"success": True, "username": user["username"]}

+ 189 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/api/routes/chat.py

@@ -0,0 +1,189 @@
+"""旅游AI对话API路由(SSE流式输出)"""
+import json
+import asyncio
+from fastapi import APIRouter, HTTPException, Request
+from fastapi.responses import StreamingResponse
+from ...models.schemas import ChatSessionResponse, ChatSessionListResponse, ChatMessagesResponse, ChatSendMessageRequest, ChatDeleteResponse
+from ...database import (
+    create_chat_session, list_chat_sessions, get_chat_session,
+    delete_chat_session, add_chat_message, get_chat_messages,
+    update_chat_session_title
+)
+from ...services.travel_chat_service import get_travel_chat_service
+from ...services.user_profile_service import load_profile_text, extract_and_update_profile, get_cross_session_context
+from .auth import require_auth
+
+router = APIRouter(prefix="/chat", tags=["旅游AI对话"])
+
+
+def _require_auth(request: Request) -> dict:
+    """统一鉴权"""
+    try:
+        return require_auth(request)
+    except HTTPException:
+        raise HTTPException(status_code=401, detail="请先登录后再使用AI对话")
+
+
+@router.post("/sessions", summary="创建新会话")
+async def create_session(request: Request):
+    """创建一个新的聊天会话"""
+    user = _require_auth(request)
+    session = create_chat_session(user["id"])
+    return ChatSessionResponse(success=True, session=session)
+
+
+@router.get("/sessions", summary="获取会话列表")
+async def list_sessions(request: Request):
+    """获取当前用户的所有会话"""
+    user = _require_auth(request)
+    sessions = list_chat_sessions(user["id"])
+    return ChatSessionListResponse(success=True, sessions=sessions)
+
+
+@router.get("/sessions/{session_id}", summary="获取会话详情")
+async def get_session(session_id: int, request: Request):
+    """获取单个会话信息"""
+    user = _require_auth(request)
+    session = get_chat_session(session_id, user["id"])
+    if not session:
+        raise HTTPException(status_code=404, detail="会话不存在")
+    return ChatSessionResponse(success=True, session=session)
+
+
+@router.delete("/sessions/{session_id}", summary="删除会话")
+async def delete_session(session_id: int, request: Request):
+    """删除会话及其所有消息"""
+    user = _require_auth(request)
+    deleted = delete_chat_session(session_id, user["id"])
+    if not deleted:
+        raise HTTPException(status_code=404, detail="会话不存在")
+    return ChatDeleteResponse(success=True, message="会话已删除")
+
+
+@router.get("/sessions/{session_id}/messages", summary="获取会话消息")
+async def get_messages(session_id: int, request: Request):
+    """获取会话的所有聊天消息"""
+    user = _require_auth(request)
+    session = get_chat_session(session_id, user["id"])
+    if not session:
+        raise HTTPException(status_code=404, detail="会话不存在")
+    messages = get_chat_messages(session_id)
+    return ChatMessagesResponse(success=True, messages=messages)
+
+
+@router.post("/sessions/{session_id}/messages", summary="发送消息(流式)")
+async def send_message(session_id: int, req: ChatSendMessageRequest, request: Request):
+    """
+    发送消息并流式获取AI回复(SSE格式)
+
+    流式返回 SSE 事件:
+    - data: {"type": "token", "content": "文本片段"}
+    - data: {"type": "error", "content": "错误信息"}
+    - data: {"type": "done", "title": "更新后的会话标题"}
+    """
+    user = _require_auth(request)
+    session = get_chat_session(session_id, user["id"])
+    if not session:
+        raise HTTPException(status_code=404, detail="会话不存在")
+
+    content = req.content.strip()
+    if not content:
+        raise HTTPException(status_code=400, detail="消息不能为空")
+
+    # 1. 保存用户消息
+    add_chat_message(session_id, "user", content)
+
+    # 2. 获取历史消息(作为上下文)
+    history = get_chat_messages(session_id)
+
+    # 3. 如果是会话首条消息,加载用户画像并包装为 XML 标签用户消息
+    profile_message = ""
+    if len(history) <= 1:
+        profile_text = load_profile_text(user["id"])
+        if profile_text:
+            profile_message = (
+                f"<user_profile>\n{profile_text}\n"
+                f"(注意:如果我现在说的与上述偏好不一致,请以我当前说的为准。)\n"
+                f"</user_profile>"
+            )
+            print(f"  👤 用户 {user['id']} 已加载画像上下文")
+
+    # 4. 返回流式响应
+    return StreamingResponse(
+        _stream_ai_response(user["id"], session_id, content, history, profile_message),
+        media_type="text/event-stream",
+        headers={
+            "Cache-Control": "no-cache",
+            "Connection": "keep-alive",
+            "X-Accel-Buffering": "no",
+        }
+    )
+
+
+async def _stream_ai_response(user_id: int, session_id: int, content: str, history: list, profile_message: str = ""):
+    """流式生成AI回复的SSE事件"""
+    travel_chat = get_travel_chat_service()
+    full_response = ""
+
+    try:
+        # 获取流式生成器(携带用户画像消息)
+        stream = travel_chat.chat_stream(
+            user_message=content,
+            history=history[:-1],  # 排除刚保存的最后一条
+            profile_message=profile_message,
+        )
+
+        for chunk in stream:
+            if chunk:
+                full_response += chunk
+                # 发送 token 事件
+                yield f"data: {json.dumps({'type': 'token', 'content': chunk}, ensure_ascii=False)}\n\n"
+
+        # 流式完成 - 保存AI回复到数据库
+        add_chat_message(session_id, "assistant", full_response)
+
+        # 如果是第一条消息,自动生成会话标题
+        title = None
+        if len(history) <= 1:
+            title = _generate_title(content)
+            update_chat_session_title(session_id, title)
+
+        # 发送完成事件(先发送,不阻塞)
+        done_event = {"type": "done"}
+        if title:
+            done_event["title"] = title
+        yield f"data: {json.dumps(done_event, ensure_ascii=False)}\n\n"
+
+        # 后台异步执行画像提取,不阻塞主进程(SSE 流已关闭)
+        asyncio.create_task(
+            asyncio.to_thread(_run_profile_extraction, user_id, content, history)
+        )
+
+    except Exception as e:
+        error_msg = f"抱歉,AI暂时无法回答您的问题,请稍后重试。"
+        # 尝试发送错误事件
+        yield f"data: {json.dumps({'type': 'error', 'content': error_msg}, ensure_ascii=False)}\n\n"
+        yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
+
+
+def _generate_title(user_message: str) -> str:
+    """根据用户第一条消息生成会话标题"""
+    title = user_message.strip()[:20]
+    if len(user_message) > 20:
+        title += "..."
+    return title
+
+
+def _run_profile_extraction(user_id: int, content: str, history: list):
+    """在后台线程中同步执行画像提取(不阻塞 SSE 流主进程)
+
+    由 asyncio.to_thread 调度到线程池执行,避免阻塞事件循环。
+    """
+    try:
+        cross_ctx = get_cross_session_context(user_id, max_sessions=5, max_messages=6)
+        extract_and_update_profile(
+            user_id, content, history,
+            cross_session_context=cross_ctx,
+        )
+    except Exception:
+        pass  # 画像提取失败不影响主流程

+ 71 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/api/routes/history.py

@@ -0,0 +1,71 @@
+"""行程历史记录API路由"""
+from fastapi import APIRouter, HTTPException, Request, Query
+from ...models.schemas import SaveHistoryRequest
+from ...database import save_trip_history, list_trip_history, get_trip_history, delete_trip_history
+from .auth import require_auth
+
+router = APIRouter(prefix="/history", tags=["历史记录"])
+
+
+@router.post("", summary="保存行程到历史记录")
+async def save_history(req: SaveHistoryRequest, request: Request):
+    """保存生成的行程到历史记录"""
+    user = require_auth(request)
+    import json
+    plan_json = json.dumps(req.plan_data, ensure_ascii=False)
+    hid = save_trip_history(
+        user_id=user["id"],
+        city=req.city,
+        start_date=req.start_date,
+        end_date=req.end_date,
+        travel_days=req.travel_days,
+        preferences=",".join(req.preferences) if req.preferences else "",
+        traveler_group=req.traveler_group or "",
+        plan_data=plan_json,
+    )
+    return {"success": True, "message": "保存成功", "history_id": hid}
+
+
+@router.get("", summary="获取历史记录列表")
+async def list_history(
+    request: Request,
+    page: int = Query(1, ge=1),
+    page_size: int = Query(20, ge=1, le=50),
+):
+    """获取当前用户的行程历史记录列表"""
+    user = require_auth(request)
+    offset = (page - 1) * page_size
+    records = list_trip_history(user["id"], limit=page_size, offset=offset)
+    return {"success": True, "records": records}
+
+
+@router.get("/{history_id}", summary="获取历史记录详情")
+async def get_history(history_id: int, request: Request):
+    """获取单条历史记录的完整行程数据"""
+    user = require_auth(request)
+    record = get_trip_history(history_id, user["id"])
+    if not record:
+        raise HTTPException(status_code=404, detail="记录不存在")
+    import json
+    plan_data = json.loads(record["plan_data"]) if isinstance(record["plan_data"], str) else record["plan_data"]
+    return {"success": True, "record": {
+        "id": record["id"],
+        "city": record["city"],
+        "start_date": record["start_date"],
+        "end_date": record["end_date"],
+        "travel_days": record["travel_days"],
+        "preferences": record["preferences"],
+        "traveler_group": record["traveler_group"],
+        "created_at": record["created_at"],
+        "plan_data": plan_data,
+    }}
+
+
+@router.delete("/{history_id}", summary="删除历史记录")
+async def delete_history(history_id: int, request: Request):
+    """删除一条历史记录"""
+    user = require_auth(request)
+    deleted = delete_trip_history(history_id, user["id"])
+    if not deleted:
+        raise HTTPException(status_code=404, detail="记录不存在")
+    return {"success": True, "message": "删除成功"}

+ 163 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/api/routes/map.py

@@ -0,0 +1,163 @@
+"""地图服务API路由"""
+
+from fastapi import APIRouter, HTTPException, Query
+from typing import Optional
+from ...models.schemas import (
+    POISearchRequest,
+    POISearchResponse,
+    RouteRequest,
+    RouteResponse,
+    WeatherResponse
+)
+from ...services.amap_service import get_amap_service
+
+router = APIRouter(prefix="/map", tags=["地图服务"])
+
+
+@router.get(
+    "/poi",
+    response_model=POISearchResponse,
+    summary="搜索POI",
+    description="根据关键词搜索POI(兴趣点)"
+)
+async def search_poi(
+    keywords: str = Query(..., description="搜索关键词", example="故宫"),
+    city: str = Query(..., description="城市", example="北京"),
+    citylimit: bool = Query(True, description="是否限制在城市范围内")
+):
+    """
+    搜索POI
+    
+    Args:
+        keywords: 搜索关键词
+        city: 城市
+        citylimit: 是否限制在城市范围内
+        
+    Returns:
+        POI搜索结果
+    """
+    try:
+        # 获取服务实例
+        service = get_amap_service()
+        
+        # 搜索POI
+        pois = service.search_poi(keywords, city, citylimit)
+        
+        return POISearchResponse(
+            success=True,
+            message="POI搜索成功",
+            data=pois
+        )
+        
+    except Exception as e:
+        print(f"❌ POI搜索失败: {str(e)}")
+        raise HTTPException(
+            status_code=500,
+            detail=f"POI搜索失败: {str(e)}"
+        )
+
+
+@router.get(
+    "/weather",
+    response_model=WeatherResponse,
+    summary="查询天气",
+    description="查询指定城市的天气信息"
+)
+async def get_weather(
+    city: str = Query(..., description="城市名称", example="北京")
+):
+    """
+    查询天气
+    
+    Args:
+        city: 城市名称
+        
+    Returns:
+        天气信息
+    """
+    try:
+        # 获取服务实例
+        service = get_amap_service()
+        
+        # 查询天气
+        weather_info = service.get_weather(city)
+        
+        return WeatherResponse(
+            success=True,
+            message="天气查询成功",
+            data=weather_info
+        )
+        
+    except Exception as e:
+        print(f"❌ 天气查询失败: {str(e)}")
+        raise HTTPException(
+            status_code=500,
+            detail=f"天气查询失败: {str(e)}"
+        )
+
+
+@router.post(
+    "/route",
+    response_model=RouteResponse,
+    summary="规划路线",
+    description="规划两点之间的路线"
+)
+async def plan_route(request: RouteRequest):
+    """
+    规划路线
+    
+    Args:
+        request: 路线规划请求
+        
+    Returns:
+        路线信息
+    """
+    try:
+        # 获取服务实例
+        service = get_amap_service()
+        
+        # 规划路线
+        route_info = service.plan_route(
+            origin_address=request.origin_address,
+            destination_address=request.destination_address,
+            origin_city=request.origin_city,
+            destination_city=request.destination_city,
+            route_type=request.route_type
+        )
+        
+        return RouteResponse(
+            success=True,
+            message="路线规划成功",
+            data=route_info
+        )
+        
+    except Exception as e:
+        print(f"❌ 路线规划失败: {str(e)}")
+        raise HTTPException(
+            status_code=500,
+            detail=f"路线规划失败: {str(e)}"
+        )
+
+
+@router.get(
+    "/health",
+    summary="健康检查",
+    description="检查地图服务是否正常"
+)
+async def health_check():
+    """健康检查"""
+    try:
+        # 检查服务是否可用
+        service = get_amap_service()
+        
+        return {
+            "status": "healthy",
+            "service": "map-service",
+            "mcp_tools_count": len(service.mcp_tool._available_tools)
+        }
+    except Exception as e:
+        raise HTTPException(
+            status_code=503,
+            detail=f"服务不可用: {str(e)}"
+        )
+

+ 129 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/api/routes/poi.py

@@ -0,0 +1,129 @@
+"""POI相关API路由"""
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel, Field
+from typing import List, Optional
+from ...services.amap_service import get_amap_service
+from ...services.unsplash_service import get_unsplash_service
+
+router = APIRouter(prefix="/poi", tags=["POI"])
+
+
+class POIDetailResponse(BaseModel):
+    """POI详情响应"""
+    success: bool
+    message: str
+    data: Optional[dict] = None
+
+
+@router.get(
+    "/detail/{poi_id}",
+    response_model=POIDetailResponse,
+    summary="获取POI详情",
+    description="根据POI ID获取详细信息,包括图片"
+)
+async def get_poi_detail(poi_id: str):
+    """
+    获取POI详情
+    
+    Args:
+        poi_id: POI ID
+        
+    Returns:
+        POI详情响应
+    """
+    try:
+        amap_service = get_amap_service()
+        
+        # 调用高德地图POI详情API
+        result = amap_service.get_poi_detail(poi_id)
+        
+        return POIDetailResponse(
+            success=True,
+            message="获取POI详情成功",
+            data=result
+        )
+        
+    except Exception as e:
+        print(f"❌ 获取POI详情失败: {str(e)}")
+        raise HTTPException(
+            status_code=500,
+            detail=f"获取POI详情失败: {str(e)}"
+        )
+
+
+@router.get(
+    "/search",
+    summary="搜索POI",
+    description="根据关键词搜索POI"
+)
+async def search_poi(keywords: str, city: str = "北京"):
+    """
+    搜索POI
+
+    Args:
+        keywords: 搜索关键词
+        city: 城市名称
+
+    Returns:
+        搜索结果
+    """
+    try:
+        amap_service = get_amap_service()
+        result = amap_service.search_poi(keywords, city)
+
+        return {
+            "success": True,
+            "message": "搜索成功",
+            "data": result
+        }
+
+    except Exception as e:
+        print(f"❌ 搜索POI失败: {str(e)}")
+        raise HTTPException(
+            status_code=500,
+            detail=f"搜索POI失败: {str(e)}"
+        )
+
+
+@router.get(
+    "/photo",
+    summary="获取景点图片",
+    description="根据景点名称从Unsplash获取图片"
+)
+async def get_attraction_photo(name: str):
+    """
+    获取景点图片
+
+    Args:
+        name: 景点名称
+
+    Returns:
+        图片URL
+    """
+    try:
+        unsplash_service = get_unsplash_service()
+
+        # 搜索景点图片
+        photo_url = unsplash_service.get_photo_url(f"{name} China landmark")
+
+        if not photo_url:
+            # 如果没找到,尝试只用景点名称搜索
+            photo_url = unsplash_service.get_photo_url(name)
+
+        return {
+            "success": True,
+            "message": "获取图片成功",
+            "data": {
+                "name": name,
+                "photo_url": photo_url
+            }
+        }
+
+    except Exception as e:
+        print(f"❌ 获取景点图片失败: {str(e)}")
+        raise HTTPException(
+            status_code=500,
+            detail=f"获取景点图片失败: {str(e)}"
+        )
+

+ 86 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/api/routes/trip.py

@@ -0,0 +1,86 @@
+"""旅行规划API路由"""
+
+from fastapi import APIRouter, HTTPException
+from ...models.schemas import (
+    TripRequest,
+    TripPlanResponse,
+    ErrorResponse
+)
+from ...agents.trip_planner_agent import get_trip_planner_agent
+
+router = APIRouter(prefix="/trip", tags=["旅行规划"])
+
+
+@router.post(
+    "/plan",
+    response_model=TripPlanResponse,
+    summary="生成旅行计划",
+    description="根据用户输入的旅行需求,生成详细的旅行计划"
+)
+async def plan_trip(request: TripRequest):
+    """
+    生成旅行计划
+
+    Args:
+        request: 旅行请求参数
+
+    Returns:
+        旅行计划响应
+    """
+    try:
+        print(f"\n{'='*60}")
+        print(f"📥 收到旅行规划请求:")
+        print(f"   城市: {request.city}")
+        print(f"   日期: {request.start_date} - {request.end_date}")
+        print(f"   天数: {request.travel_days}")
+        print(f"{'='*60}\n")
+
+        # 获取Agent实例
+        print("🔄 获取多智能体系统实例...")
+        agent = get_trip_planner_agent()
+
+        # 生成旅行计划
+        print("🚀 开始生成旅行计划...")
+        trip_plan = agent.plan_trip(request)
+
+        print("✅ 旅行计划生成成功,准备返回响应\n")
+
+        return TripPlanResponse(
+            success=True,
+            message="旅行计划生成成功",
+            data=trip_plan
+        )
+
+    except Exception as e:
+        print(f"❌ 生成旅行计划失败: {str(e)}")
+        import traceback
+        traceback.print_exc()
+        raise HTTPException(
+            status_code=500,
+            detail=f"生成旅行计划失败: {str(e)}"
+        )
+
+
+@router.get(
+    "/health",
+    summary="健康检查",
+    description="检查旅行规划服务是否正常"
+)
+async def health_check():
+    """健康检查"""
+    try:
+        # 检查Agent是否可用
+        agent = get_trip_planner_agent()
+        
+        return {
+            "status": "healthy",
+            "service": "trip-planner",
+            "agent_name": agent.attraction_agent.name,
+            "tools_count": len(agent.attraction_agent.list_tools())
+        }
+    except Exception as e:
+        raise HTTPException(
+            status_code=503,
+            detail=f"服务不可用: {str(e)}"
+        )
+

+ 133 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/config.py

@@ -0,0 +1,133 @@
+"""配置管理模块"""
+
+import os
+from pathlib import Path
+from typing import List
+from pydantic_settings import BaseSettings
+from dotenv import load_dotenv
+
+# 加载环境变量
+# 首先尝试加载当前目录的.env
+load_dotenv()
+
+# 然后尝试加载HelloAgents的.env(如果存在)
+helloagents_env = Path(__file__).parent.parent.parent.parent / "HelloAgents" / ".env"
+if helloagents_env.exists():
+    load_dotenv(helloagents_env, override=False)  # 不覆盖已有的环境变量
+
+
+class Settings(BaseSettings):
+    """应用配置"""
+
+    # 应用基本配置
+    app_name: str = "HelloAgents智能旅行助手"
+    app_version: str = "1.0.0"
+    debug: bool = False
+
+    # 服务器配置
+    host: str = "localhost"
+    port: int = 8000
+
+    # SSL/HTTPS配置
+    ssl_enabled: bool = False
+    ssl_certfile: str = ""
+    ssl_keyfile: str = ""
+
+    # CORS配置 - 使用字符串,在代码中分割
+    cors_origins: str = "http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173,http://127.0.0.1:3000,https://localhost:5173,https://localhost:3000,https://127.0.0.1:5173,https://127.0.0.1:3000"
+
+    # 高德地图API配置
+    amap_api_key: str = ""
+
+    # Unsplash API配置
+    unsplash_access_key: str = ""
+    unsplash_secret_key: str = ""
+
+    # LLM配置 (从环境变量读取,由HelloAgents管理)
+    openai_api_key: str = ""
+    openai_base_url: str = "https://api.openai.com/v1"
+    openai_model: str = "gpt-4"
+
+    # 日志配置
+    log_level: str = "INFO"
+
+    class Config:
+        env_file = ".env"
+        case_sensitive = False
+        extra = "ignore"  # 忽略额外的环境变量
+
+    def get_cors_origins_list(self) -> List[str]:
+        """获取CORS origins列表"""
+        return [origin.strip() for origin in self.cors_origins.split(',')]
+
+    def get_ssl_certfile(self) -> str:
+        """获取SSL证书路径(相对于项目根目录解析)"""
+        if not self.ssl_certfile:
+            return ""
+        path = Path(self.ssl_certfile)
+        return str(path) if path.is_absolute() else str(Path(__file__).parent.parent / self.ssl_certfile)
+
+    def get_ssl_keyfile(self) -> str:
+        """获取SSL密钥路径(相对于项目根目录解析)"""
+        if not self.ssl_keyfile:
+            return ""
+        path = Path(self.ssl_keyfile)
+        return str(path) if path.is_absolute() else str(Path(__file__).parent.parent / self.ssl_keyfile)
+
+
+# 创建全局配置实例
+settings = Settings()
+
+
+def get_settings() -> Settings:
+    """获取配置实例"""
+    return settings
+
+
+# 验证必要的配置
+def validate_config():
+    """验证配置是否完整"""
+    errors = []
+    warnings = []
+
+    if not settings.amap_api_key:
+        errors.append("AMAP_API_KEY未配置")
+
+    # HelloAgentsLLM会自动从LLM_API_KEY读取,不强制要求OPENAI_API_KEY
+    llm_api_key = os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY")
+    if not llm_api_key:
+        warnings.append("LLM_API_KEY或OPENAI_API_KEY未配置,LLM功能可能无法使用")
+
+    if errors:
+        error_msg = "配置错误:\n" + "\n".join(f"  - {e}" for e in errors)
+        raise ValueError(error_msg)
+
+    if warnings:
+        print("\n⚠️  配置警告:")
+        for w in warnings:
+            print(f"  - {w}")
+
+    return True
+
+
+# 打印配置信息(用于调试)
+def print_config():
+    """打印当前配置(隐藏敏感信息)"""
+    print(f"应用名称: {settings.app_name}")
+    print(f"版本: {settings.app_version}")
+    print(f"服务器: {settings.host}:{settings.port}")
+    protocol = "https" if settings.ssl_enabled else "http"
+    print(f"高德地图API Key: {'已配置' if settings.amap_api_key else '未配置'}")
+    print(f"SSL/HTTPS: {'已启用' if settings.ssl_enabled else '未启用'}")
+    print(f"协议: {protocol.upper()}")
+
+    # 检查LLM配置
+    llm_api_key = os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY")
+    llm_base_url = os.getenv("LLM_BASE_URL") or settings.openai_base_url
+    llm_model = os.getenv("LLM_MODEL_ID") or settings.openai_model
+
+    print(f"LLM API Key: {'已配置' if llm_api_key else '未配置'}")
+    print(f"LLM Base URL: {llm_base_url}")
+    print(f"LLM Model: {llm_model}")
+    print(f"日志级别: {settings.log_level}")
+

+ 391 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/database.py

@@ -0,0 +1,391 @@
+"""数据库管理 - SQLite"""
+import sqlite3
+import os
+import hashlib
+import secrets
+from pathlib import Path
+from datetime import datetime
+
+DB_DIR = Path(__file__).parent.parent / "data"
+DB_PATH = DB_DIR / "trip_planner.db"
+
+
+def get_db() -> sqlite3.Connection:
+    """获取数据库连接"""
+    DB_DIR.mkdir(parents=True, exist_ok=True)
+    conn = sqlite3.connect(str(DB_PATH))
+    conn.row_factory = sqlite3.Row
+    conn.execute("PRAGMA journal_mode=WAL")
+    conn.execute("PRAGMA foreign_keys=ON")
+    return conn
+
+
+def init_db():
+    """初始化数据库表"""
+    conn = get_db()
+    try:
+        conn.executescript("""
+            CREATE TABLE IF NOT EXISTS users (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                username TEXT UNIQUE NOT NULL,
+                password_hash TEXT NOT NULL,
+                salt TEXT NOT NULL,
+                created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
+            );
+
+            CREATE TABLE IF NOT EXISTS auth_tokens (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                user_id INTEGER NOT NULL,
+                token TEXT UNIQUE NOT NULL,
+                created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
+                FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+            );
+
+            CREATE TABLE IF NOT EXISTS trip_history (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                user_id INTEGER NOT NULL,
+                city TEXT NOT NULL,
+                start_date TEXT NOT NULL,
+                end_date TEXT NOT NULL,
+                travel_days INTEGER NOT NULL DEFAULT 0,
+                preferences TEXT DEFAULT '',
+                traveler_group TEXT DEFAULT '',
+                plan_data TEXT NOT NULL,
+                created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
+                FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+            );
+
+            CREATE TABLE IF NOT EXISTS chat_sessions (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                user_id INTEGER NOT NULL,
+                title TEXT NOT NULL DEFAULT '新对话',
+                created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
+                updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
+                FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+            );
+
+            CREATE TABLE IF NOT EXISTS chat_messages (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                session_id INTEGER NOT NULL,
+                role TEXT NOT NULL,
+                content TEXT NOT NULL,
+                created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
+                FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE
+            );
+
+            CREATE INDEX IF NOT EXISTS idx_tokens_user ON auth_tokens(user_id);
+            CREATE INDEX IF NOT EXISTS idx_tokens_token ON auth_tokens(token);
+            CREATE INDEX IF NOT EXISTS idx_history_user ON trip_history(user_id);
+            CREATE INDEX IF NOT EXISTS idx_chat_sessions_user ON chat_sessions(user_id);
+            CREATE INDEX IF NOT EXISTS idx_chat_messages_session ON chat_messages(session_id);
+        """)
+        conn.commit()
+    finally:
+        conn.close()
+
+
+# ============ 用户管理 ============
+
+def get_user_by_id(user_id: int) -> dict:
+    """通过ID获取用户信息"""
+    conn = get_db()
+    try:
+        row = conn.execute(
+            "SELECT id, username FROM users WHERE id = ?", (user_id,)
+        ).fetchone()
+        return dict(row) if row else None
+    finally:
+        conn.close()
+
+
+def hash_password(password: str, salt: str = None) -> tuple:
+    """密码加盐哈希,返回 (hash, salt)"""
+    if salt is None:
+        salt = secrets.token_hex(16)
+    h = hashlib.sha256((salt + password).encode()).hexdigest()
+    return h, salt
+
+
+def create_user(username: str, password: str) -> dict:
+    """创建用户,返回用户信息"""
+    conn = get_db()
+    try:
+        pwd_hash, salt = hash_password(password)
+        cursor = conn.execute(
+            "INSERT INTO users (username, password_hash, salt) VALUES (?, ?, ?)",
+            (username, pwd_hash, salt)
+        )
+        conn.commit()
+        return {"id": cursor.lastrowid, "username": username}
+    except sqlite3.IntegrityError:
+        raise ValueError("用户名已存在")
+    finally:
+        conn.close()
+
+
+def verify_user(username: str, password: str) -> dict:
+    """验证用户登录,返回用户信息或None"""
+    conn = get_db()
+    try:
+        row = conn.execute(
+            "SELECT id, username, password_hash, salt FROM users WHERE username = ?",
+            (username,)
+        ).fetchone()
+        if not row:
+            return None
+        pwd_hash, _ = hash_password(password, row["salt"])
+        if pwd_hash != row["password_hash"]:
+            return None
+        return {"id": row["id"], "username": row["username"]}
+    finally:
+        conn.close()
+
+
+# ============ Token管理 ============
+
+def create_token(user_id: int) -> str:
+    """创建登录token"""
+    token = secrets.token_hex(32)
+    conn = get_db()
+    try:
+        conn.execute(
+            "INSERT INTO auth_tokens (user_id, token) VALUES (?, ?)",
+            (user_id, token)
+        )
+        conn.commit()
+        return token
+    finally:
+        conn.close()
+
+
+def get_user_by_token(token: str) -> dict:
+    """通过token获取用户信息"""
+    conn = get_db()
+    try:
+        row = conn.execute(
+            """SELECT u.id, u.username FROM users u
+               JOIN auth_tokens t ON t.user_id = u.id
+               WHERE t.token = ?""",
+            (token,)
+        ).fetchone()
+        if row:
+            return {"id": row["id"], "username": row["username"]}
+        return None
+    finally:
+        conn.close()
+
+
+def delete_token(token: str):
+    """删除token(登出)"""
+    conn = get_db()
+    try:
+        conn.execute("DELETE FROM auth_tokens WHERE token = ?", (token,))
+        conn.commit()
+    finally:
+        conn.close()
+
+
+# ============ 历史记录管理 ============
+
+def save_trip_history(user_id: int, city: str, start_date: str, end_date: str,
+                      travel_days: int, preferences: str, traveler_group: str,
+                      plan_data: str) -> int:
+    """保存行程到历史记录"""
+    conn = get_db()
+    try:
+        cursor = conn.execute(
+            """INSERT INTO trip_history
+               (user_id, city, start_date, end_date, travel_days, preferences, traveler_group, plan_data)
+               VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
+            (user_id, city, start_date, end_date, travel_days, preferences, traveler_group, plan_data)
+        )
+        conn.commit()
+        return cursor.lastrowid
+    finally:
+        conn.close()
+
+
+def list_trip_history(user_id: int, limit: int = 20, offset: int = 0) -> list:
+    """列出用户的历史记录"""
+    conn = get_db()
+    try:
+        rows = conn.execute(
+            """SELECT id, city, start_date, end_date, travel_days, preferences, traveler_group, created_at
+               FROM trip_history
+               WHERE user_id = ?
+               ORDER BY created_at DESC
+               LIMIT ? OFFSET ?""",
+            (user_id, limit, offset)
+        ).fetchall()
+        return [dict(r) for r in rows]
+    finally:
+        conn.close()
+
+
+def get_trip_history(history_id: int, user_id: int) -> dict:
+    """获取单条历史记录详情"""
+    conn = get_db()
+    try:
+        row = conn.execute(
+            """SELECT * FROM trip_history WHERE id = ? AND user_id = ?""",
+            (history_id, user_id)
+        ).fetchone()
+        if row:
+            return dict(row)
+        return None
+    finally:
+        conn.close()
+
+
+def delete_trip_history(history_id: int, user_id: int) -> bool:
+    """删除历史记录"""
+    conn = get_db()
+    try:
+        cursor = conn.execute(
+            "DELETE FROM trip_history WHERE id = ? AND user_id = ?",
+            (history_id, user_id)
+        )
+        conn.commit()
+        return cursor.rowcount > 0
+    finally:
+        conn.close()
+
+
+# ============ 聊天会话管理 ============
+
+def init_chat_tables():
+    """初始化聊天相关表(增量迁移)"""
+    conn = get_db()
+    try:
+        conn.executescript("""
+            CREATE TABLE IF NOT EXISTS chat_sessions (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                user_id INTEGER NOT NULL,
+                title TEXT NOT NULL DEFAULT '新对话',
+                created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
+                updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
+                FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+            );
+
+            CREATE TABLE IF NOT EXISTS chat_messages (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                session_id INTEGER NOT NULL,
+                role TEXT NOT NULL,
+                content TEXT NOT NULL,
+                created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
+                FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE
+            );
+
+            CREATE INDEX IF NOT EXISTS idx_chat_sessions_user ON chat_sessions(user_id);
+            CREATE INDEX IF NOT EXISTS idx_chat_messages_session ON chat_messages(session_id);
+        """)
+        conn.commit()
+    finally:
+        conn.close()
+
+
+def create_chat_session(user_id: int, title: str = "新对话") -> dict:
+    """创建聊天会话"""
+    conn = get_db()
+    try:
+        cursor = conn.execute(
+            "INSERT INTO chat_sessions (user_id, title) VALUES (?, ?)",
+            (user_id, title)
+        )
+        conn.commit()
+        return {"id": cursor.lastrowid, "user_id": user_id, "title": title}
+    finally:
+        conn.close()
+
+
+def list_chat_sessions(user_id: int) -> list:
+    """列出用户的所有聊天会话(按更新时间倒序)"""
+    conn = get_db()
+    try:
+        rows = conn.execute(
+            """SELECT id, title, created_at, updated_at
+               FROM chat_sessions
+               WHERE user_id = ?
+               ORDER BY updated_at DESC""",
+            (user_id,)
+        ).fetchall()
+        return [dict(r) for r in rows]
+    finally:
+        conn.close()
+
+
+def get_chat_session(session_id: int, user_id: int) -> dict:
+    """获取单个聊天会话"""
+    conn = get_db()
+    try:
+        row = conn.execute(
+            "SELECT id, title, created_at, updated_at FROM chat_sessions WHERE id = ? AND user_id = ?",
+            (session_id, user_id)
+        ).fetchone()
+        return dict(row) if row else None
+    finally:
+        conn.close()
+
+
+def update_chat_session_title(session_id: int, title: str) -> bool:
+    """更新会话标题"""
+    conn = get_db()
+    try:
+        cursor = conn.execute(
+            "UPDATE chat_sessions SET title = ?, updated_at = datetime('now','localtime') WHERE id = ?",
+            (title, session_id)
+        )
+        conn.commit()
+        return cursor.rowcount > 0
+    finally:
+        conn.close()
+
+
+def delete_chat_session(session_id: int, user_id: int) -> bool:
+    """删除聊天会话(级联删除消息)"""
+    conn = get_db()
+    try:
+        cursor = conn.execute(
+            "DELETE FROM chat_sessions WHERE id = ? AND user_id = ?",
+            (session_id, user_id)
+        )
+        conn.commit()
+        return cursor.rowcount > 0
+    finally:
+        conn.close()
+
+
+# ============ 聊天消息管理 ============
+
+def add_chat_message(session_id: int, role: str, content: str) -> dict:
+    """添加聊天消息,并更新会话的 updated_at"""
+    conn = get_db()
+    try:
+        cursor = conn.execute(
+            "INSERT INTO chat_messages (session_id, role, content) VALUES (?, ?, ?)",
+            (session_id, role, content)
+        )
+        conn.execute(
+            "UPDATE chat_sessions SET updated_at = datetime('now','localtime') WHERE id = ?",
+            (session_id,)
+        )
+        conn.commit()
+        return {"id": cursor.lastrowid, "session_id": session_id, "role": role, "content": content}
+    finally:
+        conn.close()
+
+
+def get_chat_messages(session_id: int) -> list:
+    """获取会话的所有消息(按时间正序)"""
+    conn = get_db()
+    try:
+        rows = conn.execute(
+            """SELECT id, role, content, created_at
+               FROM chat_messages
+               WHERE session_id = ?
+               ORDER BY id ASC""",
+            (session_id,)
+        ).fetchall()
+        return [dict(r) for r in rows]
+    finally:
+        conn.close()

+ 106 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/jwt_utils.py

@@ -0,0 +1,106 @@
+"""JWT工具 - 标准RFC7519格式 + Redis持久化Refresh Token"""
+import os
+import secrets
+import jwt
+from datetime import datetime, timedelta, timezone
+
+_ISSUER = "trip-planner-pro"
+
+# 密钥(首次运行自动生成)
+_SECRET_KEY = None
+
+
+def _get_secret() -> str:
+    global _SECRET_KEY
+    if _SECRET_KEY is None:
+        key = os.getenv("JWT_SECRET")
+        if not key:
+            key = os.urandom(32).hex()
+            os.environ["JWT_SECRET"] = key
+        _SECRET_KEY = key
+    return _SECRET_KEY
+
+
+ALGORITHM = "HS256"
+
+# 过期时间
+ACCESS_TOKEN_EXPIRE_MINUTES = 30
+REFRESH_TOKEN_EXPIRE_DAYS = 7
+
+
+def _now() -> datetime:
+    return datetime.now(timezone.utc)
+
+
+def create_access_token(user_id: int) -> str:
+    """生成标准 Access Token(30分钟有效,HttpOnly Cookie传递)"""
+    now = _now()
+    payload = {
+        "iss": _ISSUER,
+        "sub": str(user_id),
+        "aud": f"{_ISSUER}/api",
+        "exp": now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
+        "iat": now,
+        "jti": secrets.token_hex(16),
+        "type": "access",
+    }
+    return jwt.encode(payload, _get_secret(), algorithm=ALGORITHM)
+
+
+def create_refresh_token(user_id: int) -> tuple:
+    """
+    生成标准 Refresh Token(7天有效,jti存入Redis)
+    返回: (token_str, jti)
+    """
+    now = _now()
+    jti = secrets.token_hex(16)
+    payload = {
+        "iss": _ISSUER,
+        "sub": str(user_id),
+        "aud": f"{_ISSUER}/auth/refresh",
+        "exp": now + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
+        "iat": now,
+        "jti": jti,
+        "type": "refresh",
+    }
+    token = jwt.encode(payload, _get_secret(), algorithm=ALGORITHM)
+    return token, jti
+
+
+def verify_access_token(token: str) -> dict:
+    """验证 Access Token,返回 {"id": user_id}"""
+    payload = jwt.decode(
+        token,
+        _get_secret(),
+        algorithms=[ALGORITHM],
+        audience=f"{_ISSUER}/api",
+        issuer=_ISSUER,
+        options={"require": ["exp", "iat", "sub", "jti", "type"]},
+    )
+    if payload.get("type") != "access":
+        raise jwt.InvalidTokenError("Token类型错误")
+    return {"id": int(payload["sub"])}
+
+
+def verify_refresh_token(token: str) -> dict:
+    """验证 Refresh Token(仅JWT签名验证),返回 {"id": user_id, "jti": jti}"""
+    payload = jwt.decode(
+        token,
+        _get_secret(),
+        algorithms=[ALGORITHM],
+        audience=f"{_ISSUER}/auth/refresh",
+        issuer=_ISSUER,
+        options={"require": ["exp", "iat", "sub", "jti", "type"]},
+    )
+    if payload.get("type") != "refresh":
+        raise jwt.InvalidTokenError("Token类型错误")
+    return {"id": int(payload["sub"]), "jti": payload["jti"]}
+
+
+def get_token_jti(token: str) -> str:
+    """解码token获取jti(不验证签名,仅用于找回jti)"""
+    try:
+        payload = jwt.decode(token, options={"verify_signature": False})
+        return payload.get("jti", "")
+    except Exception:
+        return ""

+ 2 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/models/__init__.py

@@ -0,0 +1,2 @@
+"""数据模型模块"""
+

+ 322 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/models/schemas.py

@@ -0,0 +1,322 @@
+"""数据模型定义"""
+
+from typing import List, Optional, Union
+from pydantic import BaseModel, Field, field_validator
+from datetime import date
+
+
+# ============ 请求模型 ============
+
+class TripRequest(BaseModel):
+    """旅行规划请求"""
+    city: str = Field(..., description="目的地城市", example="北京")
+    start_date: str = Field(..., description="开始日期 YYYY-MM-DD", example="2025-06-01")
+    end_date: str = Field(..., description="结束日期 YYYY-MM-DD", example="2025-06-03")
+    travel_days: int = Field(..., description="旅行天数", ge=1, le=30, example=3)
+    transportation: str = Field(..., description="交通方式", example="公共交通")
+    accommodation: str = Field(..., description="住宿偏好", example="经济型酒店")
+    preferences: List[str] = Field(default=[], description="旅行偏好标签", example=["历史文化", "美食"])
+    traveler_group: str = Field(default="", description="出行人群,如:独自旅行/情侣夫妻/朋友结伴/家庭亲子/公司团建/老年旅行/研学旅行", example="家庭亲子")
+    free_text_input: Optional[str] = Field(default="", description="额外要求", example="希望多安排一些博物馆")
+
+    class Config:
+        json_schema_extra = {
+            "example": {
+                "city": "北京",
+                "start_date": "2025-06-01",
+                "end_date": "2025-06-03",
+                "travel_days": 3,
+                "transportation": "公共交通",
+                "accommodation": "经济型酒店",
+                "preferences": ["历史文化", "美食"],
+                "traveler_group": "家庭亲子",
+                "free_text_input": "希望多安排一些博物馆"
+            }
+        }
+
+
+class POISearchRequest(BaseModel):
+    """POI搜索请求"""
+    keywords: str = Field(..., description="搜索关键词", example="故宫")
+    city: str = Field(..., description="城市", example="北京")
+    citylimit: bool = Field(default=True, description="是否限制在城市范围内")
+
+
+class RouteRequest(BaseModel):
+    """路线规划请求"""
+    origin_address: str = Field(..., description="起点地址", example="北京市朝阳区阜通东大街6号")
+    destination_address: str = Field(..., description="终点地址", example="北京市海淀区上地十街10号")
+    origin_city: Optional[str] = Field(default=None, description="起点城市")
+    destination_city: Optional[str] = Field(default=None, description="终点城市")
+    route_type: str = Field(default="walking", description="路线类型: walking/driving/transit")
+
+
+# ============ 响应模型 ============
+
+class Location(BaseModel):
+    """地理位置"""
+    longitude: float = Field(..., description="经度")
+    latitude: float = Field(..., description="纬度")
+
+
+class Attraction(BaseModel):
+    """景点信息"""
+    name: str = Field(..., description="景点名称")
+    address: str = Field(..., description="地址")
+    location: Location = Field(..., description="经纬度坐标")
+    visit_duration: int = Field(..., description="建议游览时间(分钟)")
+    description: str = Field(..., description="景点描述")
+    category: Optional[str] = Field(default="景点", description="景点类别")
+    rating: Optional[float] = Field(default=None, description="评分")
+    photos: Optional[List[str]] = Field(default_factory=list, description="景点图片URL列表")
+    poi_id: Optional[str] = Field(default="", description="POI ID")
+    image_url: Optional[str] = Field(default=None, description="图片URL")
+    ticket_price: int = Field(default=0, description="门票价格(元)")
+
+
+class Meal(BaseModel):
+    """餐饮信息"""
+    type: str = Field(..., description="餐饮类型: breakfast/lunch/dinner/snack")
+    name: str = Field(..., description="餐饮名称")
+    address: Optional[str] = Field(default=None, description="地址")
+    location: Optional[Location] = Field(default=None, description="经纬度坐标")
+    description: Optional[str] = Field(default=None, description="描述")
+    estimated_cost: int = Field(default=0, description="预估费用(元)")
+
+
+class Hotel(BaseModel):
+    """酒店信息"""
+    name: str = Field(..., description="酒店名称")
+    address: str = Field(default="", description="酒店地址")
+    location: Optional[Location] = Field(default=None, description="酒店位置")
+    price_range: str = Field(default="", description="价格范围")
+    rating: str = Field(default="", description="评分")
+    distance: str = Field(default="", description="距离景点距离")
+    type: str = Field(default="", description="酒店类型")
+    estimated_cost: int = Field(default=0, description="预估费用(元/晚)")
+
+
+class TransportSegment(BaseModel):
+    """交通段详情"""
+    type: str = Field(..., description="交通方式: 步行/公交/地铁/出租车/自驾")
+    instruction: str = Field(..., description="详细交通说明,如'从天安门东站乘坐1号线到王府井站'")
+    from_name: str = Field(..., description="起点名称,如'酒店'或上一个景点名")
+    to_name: str = Field(..., description="终点名称,如景点名或餐厅名")
+    departure_time: str = Field(default="", description="出发时间,如'09:00'")
+    duration: int = Field(default=0, description="耗时(分钟)")
+    distance: int = Field(default=0, description="距离(米)")
+    route_detail: Optional[str] = Field(default=None, description="路线详情,如'经过5站·步行300米'")
+
+
+class DayPlan(BaseModel):
+    """单日行程"""
+    date: str = Field(..., description="日期 YYYY-MM-DD")
+    day_index: int = Field(..., description="第几天(从0开始)")
+    description: str = Field(..., description="当日行程描述")
+    transportation: str = Field(..., description="交通方式")
+    transportation_details: List[TransportSegment] = Field(default=[], description="详细交通分段信息,包含每一段的路线、时间、距离")
+    accommodation: str = Field(..., description="住宿")
+    hotel: Optional[Hotel] = Field(default=None, description="推荐酒店")
+    attractions: List[Attraction] = Field(default=[], description="景点列表")
+    meals: List[Meal] = Field(default=[], description="餐饮列表")
+
+
+class WeatherInfo(BaseModel):
+    """天气信息"""
+    date: str = Field(..., description="日期 YYYY-MM-DD")
+    day_weather: str = Field(default="", description="白天天气")
+    night_weather: str = Field(default="", description="夜间天气")
+    day_temp: Union[int, str] = Field(default=0, description="白天温度")
+    night_temp: Union[int, str] = Field(default=0, description="夜间温度")
+    wind_direction: str = Field(default="", description="风向")
+    wind_power: str = Field(default="", description="风力")
+
+    @field_validator('day_temp', 'night_temp', mode='before')
+    @classmethod
+    def parse_temperature(cls, v):
+        """解析温度,移除°C等单位"""
+        if isinstance(v, str):
+            # 移除°C, ℃等单位符号
+            v = v.replace('°C', '').replace('℃', '').replace('°', '').strip()
+            try:
+                return int(v)
+            except ValueError:
+                return 0
+        return v
+
+
+class Budget(BaseModel):
+    """预算信息"""
+    total_attractions: int = Field(default=0, description="景点门票总费用")
+    total_hotels: int = Field(default=0, description="酒店总费用")
+    total_meals: int = Field(default=0, description="餐饮总费用")
+    total_transportation: int = Field(default=0, description="交通总费用")
+    total: int = Field(default=0, description="总费用")
+
+
+class TripPlan(BaseModel):
+    """旅行计划"""
+    city: str = Field(..., description="目的地城市")
+    start_date: str = Field(..., description="开始日期")
+    end_date: str = Field(..., description="结束日期")
+    days: List[DayPlan] = Field(..., description="每日行程")
+    weather_info: List[WeatherInfo] = Field(default=[], description="天气信息")
+    overall_suggestions: str = Field(..., description="总体建议")
+    budget: Optional[Budget] = Field(default=None, description="预算信息")
+
+
+class TripPlanResponse(BaseModel):
+    """旅行计划响应"""
+    success: bool = Field(..., description="是否成功")
+    message: str = Field(default="", description="消息")
+    data: Optional[TripPlan] = Field(default=None, description="旅行计划数据")
+
+
+class POIInfo(BaseModel):
+    """POI信息"""
+    id: str = Field(..., description="POI ID")
+    name: str = Field(..., description="名称")
+    type: str = Field(..., description="类型")
+    address: str = Field(..., description="地址")
+    location: Location = Field(..., description="经纬度坐标")
+    tel: Optional[str] = Field(default=None, description="电话")
+
+
+class POISearchResponse(BaseModel):
+    """POI搜索响应"""
+    success: bool = Field(..., description="是否成功")
+    message: str = Field(default="", description="消息")
+    data: List[POIInfo] = Field(default=[], description="POI列表")
+
+
+class RouteInfo(BaseModel):
+    """路线信息"""
+    distance: float = Field(..., description="距离(米)")
+    duration: int = Field(..., description="时间(秒)")
+    route_type: str = Field(..., description="路线类型")
+    description: str = Field(..., description="路线描述")
+
+
+class RouteResponse(BaseModel):
+    """路线规划响应"""
+    success: bool = Field(..., description="是否成功")
+    message: str = Field(default="", description="消息")
+    data: Optional[RouteInfo] = Field(default=None, description="路线信息")
+
+
+class WeatherResponse(BaseModel):
+    """天气查询响应"""
+    success: bool = Field(..., description="是否成功")
+    message: str = Field(default="", description="消息")
+    data: List[WeatherInfo] = Field(default=[], description="天气信息")
+
+
+# ============ 错误响应 ============
+
+class ErrorResponse(BaseModel):
+    """错误响应"""
+    success: bool = Field(default=False, description="是否成功")
+    message: str = Field(..., description="错误消息")
+    error_code: Optional[str] = Field(default=None, description="错误代码")
+
+
+# ============ 认证模型 ============
+
+class RegisterRequest(BaseModel):
+    """注册请求(密码经RSA公钥加密,Base64编码)"""
+    username: str = Field(..., min_length=2, max_length=50, description="用户名")
+    encrypted_password: str = Field(..., description="RSA-OAEP加密后的密码(Base64)")
+
+
+class LoginRequest(BaseModel):
+    """登录请求(密码经RSA公钥加密,Base64编码)"""
+    username: str = Field(..., description="用户名")
+    encrypted_password: str = Field(..., description="RSA-OAEP加密后的密码(Base64)")
+
+
+# ============ 历史记录模型 ============
+
+class SaveHistoryRequest(BaseModel):
+    """保存历史记录请求"""
+    city: str = Field(..., description="城市")
+    start_date: str = Field(..., description="开始日期")
+    end_date: str = Field(..., description="结束日期")
+    travel_days: int = Field(..., description="旅行天数")
+    preferences: List[str] = Field(default=[], description="偏好标签")
+    traveler_group: str = Field(default="", description="出行人群")
+    plan_data: dict = Field(..., description="完整行程数据(JSON)")
+
+
+class HistoryItemResponse(BaseModel):
+    """保存历史记录响应"""
+    success: bool = Field(..., description="是否成功")
+    message: str = Field(default="", description="消息")
+    history_id: int = Field(default=0, description="历史记录ID")
+
+
+class HistoryListResponse(BaseModel):
+    """历史记录列表响应"""
+    success: bool = Field(..., description="是否成功")
+    records: list = Field(default=[], description="历史记录列表")
+
+
+class HistoryDetailResponse(BaseModel):
+    """历史记录详情响应"""
+    success: bool = Field(..., description="是否成功")
+    record: Optional[dict] = Field(default=None, description="历史记录详情")
+
+
+class DeleteResponse(BaseModel):
+    """删除响应"""
+    success: bool = Field(..., description="是否成功")
+    message: str = Field(default="", description="消息")
+
+
+# ============ 聊天模型 ============
+
+class ChatMessageSchema(BaseModel):
+    """聊天消息"""
+    id: int = Field(..., description="消息ID")
+    session_id: int = Field(..., description="会话ID")
+    role: str = Field(..., description="角色: user/assistant")
+    content: str = Field(..., description="消息内容")
+    created_at: str = Field(..., description="创建时间")
+
+
+class ChatSessionSchema(BaseModel):
+    """聊天会话"""
+    id: int = Field(..., description="会话ID")
+    user_id: int = Field(..., description="用户ID")
+    title: str = Field(..., description="会话标题")
+    created_at: str = Field(default="", description="创建时间")
+    updated_at: str = Field(default="", description="更新时间")
+
+
+class ChatSessionResponse(BaseModel):
+    """会话响应"""
+    success: bool = Field(..., description="是否成功")
+    session: Optional[ChatSessionSchema] = Field(default=None, description="会话信息")
+
+
+class ChatSessionListResponse(BaseModel):
+    """会话列表响应"""
+    success: bool = Field(..., description="是否成功")
+    sessions: list = Field(default=[], description="会话列表")
+
+
+class ChatMessagesResponse(BaseModel):
+    """消息列表响应"""
+    success: bool = Field(..., description="是否成功")
+    messages: list = Field(default=[], description="消息列表")
+
+
+class ChatSendMessageRequest(BaseModel):
+    """发送消息请求"""
+    content: str = Field(..., description="消息内容", min_length=1)
+
+
+class ChatDeleteResponse(BaseModel):
+    """聊天删除响应"""
+    success: bool = Field(..., description="是否成功")
+    message: str = Field(..., description="消息")
+

+ 105 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/redis_service.py

@@ -0,0 +1,105 @@
+"""Redis服务封装"""
+import json
+from datetime import timedelta
+import redis as redis_module
+import os
+
+REDIS_HOST = os.getenv("REDIS_HOST") 
+REDIS_PORT = int(os.getenv("REDIS_PORT"))
+REDIS_PASSWORD = os.getenv("REDIS_PASSWORD")
+REDIS_DB = int(os.getenv("REDIS_DB"))
+
+# Refresh Token 过期时间(与JWT refresh token一致)
+REFRESH_TOKEN_TTL = timedelta(days=7)
+
+# Key 前缀
+PREFIX_REFRESH = "refresh_token:"   # refresh_token:{jti} -> user_id
+PREFIX_USER_TOKENS = "user_tokens:"  # user_tokens:{user_id} -> set of jti
+
+
+def get_redis() -> redis_module.Redis:
+    """获取Redis连接"""
+    return redis_module.Redis(
+        host=REDIS_HOST,
+        port=REDIS_PORT,
+        password=REDIS_PASSWORD,
+        db=REDIS_DB,
+        decode_responses=True,
+        socket_connect_timeout=3,
+    )
+
+
+def store_refresh_token(user_id: int, jti: str, ttl_seconds: int = None, user_agent: str = "") -> bool:
+    """
+    将 Refresh Token 的 jti 存入 Redis
+    - refresh_token:{jti} -> json({"user_id": ..., "user_agent": ...}) (正向查:token -> user + 设备)
+    - user_tokens:{user_id} -> set of jti (反向查:user -> tokens,用于踢下线)
+    """
+    if ttl_seconds is None:
+        ttl_seconds = int(REFRESH_TOKEN_TTL.total_seconds())
+
+    r = get_redis()
+    try:
+        pipe = r.pipeline()
+        data = json.dumps({"user_id": user_id, "user_agent": user_agent})
+        pipe.setex(f"{PREFIX_REFRESH}{jti}", ttl_seconds, data)
+        pipe.sadd(f"{PREFIX_USER_TOKENS}{user_id}", jti)
+        pipe.expire(f"{PREFIX_USER_TOKENS}{user_id}", ttl_seconds)
+        pipe.execute()
+        return True
+    finally:
+        r.close()
+
+
+def validate_refresh_token(jti: str) -> dict | None:
+    """
+    验证 Refresh Token 是否在 Redis 中有效
+    返回值: {"user_id": int, "user_agent": str} 或 None
+    """
+    r = get_redis()
+    try:
+        data = r.get(f"{PREFIX_REFRESH}{jti}")
+        if data is None:
+            return None
+        parsed = json.loads(data)
+        return {
+            "user_id": int(parsed["user_id"]),
+            "user_agent": parsed.get("user_agent", ""),
+        }
+    finally:
+        r.close()
+
+
+def revoke_refresh_token(jti: str) -> bool:
+    """吊销单个 Refresh Token"""
+    r = get_redis()
+    try:
+        data = r.get(f"{PREFIX_REFRESH}{jti}")
+        if data is None:
+            return False
+        parsed = json.loads(data)
+        uid = int(parsed["user_id"])
+        pipe = r.pipeline()
+        pipe.delete(f"{PREFIX_REFRESH}{jti}")
+        pipe.srem(f"{PREFIX_USER_TOKENS}{uid}", jti)
+        pipe.execute()
+        return True
+    finally:
+        r.close()
+
+
+def revoke_all_user_tokens(user_id: int) -> int:
+    """吊销用户的所有 Refresh Token(全部踢下线)"""
+    r = get_redis()
+    try:
+        jtis = r.smembers(f"{PREFIX_USER_TOKENS}{user_id}")
+        if not jtis:
+            return 0
+        pipe = r.pipeline()
+        for jti in jtis:
+            pipe.delete(f"{PREFIX_REFRESH}{jti}")
+        pipe.delete(f"{PREFIX_USER_TOKENS}{user_id}")
+        pipe.execute()
+        return len(jtis)
+    finally:
+        r.close()

+ 88 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/rsa_service.py

@@ -0,0 +1,88 @@
+"""RSA 密钥管理服务 - 用于前端传输密码的非对称加密"""
+
+import base64
+import os
+from pathlib import Path
+
+from cryptography.hazmat.primitives.asymmetric import rsa, padding
+from cryptography.hazmat.primitives import hashes, serialization
+
+# 密钥文件存储路径(backend/data/rsa_private_key.pem)
+_KEY_DIR = Path(__file__).parent.parent / "data"
+_PRIVATE_KEY_PATH = _KEY_DIR / "rsa_private_key.pem"
+_PUBLIC_KEY_PATH = _KEY_DIR / "rsa_public_key.pem"
+
+_private_key = None
+_public_key = None
+
+
+def init_rsa_keys():
+    """初始化 RSA 密钥对:如果密钥文件已存在则加载,否则生成新密钥"""
+    global _private_key, _public_key
+
+    _KEY_DIR.mkdir(parents=True, exist_ok=True)
+
+    if _PRIVATE_KEY_PATH.exists():
+        # 加载已有密钥
+        with open(_PRIVATE_KEY_PATH, "rb") as f:
+            _private_key = serialization.load_pem_private_key(f.read(), password=None)
+        with open(_PUBLIC_KEY_PATH, "rb") as f:
+            _public_key = serialization.load_pem_public_key(f.read())
+    else:
+        # 生成新 RSA 2048 密钥对
+        _private_key = rsa.generate_private_key(
+            public_exponent=65537,
+            key_size=2048,
+        )
+        _public_key = _private_key.public_key()
+
+        # 保存私钥
+        with open(_PRIVATE_KEY_PATH, "wb") as f:
+            f.write(_private_key.private_bytes(
+                encoding=serialization.Encoding.PEM,
+                format=serialization.PrivateFormat.PKCS8,
+                encryption_algorithm=serialization.NoEncryption(),
+            ))
+
+        # 保存公钥
+        with open(_PUBLIC_KEY_PATH, "wb") as f:
+            f.write(_public_key.public_bytes(
+                encoding=serialization.Encoding.PEM,
+                format=serialization.PublicFormat.SubjectPublicKeyInfo,
+            ))
+
+    print(f"✅ RSA密钥已{'加载' if _PRIVATE_KEY_PATH.exists() else '生成'}")
+    print(f"   私钥: {_PRIVATE_KEY_PATH}")
+    print(f"   公钥: {_PUBLIC_KEY_PATH}")
+
+
+def get_public_key_pem() -> str:
+    """获取公钥 PEM 字符串(用于前端加密)"""
+    global _public_key
+    if _public_key is None:
+        init_rsa_keys()
+    return _public_key.public_bytes(
+        encoding=serialization.Encoding.PEM,
+        format=serialization.PublicFormat.SubjectPublicKeyInfo,
+    ).decode()
+
+
+def decrypt_data(encrypted_b64: str) -> str:
+    """解密前端 RSA-OAEP 加密的 Base64 数据"""
+    global _private_key
+    if _private_key is None:
+        init_rsa_keys()
+
+    try:
+        ciphertext = base64.b64decode(encrypted_b64)
+        plaintext = _private_key.decrypt(
+            ciphertext,
+            padding.OAEP(
+                mgf=padding.MGF1(algorithm=hashes.SHA256()),
+                algorithm=hashes.SHA256(),
+                label=None,
+            ),
+        )
+        return plaintext.decode()
+    except Exception as e:
+        raise ValueError(f"RSA解密失败: {e}")

+ 2 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/services/__init__.py

@@ -0,0 +1,2 @@
+"""服务模块"""
+

+ 715 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/services/amap_service.py

@@ -0,0 +1,715 @@
+"""高德地图MCP服务封装"""
+
+import json
+import re
+from typing import List, Dict, Any, Optional
+from ..agents.mcp_tool import MCPTool
+from ..config import get_settings
+from ..models.schemas import Location, POIInfo, WeatherInfo
+
+# 全局MCP工具实例
+_amap_mcp_tool = None
+
+
+def get_amap_mcp_tool() -> MCPTool:
+    """
+    获取高德地图MCP工具实例(单例模式)
+
+    Returns:
+        MCPTool实例
+    """
+    global _amap_mcp_tool
+
+    if _amap_mcp_tool is None:
+        settings = get_settings()
+
+        if not settings.amap_api_key:
+            raise ValueError("高德地图API Key未配置,请在.env文件中设置AMAP_API_KEY")
+
+        # 创建MCP工具
+        _amap_mcp_tool = MCPTool(
+            name="amap",
+            description="高德地图服务,支持POI搜索、路线规划、天气查询等功能",
+            server_command=["uvx", "amap-mcp-server"],
+            env={"AMAP_MAPS_API_KEY": settings.amap_api_key},
+            auto_expand=True  # 自动展开为独立工具
+        )
+
+        print(f"✅ 高德地图MCP工具初始化成功")
+        print(f"   工具数量: {len(_amap_mcp_tool._available_tools)}")
+
+        # 打印可用工具列表
+        if _amap_mcp_tool._available_tools:
+            print("   可用工具:")
+            for tool in _amap_mcp_tool._available_tools[:5]:  # 只打印前5个
+                print(f"     - {tool.get('name', 'unknown')}")
+            if len(_amap_mcp_tool._available_tools) > 5:
+                print(f"     ... 还有 {len(_amap_mcp_tool._available_tools) - 5} 个工具")
+
+    return _amap_mcp_tool
+
+
+class AmapService:
+    """高德地图服务封装类"""
+
+    def __init__(self):
+        """初始化服务"""
+        self.mcp_tool = get_amap_mcp_tool()
+
+    def search_poi(self, keywords: str, city: str, citylimit: bool = True) -> List[POIInfo]:
+        """
+        搜索POI
+        """
+        try:
+            result = self.mcp_tool.run({
+                "action": "call_tool",
+                "tool_name": "maps_text_search",
+                "arguments": {
+                    "keywords": keywords,
+                    "city": city,
+                    "citylimit": str(citylimit).lower()
+                }
+            })
+
+            # 从 MCP 返回文本中提取 JSON
+            json_match = re.search(r'\{.*\}', result, re.DOTALL)
+            if not json_match:
+                return []
+
+            data = json.loads(json_match.group())
+            pois_data = data.get("pois", [])
+
+            pois = []
+            for p in pois_data:
+                loc = None
+                location_str = p.get("location", "")
+                if location_str and isinstance(location_str, str) and "," in location_str:
+                    try:
+                        lng, lat = location_str.split(",")
+                        loc = Location(longitude=float(lng), latitude=float(lat))
+                    except (ValueError, TypeError):
+                        pass
+
+                pois.append(POIInfo(
+                    id=p.get("id", ""),
+                    name=p.get("name", ""),
+                    type=p.get("typecode", p.get("type", "")),
+                    address=p.get("address", ""),
+                    location=loc or Location(longitude=116.4, latitude=39.9),
+                    tel=p.get("tel")
+                ))
+
+            print(f"  ✅ POI搜索成功: {len(pois)} 条结果")
+            return pois
+
+        except Exception as e:
+            print(f"❌ POI搜索失败: {str(e)}")
+            return []
+
+    def get_weather(self, city: str) -> List[WeatherInfo]:
+        """
+        查询天气
+        """
+        try:
+            result = self.mcp_tool.run({
+                "action": "call_tool",
+                "tool_name": "maps_weather",
+                "arguments": {
+                    "city": city
+                }
+            })
+
+            json_match = re.search(r'\{.*\}', result, re.DOTALL)
+            if not json_match:
+                return []
+
+            data = json.loads(json_match.group())
+            # 高德天气返回 forecast 格式
+            forecasts = data.get("forecasts", [])
+
+            weather_list = []
+            for w in forecasts:
+                weather_list.append(WeatherInfo(
+                    date=w.get("date", ""),
+                    day_weather=w.get("dayweather", ""),
+                    night_weather=w.get("nightweather", ""),
+                    day_temp=w.get("daytemp", w.get("daytemp_float", 0)),
+                    night_temp=w.get("nighttemp", w.get("nighttemp_float", 0)),
+                    wind_direction=w.get("daywind", ""),
+                    wind_power=w.get("daypower", "")
+                ))
+
+            print(f"  ✅ 天气查询成功: {len(weather_list)} 条记录")
+            return weather_list
+
+        except Exception as e:
+            print(f"❌ 天气查询失败: {str(e)}")
+            return []
+
+    def plan_route(
+        self,
+        origin_address: str,
+        destination_address: str,
+        origin_city: Optional[str] = None,
+        destination_city: Optional[str] = None,
+        route_type: str = "walking"
+    ) -> Dict[str, Any]:
+        """
+        规划路线,调用高德地图MCP获取真实路线数据
+
+        Args:
+            origin_address: 起点地址
+            destination_address: 终点地址
+            origin_city: 起点城市
+            destination_city: 终点城市
+            route_type: 路线类型 (walking/driving/transit)
+
+        Returns:
+            路线信息字典,包含 distance(米)、duration(秒)、type、segments
+        """
+        try:
+            tool_map = {
+                "walking": "maps_direction_walking_by_address",
+                "driving": "maps_direction_driving_by_address",
+                "transit": "maps_direction_transit_integrated_by_address"
+            }
+            tool_name = tool_map.get(route_type, "maps_direction_walking_by_address")
+
+            arguments = {
+                "origin_address": origin_address,
+                "destination_address": destination_address
+            }
+            if origin_city:
+                arguments["origin_city"] = origin_city
+            if destination_city:
+                arguments["destination_city"] = destination_city
+
+            result = self.mcp_tool.run({
+                "action": "call_tool",
+                "tool_name": tool_name,
+                "arguments": arguments
+            })
+
+            parsed = self._parse_route_response(result, route_type)
+            if not parsed:
+                print(f"  ⚠️ 路线({route_type})返回空: {result[:150]}")
+            else:
+                print(f"  ✅ 路线({route_type})成功: {parsed.get('distance',0)}m, {parsed.get('duration',0)}s")
+            return parsed
+
+        except Exception as e:
+            print(f"❌ 路线规划失败: {str(e)}")
+            import traceback
+            traceback.print_exc()
+            return {}
+
+    def _parse_python_repr(self, text: str) -> Optional[Dict]:
+        """amap-mcp-server 返回的是 Python repr(单引号),尝试解析"""
+        import ast
+        try:
+            result = ast.literal_eval(text)
+            if isinstance(result, dict):
+                # 递归将键名统一为str
+                return json.loads(json.dumps(result))
+            return None
+        except Exception:
+            return None
+
+    def _parse_route_response(self, result: str, route_type: str) -> Dict[str, Any]:
+        """解析MCP路线返回结果为统一格式"""
+        json_match = re.search(r'\{.*\}', result, re.DOTALL)
+        if not json_match:
+            return {}
+
+        raw_text = json_match.group()
+        data = None
+        # 先尝试标准 JSON
+        try:
+            data = json.loads(raw_text)
+        except json.JSONDecodeError:
+            # 再尝试 Python repr (单引号)
+            data = self._parse_python_repr(raw_text)
+
+        if not data:
+            print(f"  ⚠️ 无法解析路线返回数据, 前100字符: {raw_text[:100]}")
+            return {}
+
+        route = data.get("route", data)
+        info = {"distance": 0, "duration": 0, "type": route_type, "segments": []}
+
+        if route_type == "transit":
+            transits = route.get("transits", [])
+            if transits:
+                transit = transits[0]
+                info["duration"] = self._safe_int(
+                    transit.get("cost", {}).get("duration", "0")
+                )
+                for seg in transit.get("segments", []):
+                    info["segments"].extend(
+                        self._parse_transit_segment(seg)
+                    )
+        else:
+            paths = route.get("paths", [])
+            if paths:
+                path = paths[0]
+                info["distance"] = self._safe_int(path.get("distance", "0"))
+                info["duration"] = self._safe_int(path.get("duration", "0"))
+                steps = path.get("steps", [])
+                for step in steps:
+                    info["segments"].append({
+                        "instruction": step.get("instruction", ""),
+                        "distance": self._safe_int(step.get("distance", "0")),
+                        "duration": self._safe_int(step.get("duration", "0")),
+                    })
+                if not steps:
+                    info["segments"].append({
+                        "instruction": f"从起点到终点",
+                        "distance": info["distance"],
+                        "duration": info["duration"],
+                    })
+
+        return info
+
+    def _parse_transit_segment(self, seg: Dict) -> List[Dict]:
+        """解析公共交通的一个分段"""
+        segments = []
+        if "walking" in seg:
+            walk = seg["walking"]
+            instr = "步行"
+            if walk.get("steps"):
+                instr = walk["steps"][0].get("instruction", "步行")
+            segments.append({
+                "instruction": instr,
+                "distance": self._safe_int(walk.get("distance", "0")),
+                "duration": self._safe_int(walk.get("duration", "0")),
+            })
+        if "bus" in seg:
+            bus = seg["bus"]
+            buslines = bus.get("buslines", [])
+            if buslines:
+                bl = buslines[0]
+                segments.append(self._make_vehicle_segment(bl, "公交"))
+        if "subway" in seg:
+            subway = seg["subway"]
+            subwaylines = subway.get("subwaylines", [])
+            if subwaylines:
+                sl = subwaylines[0]
+                segments.append(self._make_vehicle_segment(sl, "地铁"))
+        return segments
+
+    def _make_vehicle_segment(self, line: Dict, mode: str) -> Dict:
+        """生成交通工具分段"""
+        pass_num = line.get("pass_stop_num", "0")
+        return {
+            "instruction": f"乘坐{line.get('name', mode)}",
+            "distance": self._safe_int(line.get("distance", "0")),
+            "duration": self._safe_int(line.get("duration", "0")),
+            "route_detail": f"经过{pass_num}站",
+            "departure_stop": line.get("departure_stop", {}).get("name", ""),
+            "arrival_stop": line.get("arrival_stop", {}).get("name", ""),
+        }
+
+    @staticmethod
+    def _safe_int(value: Any) -> int:
+        """安全转int"""
+        if isinstance(value, (int, float)):
+            return int(value)
+        try:
+            return int(float(str(value).replace(",", "")))
+        except (ValueError, TypeError):
+            return 0
+
+    def get_route_segments(
+        self,
+        origin_address: str,
+        destination_address: str,
+        origin_name: str = "",
+        destination_name: str = "",
+        origin_city: Optional[str] = None,
+        destination_city: Optional[str] = None,
+        route_type: str = "transit"
+    ) -> List[Dict]:
+        """
+        获取两点之间的交通分段信息,格式化为TransportSegment兼容的字典
+
+        Args:
+            origin_address: 起点地址
+            destination_address: 终点地址
+            origin_name: 起点名称(如酒店名/景点名)
+            destination_name: 终点名称
+            origin_city: 起点城市
+            destination_city: 终点城市
+            route_type: walking/driving/transit
+
+        Returns:
+            List[Dict], 每段包含 type/instruction/from_name/to_name/duration/distance/route_detail
+        """
+        raw = self.plan_route(
+            origin_address=origin_address,
+            destination_address=destination_address,
+            origin_city=origin_city,
+            destination_city=destination_city,
+            route_type=route_type
+        )
+        if not raw:
+            return []
+
+        type_map = {
+            "walking": "步行",
+            "driving": "自驾",
+            "transit": "公共交通",
+        }
+        segments = raw.get("segments", [])
+        result = []
+        base_minutes = 0
+
+        if not segments and raw.get("distance", 0) > 0:
+            total_dist = raw.get("distance", 0)
+            total_dur = max(1, raw.get("duration", 0) // 60)
+            hour = 8 + base_minutes // 60
+            minute = base_minutes % 60
+
+            route_type_cn = type_map.get(route_type, "公共交通")
+            result.append({
+                "type": route_type_cn,
+                "instruction": f"从{origin_name or origin_address}前往{destination_name or destination_address}",
+                "from_name": origin_name or origin_address,
+                "to_name": destination_name or destination_address,
+                "departure_time": f"{hour:02d}:{minute:02d}",
+                "duration": total_dur,
+                "distance": total_dist,
+                "route_detail": f"总距离约{round(total_dist / 1000, 1)}公里" if total_dist >= 1000 else f"总距离{total_dist}米",
+            })
+        else:
+            for seg in segments:
+                dur_min = max(1, seg.get("duration", 0) // 60)
+                dist = seg.get("distance", 0)
+                current_minutes = base_minutes
+                hour = 8 + current_minutes // 60
+                minute = current_minutes % 60
+
+                instruction = seg.get("instruction", "")
+                route_detail = seg.get("route_detail", "")
+
+                # 判断交通类型
+                instr_lower = instruction.lower()
+                if "步行" in instruction or route_type == "walking":
+                    seg_type = "步行"
+                elif "公交" in instruction or "bus" in instr_lower:
+                    seg_type = "公交"
+                elif "地铁" in instruction or "subway" in instr_lower:
+                    seg_type = "地铁"
+                elif route_type == "driving":
+                    seg_type = "自驾"
+                else:
+                    seg_type = "公共交通"
+
+                dep_stop = seg.get("departure_stop", "")
+                arr_stop = seg.get("arrival_stop", "")
+                full_instruction = instruction
+                if dep_stop and arr_stop:
+                    full_instruction = f"从{dep_stop}出发,{instruction}到{arr_stop}"
+
+                # 起点/终点名称
+                seg_from = origin_name
+                if result:
+                    seg_from = dep_stop or origin_name
+                seg_to = destination_name
+                if seg != segments[-1]:
+                    seg_to = arr_stop or destination_name
+
+                result.append({
+                    "type": seg_type,
+                    "instruction": full_instruction,
+                    "from_name": seg_from,
+                    "to_name": seg_to,
+                    "departure_time": f"{hour:02d}:{minute:02d}",
+                    "duration": dur_min,
+                    "distance": dist,
+                    "route_detail": route_detail or (f"约{dist}米" if dist else ""),
+                })
+
+                base_minutes += dur_min
+
+        return result
+
+    def get_route_via_http(
+        self,
+        origin_address: str,
+        destination_address: str,
+        origin_name: str = "",
+        destination_name: str = "",
+        origin_city: Optional[str] = None,
+        destination_city: Optional[str] = None,
+        route_type: str = "transit"
+    ) -> List[Dict]:
+        """
+        通过高德HTTP API直接获取路线(绕过MCP子进程,更快更稳定)
+
+        Returns:
+            List[Dict], 同 get_route_segments 格式
+        """
+        import urllib.request, urllib.parse
+        from ..config import get_settings
+
+        settings = get_settings()
+        if not settings.amap_api_key:
+            return []
+
+        city = origin_city or ""
+
+        # origin/destination 先尝试地理编码
+        origin_lng, origin_lat = self._geocode_sync(origin_address, city)
+        dest_lng, dest_lat = self._geocode_sync(destination_address, city)
+        if not origin_lng or not dest_lng:
+            return []
+
+        try:
+            if route_type == "transit":
+                params = urllib.parse.urlencode({
+                    "key": settings.amap_api_key,
+                    "origin": f"{origin_lng},{origin_lat}",
+                    "destination": f"{dest_lng},{dest_lat}",
+                    "city": city,
+                    "cityd": city,
+                }, encoding="utf-8")
+                url = f"https://restapi.amap.com/v3/direction/transit/integrated?{params}"
+            elif route_type == "walking":
+                params = urllib.parse.urlencode({
+                    "key": settings.amap_api_key,
+                    "origin": f"{origin_lng},{origin_lat}",
+                    "destination": f"{dest_lng},{dest_lat}",
+                }, encoding="utf-8")
+                url = f"https://restapi.amap.com/v3/direction/walking?{params}"
+            elif route_type == "driving":
+                params = urllib.parse.urlencode({
+                    "key": settings.amap_api_key,
+                    "origin": f"{origin_lng},{origin_lat}",
+                    "destination": f"{dest_lng},{dest_lat}",
+                    "city": city,
+                }, encoding="utf-8")
+                url = f"https://restapi.amap.com/v3/direction/driving?{params}"
+            else:
+                return []
+
+            resp = urllib.request.urlopen(url, timeout=10)
+            data = json.loads(resp.read().decode("utf-8"))
+
+            if data.get("status") != "1":
+                return []
+
+            segments = []
+            base_minutes = 0
+
+            if route_type == "transit":
+                route = data.get("route", {})
+                transits = route.get("transits", [])
+                if not transits:
+                    return []
+                transit = transits[0]
+                total_dur = self._safe_int(transit.get("duration", "0"))
+                total_dist = self._safe_int(transit.get("distance", "0"))
+
+                # 预检: 如果只有步行段且总距离>500m,返回空让调用者降级
+                has_vehicle = any("bus" in seg or "subway" in seg for seg in transit.get("segments", []))
+                total_walk_dist = sum(
+                    self._safe_int(seg["walking"].get("distance", "0"))
+                    for seg in transit.get("segments", []) if "walking" in seg
+                )
+                if not has_vehicle and total_walk_dist > 500:
+                    return []  # 全程步行且距离过长,触发调用方降级
+
+                for seg in transit.get("segments", []):
+                    dur_min = max(1, self._safe_int(seg.get("duration", "0")) // 60)
+                    dist = self._safe_int(seg.get("distance", "0"))
+                    hour = 8 + base_minutes // 60
+                    minute = base_minutes % 60
+
+                    if "walking" in seg:
+                        walk = seg["walking"]
+                        walk_dist = self._safe_int(walk.get("distance", "0"))
+                        walk_dur = max(1, self._safe_int(walk.get("duration", "0")) // 60)
+                        instruction = f"步行{walk_dist}米"
+                        if walk_dist > 500 and has_vehicle:
+                            instruction += "(步行距离较长,建议共享单车)"
+                        elif walk_dist > 500:
+                            instruction += "(距离较长,建议乘车)"
+                        segments.append({
+                            "type": "步行",
+                            "instruction": instruction,
+                            "from_name": origin_name if not segments else origin_name,
+                            "to_name": destination_name,
+                            "departure_time": f"{hour:02d}:{minute:02d}",
+                            "duration": walk_dur,
+                            "distance": walk_dist,
+                            "route_detail": f"步行{walk_dist}米",
+                        })
+                    elif "bus" in seg:
+                        for bl in seg["bus"].get("buslines", []):
+                            dep_stop = bl.get("departure_stop", {}).get("name", "")
+                            arr_stop = bl.get("arrival_stop", {}).get("name", "")
+                            pass_num = bl.get("pass_stop_num", "0")
+                            segments.append({
+                                "type": "公交",
+                                "instruction": f"乘坐{bl.get('name', '公交')}",
+                                "from_name": f"{dep_stop}" if dep_stop else origin_name,
+                                "to_name": f"{arr_stop}" if arr_stop else destination_name,
+                                "departure_time": f"{hour:02d}:{minute:02d}",
+                                "duration": max(1, self._safe_int(bl.get("duration", "0")) // 60),
+                                "distance": self._safe_int(bl.get("distance", "0")),
+                                "route_detail": f"{bl.get('name', '')}·经过{pass_num}站",
+                            })
+                    elif "subway" in seg:
+                        for sl in seg["subway"].get("subwaylines", []):
+                            dep_stop = sl.get("departure_stop", {}).get("name", "")
+                            arr_stop = sl.get("arrival_stop", {}).get("name", "")
+                            pass_num = sl.get("pass_stop_num", "0")
+                            segments.append({
+                                "type": "地铁",
+                                "instruction": f"乘坐{sl.get('name', '地铁')}",
+                                "from_name": f"{dep_stop}" if dep_stop else origin_name,
+                                "to_name": f"{arr_stop}" if arr_stop else destination_name,
+                                "departure_time": f"{hour:02d}:{minute:02d}",
+                                "duration": max(1, self._safe_int(sl.get("duration", "0")) // 60),
+                                "distance": self._safe_int(sl.get("distance", "0")),
+                                "route_detail": f"{sl.get('name', '')}·经过{pass_num}站",
+                            })
+                    base_minutes += dur_min
+
+                if not segments:
+                    # 只有总数据,生成一个整体段
+                    segments.append({
+                        "type": "公共交通",
+                        "instruction": f"从{origin_name or origin_address}到{destination_name or destination_address}",
+                        "from_name": origin_name or origin_address,
+                        "to_name": destination_name or destination_address,
+                        "departure_time": "08:00",
+                        "duration": max(1, total_dur // 60),
+                        "distance": total_dist,
+                        "route_detail": f"约{round(total_dist/1000,1)}公里",
+                    })
+            else:
+                # walking/driving
+                route = data.get("route", {})
+                paths = route.get("paths", [])
+                if paths:
+                    path = paths[0]
+                    total_dist = self._safe_int(path.get("distance", "0"))
+                    total_dur = self._safe_int(path.get("duration", "0"))
+                    road_type_cn = "步行" if route_type == "walking" else "自驾"
+                    segments.append({
+                        "type": road_type_cn,
+                        "instruction": f"从{origin_name or origin_address}到{destination_name or destination_address}",
+                        "from_name": origin_name or origin_address,
+                        "to_name": destination_name or destination_address,
+                        "departure_time": "08:00",
+                        "duration": max(1, total_dur // 60),
+                        "distance": total_dist,
+                        "route_detail": f"约{round(total_dist/1000,1)}公里",
+                    })
+
+            return segments
+
+        except Exception as e:
+            print(f"  ⚠️ HTTP路线({route_type})失败: {e}")
+            return []
+
+    def _geocode_sync(self, address: str, city: str) -> tuple:
+        """同步地理编码,返回 (lng, lat)"""
+        import urllib.request, urllib.parse
+        from ..config import get_settings
+
+        try:
+            params = urllib.parse.urlencode({
+                "key": get_settings().amap_api_key,
+                "address": address,
+                "city": city,
+            }, encoding="utf-8")
+            url = f"https://restapi.amap.com/v3/geocode/geo?{params}"
+            resp = urllib.request.urlopen(url, timeout=10)
+            data = json.loads(resp.read().decode("utf-8"))
+            if data.get("status") == "1" and data.get("geocodes"):
+                loc = data["geocodes"][0].get("location", "")
+                if loc and "," in loc:
+                    parts = loc.split(",")
+                    return parts[0], parts[1]
+        except Exception:
+            pass
+        return None, None
+
+    def geocode(self, address: str, city: Optional[str] = None) -> Optional[Location]:
+        """
+        地理编码(地址转坐标)
+
+        Args:
+            address: 地址
+            city: 城市
+
+        Returns:
+            经纬度坐标
+        """
+        try:
+            arguments = {"address": address}
+            if city:
+                arguments["city"] = city
+
+            result = self.mcp_tool.run({
+                "action": "call_tool",
+                "tool_name": "maps_geo",
+                "arguments": arguments
+            })
+
+            print(f"地理编码结果: {result[:200]}...")
+
+            # TODO: 解析实际的坐标数据
+            return None
+
+        except Exception as e:
+            print(f"❌ 地理编码失败: {str(e)}")
+            return None
+
+    def get_poi_detail(self, poi_id: str) -> Dict[str, Any]:
+        """
+        获取POI详情
+
+        Args:
+            poi_id: POI ID
+
+        Returns:
+            POI详情信息
+        """
+        try:
+            result = self.mcp_tool.run({
+                "action": "call_tool",
+                "tool_name": "maps_search_detail",
+                "arguments": {
+                    "id": poi_id
+                }
+            })
+
+            print(f"POI详情结果: {result[:200]}...")
+
+            json_match = re.search(r'\{.*\}', result, re.DOTALL)
+            if json_match:
+                data = json.loads(json_match.group())
+                return data
+
+            return {"raw": result}
+
+        except Exception as e:
+            print(f"❌ 获取POI详情失败: {str(e)}")
+            return {}
+
+
+# 创建全局服务实例
+_amap_service = None
+
+
+def get_amap_service() -> AmapService:
+    """获取高德地图服务实例(单例模式)"""
+    global _amap_service
+
+    if _amap_service is None:
+        _amap_service = AmapService()
+
+    return _amap_service

+ 36 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/services/llm_service.py

@@ -0,0 +1,36 @@
+"""LLM服务模块"""
+
+from hello_agents import HelloAgentsLLM
+from ..config import get_settings
+
+# 全局LLM实例
+_llm_instance = None
+
+
+def get_llm() -> HelloAgentsLLM:
+    """
+    获取LLM实例(单例模式)
+    
+    Returns:
+        HelloAgentsLLM实例
+    """
+    global _llm_instance
+    
+    if _llm_instance is None:
+        settings = get_settings()
+        
+        # HelloAgentsLLM会自动从环境变量读取配置
+        # 包括OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL等
+        _llm_instance = HelloAgentsLLM()
+        
+        print(f"✅ LLM服务初始化成功")
+        print(f"   模型: {_llm_instance.model}")
+    
+    return _llm_instance
+
+
+def reset_llm():
+    """重置LLM实例(用于测试或重新配置)"""
+    global _llm_instance
+    _llm_instance = None
+

+ 102 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/services/travel_chat_service.py

@@ -0,0 +1,102 @@
+"""旅游AI对话服务 - 专用于回答旅游相关问题的LLM Agent"""
+
+from typing import Iterator, List
+from ..services.llm_service import get_llm
+
+# 系统提示词 - 严格限定只回答旅游相关问题
+TRAVEL_AGENT_PROMPT = """你是"Trip Planner Pro"智能旅行助手的AI旅游顾问,一个专业、热情、细致的旅行规划专家。
+
+## 你的角色
+你只回答与**旅游、旅行、出行**相关的问题。包括但不限于:
+1. 🌍 **目的地推荐** - 根据预算、季节、人群推荐旅行目的地
+2. 🏛️ **景点介绍** - 景点历史、文化、特色、开放时间、门票信息
+3. 🍜 **美食推荐** - 各地特色美食、餐厅推荐、饮食文化
+4. 🏨 **住宿建议** - 酒店、民宿、青旅推荐和预订建议
+5. 🚗 **交通指南** - 到达方式、当地交通、路线规划建议
+6. 🌤️ **旅行贴士** - 最佳旅行季节、穿衣建议、注意事项
+7. 📋 **行程规划建议** - 天数安排、路线组合、节奏把控
+8. 💰 **预算参考** - 旅行费用估算、省钱技巧
+9. 🛡️ **安全提示** - 旅行安全、健康建议、保险信息
+10. 🎒 **行前准备** - 行李清单、证件准备、实用APP推荐
+
+## 回答规则
+1. 只回答与旅游/旅行/出行明确相关的问题。
+2. 如果用户提出非旅游相关的问题(如编程、数学、政治、医疗建议等),请礼貌地拒绝,并引导回到旅游话题。
+3. 回答要详细、实用、有温度,提供具体的建议而不是笼统的概括。
+4. 可以结合你对中国各地旅游资源的了解来回答。
+5. 当用户提到具体城市时,可以结合该城市的特色来推荐。
+6. 如果用户的问题比较宽泛,可以主动追问细节(预算、天数、人群等)来提供更有针对性的建议。
+7. 回答不要提及你是AI或大模型,用"我"来指代自己。
+8. 回答使用中文,保持友好热情的语调。
+
+## 非旅游问题的拒绝模板
+当用户问非旅游问题时,请这样回复:
+"抱歉,我是专门为您提供旅行建议的AI助手,只能回答与旅游出行相关的问题。如果您有任何旅行方面的疑问,比如目的地推荐、行程规划、景点介绍等,我都很乐意为您解答!😊"
+
+## 语气风格
+- 热情友好,像一个经验丰富的旅行达人
+- 回答要有结构,适当使用emoji
+- 给出具体可操作的建议
+- 如果信息不确定,诚实告知并提供查证建议
+"""
+
+
+class TravelChatService:
+    """旅游AI对话服务"""
+
+    def __init__(self):
+        self.llm = get_llm()
+
+    def _build_messages(self, user_message: str, history: list = None, profile_message: str = "") -> List[dict]:
+        """构建带上下文的对话消息列表"""
+        messages = [{"role": "system", "content": TRAVEL_AGENT_PROMPT}]
+
+        # 如果有用户画像消息,作为首条用户消息加入(使用 XML 标签标注)
+        if profile_message:
+            messages.append({"role": "user", "content": profile_message})
+
+        # 添加历史上下文(取最近20条消息)
+        if history:
+            for msg in history[-20:]:
+                role = msg.get("role", "user")
+                content = msg.get("content", "")
+                if role in ("user", "assistant"):
+                    messages.append({"role": role, "content": content})
+
+        # 添加当前用户消息
+        messages.append({"role": "user", "content": user_message})
+        return messages
+
+    def chat(self, user_message: str, history: list = None, profile_message: str = "") -> str:
+        """
+        非流式调用:发送消息给旅游AI并获取回复
+        """
+        messages = self._build_messages(user_message, history, profile_message)
+        try:
+            response = self.llm.invoke(messages=messages)
+            return response.content if hasattr(response, 'content') else str(response)
+        except Exception as e:
+            raise RuntimeError(f"AI对话服务调用失败: {str(e)}")
+
+    def chat_stream(self, user_message: str, history: list = None, profile_message: str = "") -> Iterator[str]:
+        """
+        流式调用:逐块获取AI回复
+        """
+        messages = self._build_messages(user_message, history, profile_message)
+        try:
+            for chunk in self.llm.think(messages=messages):
+                yield chunk
+        except Exception as e:
+            raise RuntimeError(f"AI对话流式调用失败: {str(e)}")
+
+
+# 全局实例
+_travel_chat_service = None
+
+
+def get_travel_chat_service() -> TravelChatService:
+    """获取旅游对话服务实例(单例)"""
+    global _travel_chat_service
+    if _travel_chat_service is None:
+        _travel_chat_service = TravelChatService()
+    return _travel_chat_service

+ 86 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/services/unsplash_service.py

@@ -0,0 +1,86 @@
+"""Unsplash图片服务"""
+
+import requests
+from typing import List, Optional
+from ..config import get_settings
+
+class UnsplashService:
+    """Unsplash图片服务类"""
+    
+    def __init__(self):
+        """初始化服务"""
+        settings = get_settings()
+        self.access_key = settings.unsplash_access_key
+        self.base_url = "https://api.unsplash.com"
+    
+    def search_photos(self, query: str, per_page: int = 5) -> List[dict]:
+        """
+        搜索图片
+        
+        Args:
+            query: 搜索关键词
+            per_page: 每页数量
+            
+        Returns:
+            图片列表
+        """
+        try:
+            url = f"{self.base_url}/search/photos"
+            params = {
+                "query": query,
+                "per_page": per_page,
+                "client_id": self.access_key
+            }
+            
+            response = requests.get(url, params=params, timeout=10)
+            response.raise_for_status()
+            
+            data = response.json()
+            results = data.get("results", [])
+            
+            # 提取图片URL
+            photos = []
+            for photo in results:
+                photos.append({
+                    "id": photo.get("id"),
+                    "url": photo.get("urls", {}).get("regular"),
+                    "thumb": photo.get("urls", {}).get("thumb"),
+                    "description": photo.get("description") or photo.get("alt_description"),
+                    "photographer": photo.get("user", {}).get("name")
+                })
+            
+            return photos
+            
+        except Exception as e:
+            print(f"❌ Unsplash搜索失败: {str(e)}")
+            return []
+    
+    def get_photo_url(self, query: str) -> Optional[str]:
+        """
+        获取单张图片URL
+
+        Args:
+            query: 搜索关键词
+
+        Returns:
+            图片URL
+        """
+        photos = self.search_photos(query, per_page=1)
+        if photos:
+            return photos[0].get("url")
+        return None
+
+
+# 全局服务实例
+_unsplash_service = None
+
+
+def get_unsplash_service() -> UnsplashService:
+    """获取Unsplash服务实例(单例模式)"""
+    global _unsplash_service
+    
+    if _unsplash_service is None:
+        _unsplash_service = UnsplashService()
+    
+    return _unsplash_service
+

+ 338 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/services/user_profile_service.py

@@ -0,0 +1,338 @@
+"""用户画像服务 - 从对话中提取用户偏好并持久化到本地md文件
+
+内存缓存策略(参考 Claude Code 记忆模式):
+- 首次读取后缓存到 _profile_cache,避免重复磁盘 I/O
+- 通过文件 mtime 检测外部修改,自动刷新缓存
+- 写入时同时更新缓存和文件,保证读写一致性
+- 每条用户画像使用 frontmatter 记录元数据(更新时间、来源会话)
+
+画像提取逻辑委托给 ProfileExtractionAgent 子代理执行,
+而非直接调用 LLM,保持多智能体架构一致性。
+"""
+
+import time
+from pathlib import Path
+from typing import Optional
+from ..agents.profile_extraction_agent import ProfileExtractionAgent
+from ..services.llm_service import get_llm
+from ..database import get_db
+
+# 用户画像存储目录
+PROFILES_DIR = Path(__file__).parent.parent.parent / "user_profiles"
+
+# 内存缓存:user_id -> (profile_text, mtime, cached_at)
+# mtime:文件最后修改时间(用于检测外部修改)
+# cached_at:缓存写入时间(用于 TTL 过期)
+_profile_cache: dict[int, tuple[str, float, float]] = {}
+
+# 会话级快照缓存:session_id -> context_text
+# 同一场对话内首条消息固话,后续消息复用,保证 LLM prompt cache 命中
+_session_snapshot_cache: dict[int, str] = {}
+
+# 缓存 TTL:300 秒(5 分钟内认为缓存新鲜,无需 stat 文件)
+_CACHE_TTL = 300
+
+# 画像提取 Agent 全局实例(惰性初始化)
+_profile_extraction_agent: Optional[ProfileExtractionAgent] = None
+
+
+def _ensure_profiles_dir():
+    """确保画像目录存在"""
+    PROFILES_DIR.mkdir(parents=True, exist_ok=True)
+
+
+def _profile_path(user_id: int) -> Path:
+    """获取用户画像文件路径"""
+    return PROFILES_DIR / f"user_{user_id}.md"
+
+
+def _read_file_with_frontmatter(path: Path) -> tuple[str, str]:
+    """
+    读取 md 文件,分离 frontmatter 和正文
+    返回: (frontmatter_yaml, body)
+    无 frontmatter 时 frontmatter 返回空字符串
+    """
+    if not path.exists():
+        return "", ""
+
+    content = path.read_text(encoding="utf-8").strip()
+
+    if content.startswith("---"):
+        parts = content.split("---", 2)
+        if len(parts) >= 3:
+            frontmatter = parts[1].strip()
+            body = parts[2].strip()
+            return frontmatter, body
+
+    return "", content
+
+
+def _build_frontmatter(user_id: int) -> str:
+    """构建 YAML frontmatter"""
+    now = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
+    return (
+        f"---\n"
+        f"user_id: {user_id}\n"
+        f"updated_at: '{now}'\n"
+        f"---"
+    )
+
+
+def get_profile_extraction_agent() -> ProfileExtractionAgent:
+    """获取画像提取 Agent 实例(单例,惰性初始化)"""
+    global _profile_extraction_agent
+    if _profile_extraction_agent is None:
+        llm = get_llm()
+        _profile_extraction_agent = ProfileExtractionAgent(llm)
+        print(f"  ✅ 用户画像提取 Agent 初始化成功")
+    return _profile_extraction_agent
+
+
+def _invalidate_cache(user_id: int):
+    """清除指定用户的缓存"""
+    _profile_cache.pop(user_id, None)
+
+
+def _refresh_from_disk(user_id: int) -> str:
+    """从磁盘加载用户画像正文(跳过 frontmatter),更新缓存"""
+    path = _profile_path(user_id)
+    if not path.exists():
+        _profile_cache[user_id] = ("", 0.0, time.time())
+        return ""
+
+    mtime = path.stat().st_mtime
+    _, body = _read_file_with_frontmatter(path)
+    _profile_cache[user_id] = (body, mtime, time.time())
+    return body
+
+
+def load_profile_text(user_id: int) -> str:
+    """
+    加载用户画像文本(带内存缓存)
+
+    缓存策略:
+    1. 缓存命中且未超过 TTL → 直接返回
+    2. 缓存命中但超过 TTL → stat 检查文件 mtime,未变则续期缓存
+    3. 缓存未命中或文件已变 → 重新从磁盘读取
+
+    Returns:
+        用户画像正文(仅 "- " 开头的条目行),不存在则返回空字符串
+    """
+    path = _profile_path(user_id)
+    cached = _profile_cache.get(user_id)
+    now = time.time()
+
+    if cached is not None:
+        body, mtime, cached_at = cached
+
+        # TTL 内:直接返回缓存
+        if now - cached_at < _CACHE_TTL:
+            return body
+
+        # TTL 已过:检查文件 mtime
+        if path.exists():
+            current_mtime = path.stat().st_mtime
+            if current_mtime == mtime:
+                # 文件未变,续期缓存
+                _profile_cache[user_id] = (body, mtime, now)
+                return body
+
+    # 缓存失效或文件变更,从磁盘重新加载
+    return _refresh_from_disk(user_id)
+
+
+def save_profile(user_id: int, profile_text: str):
+    """
+    保存用户画像到 md 文件(frontmatter + 正文)
+
+    格式:
+    ---
+    user_id: 1
+    updated_at: '2026-06-04 12:00:00'
+    ---
+    # 用户旅行画像
+    - 条目1
+    - 条目2
+    """
+    _ensure_profiles_dir()
+    frontmatter = _build_frontmatter(user_id)
+    content = f"{frontmatter}\n\n# 用户旅行画像\n\n{profile_text}\n"
+    path = _profile_path(user_id)
+
+    # 先写磁盘,再更新缓存(保证缓存与磁盘一致)
+    path.write_text(content, encoding="utf-8")
+    mtime = path.stat().st_mtime
+
+    # 只缓存有效条目行作为正文
+    lines = [l for l in profile_text.split("\n") if l.strip().startswith("- ")]
+    body = "\n".join(lines)
+    _profile_cache[user_id] = (body, mtime, time.time())
+
+
+def extract_and_update_profile(
+    user_id: int,
+    user_message: str,
+    history: Optional[list] = None,
+    cross_session_context: str = "",
+):
+    """
+    从用户消息中提取偏好,与现有画像对比合并(冲突时以最新为准),然后更新保存
+
+    与旧版的关键区别:
+    1. 支持传入跨会话上下文(cross_session_context),让 LLM 能理解
+       用户在其他会话中表达过的偏好,避免将长期偏好误判为一次性信息
+    2. 内存缓存:每次提取后自动更新缓存,后续 load 直接命中
+
+    Args:
+        user_id: 用户ID
+        user_message: 用户发送的消息
+        history: 当前会话的最近消息列表(用于理解上下文)
+        cross_session_context: 跨会话上下文文本(来自其他会话的消息摘要)
+    """
+    user_msg = user_message.strip()
+    # 太短或纯语气词,跳过
+    if len(user_msg) < 3:
+        return
+
+    # 加载现有画像(走缓存)
+    existing = load_profile_text(user_id)
+
+    # 构建对话上下文
+    context_parts = []
+
+    # 优先注入跨会话上下文
+    if cross_session_context:
+        context_parts.append("=== 历史会话摘要 ===\n" + cross_session_context)
+
+    # 当前会话的最近消息作为细粒度上下文
+    if history:
+        recent = history[-6:]  # 最近 3 轮对话(最多 6 条)
+        context_parts.append("=== 当前会话 ===")
+        for msg in recent:
+            role = msg.get("role", "")
+            content = msg.get("content", "")
+            if role == "user":
+                context_parts.append(f"用户:{content[:150]}")
+            elif role == "assistant":
+                context_parts.append(f"助手:{content[:150]}")
+
+    conversation_context = "\n".join(context_parts) if context_parts else "(无)"
+
+    try:
+        agent = get_profile_extraction_agent()
+        new_profile = agent.extract(
+            existing_profile=existing,
+            conversation_context=conversation_context,
+            user_message=user_msg[:300],
+        )
+
+        if not new_profile:
+            return
+
+        # 解析有效条目
+        lines = []
+        for line in new_profile.split("\n"):
+            line = line.strip()
+            if line.startswith("- ") and len(line) > 3:
+                lines.append(line)
+
+        if lines:
+            save_profile(user_id, "\n".join(lines))
+            print(f"  ✅ 用户 {user_id} 画像更新成功 ({len(lines)} 条)")
+    except Exception as e:
+        print(f"  ⚠️ 用户画像提取失败: {e}")
+
+
+def get_profile_context(user_id: int, session_id: int = None) -> str:
+    """
+    获取用户画像上下文文本(用于注入到系统提示词)
+
+    支持会话级快照:传入 session_id 后,同一场对话内首条消息固话画像字符串,
+    后续消息无论画像如何更新都复用该字符串,保证 LLM prompt cache 不变。
+
+    缓存层级(从快到慢):
+    session snapshot → memory cache → disk
+
+    Args:
+        user_id: 用户ID
+        session_id: 可选,会话ID。传入后启用会话级快照。
+
+    Returns:
+        格式化的画像上下文,如果不存在则返回空字符串
+    """
+    # 1. 会话级快照命中 → 直接返回(零开销)
+    if session_id is not None and session_id in _session_snapshot_cache:
+        return _session_snapshot_cache[session_id]
+
+    # 2. 加载画像(走内存缓存 → disk)
+    profile = load_profile_text(user_id)
+    if not profile:
+        return ""
+
+    context = (
+        f"\n## 关于用户\n"
+        f"根据过往对话,我了解到该用户的一些偏好:\n{profile}\n"
+        f"**注意:用户当前的问题/要求始终优先于历史偏好。"
+        f"如果用户现在的说法与历史偏好矛盾,以用户现在说的为准。**\n"
+    )
+
+    # 3. 固话到会话级快照(后续同一 session 不再变动)
+    if session_id is not None:
+        _session_snapshot_cache[session_id] = context
+
+    return context
+
+
+def get_cross_session_context(user_id: int, max_sessions: int = 5, max_messages: int = 6) -> str:
+    """
+    获取用户跨会话的近期消息摘要(用于提取画像时的跨会话上下文)
+
+    查询该用户最近 N 个会话的前几条消息,拼接为纯文本返回。
+    这些文本不用于注入系统提示词,仅作为提取画像时的参考上下文。
+
+    Args:
+        user_id: 用户ID
+        max_sessions: 最多取多少个会话
+        max_messages: 每个会话最多取多少条消息
+
+    Returns:
+        格式化的跨会话上下文文本
+    """
+    conn = get_db()
+    try:
+        # 获取用户最近的会话
+        sessions = conn.execute(
+            """SELECT id, title, created_at FROM chat_sessions
+               WHERE user_id = ?
+               ORDER BY updated_at DESC LIMIT ?""",
+            (user_id, max_sessions)
+        ).fetchall()
+
+        if not sessions:
+            return ""
+
+        parts = []
+        for sess in sessions:
+            sess_id = sess["id"]
+            # 每个会话取前几条消息
+            messages = conn.execute(
+                """SELECT role, content FROM chat_messages
+                   WHERE session_id = ?
+                   ORDER BY id ASC LIMIT ?""",
+                (sess_id, max_messages)
+            ).fetchall()
+
+            if messages:
+                msg_text = []
+                for msg in messages:
+                    role_label = "用户" if msg["role"] == "user" else "助手"
+                    content = msg["content"][:100]
+                    msg_text.append(f"  {role_label}:{content}")
+                parts.append(
+                    f"【会话 {sess_id}】\n" + "\n".join(msg_text)
+                )
+
+        return "\n\n".join(parts) if parts else ""
+
+    finally:
+        conn.close()

+ 129 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/app/user_context.py

@@ -0,0 +1,129 @@
+"""
+请求级用户上下文 — Python contextvars 实现(等价于 Java ThreadLocal)
+
+中间件在每个请求开始时解析 JWT(Cookie 或 Authorization Header),
+将用户信息存入 ContextVar,后续任何位置通过 get_current_user() 即可获取,
+无需显式传参或重复解码 JWT。
+
+支持两种认证方式 + 自动续期:
+1. Cookie:   access_token(浏览器,HttpOnly 自动携带)
+2. Header:   Authorization: Bearer <token> + X-Refresh-Token(非浏览器设备)
+3. 自动续期:非浏览器设备 access_token 过期时,中间件自动用 refresh_token 换新
+"""
+from contextvars import ContextVar
+import jwt
+from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.requests import Request
+from .jwt_utils import (
+    verify_access_token, verify_refresh_token,
+    create_access_token, create_refresh_token,
+)
+from .redis_service import (
+    validate_refresh_token, revoke_refresh_token, store_refresh_token,
+)
+from .database import get_user_by_id
+
+# 核心:ContextVar 像 ThreadLocal,但兼容 asyncio
+# 每个请求只能看见自己的那份,请求之间完全隔离
+_current_user_var: ContextVar[dict | None] = ContextVar("current_user", default=None)
+
+
+def get_current_user() -> dict | None:
+    """
+    获取当前登录用户。
+    返回值: {"id": int, "username": str} 或 None(未登录时)
+    无需 Request 参数,像全局变量一样调用。
+    """
+    return _current_user_var.get()
+
+
+def _extract_token(request: Request) -> str:
+    """
+    从请求中提取 JWT Token,优先级:
+    1. Authorization: Bearer <token>(非浏览器设备)
+    2. Cookie: access_token(浏览器)
+    """
+    # 1. 检查 Authorization Header
+    auth_header = request.headers.get("Authorization", "")
+    if auth_header.startswith("Bearer "):
+        return auth_header[7:].strip()
+
+    # 2. 回退到 Cookie
+    return request.cookies.get("access_token", "")
+
+
+def _try_auto_refresh(request: Request) -> tuple:
+    """
+    当 access_token 过期时,尝试用 X-Refresh-Token 自动续期。
+    返回: (user_dict, new_access_token, new_refresh_token) 或 (None, None, None)
+    """
+    refresh_token = request.headers.get("X-Refresh-Token", "")
+    if not refresh_token:
+        return None, None, None
+
+    try:
+        payload = verify_refresh_token(refresh_token)
+        stored = validate_refresh_token(payload["jti"])
+        if stored is None or stored["user_id"] != payload["id"]:
+            return None, None, None
+
+        # 设备校验(非浏览器设备可能无 User-Agent,不阻塞)
+        current_ua = request.headers.get("User-Agent", "")
+        if stored.get("user_agent") and current_ua and stored["user_agent"] != current_ua:
+            return None, None, None
+
+        # 吊销旧 Token,签发新 Token
+        revoke_refresh_token(payload["jti"])
+        new_access = create_access_token(payload["id"])
+        new_refresh, new_jti = create_refresh_token(payload["id"])
+        store_refresh_token(payload["id"], new_jti, user_agent=current_ua)
+
+        # 查用户信息
+        user_info = get_user_by_id(payload["id"])
+        if user_info:
+            return {"id": user_info["id"], "username": user_info["username"]}, new_access, new_refresh
+    except Exception:
+        pass
+
+    return None, None, None
+
+
+class UserContextMiddleware(BaseHTTPMiddleware):
+    """
+    FastAPI 中间件 — 自动解析 access_token(Cookie 或 Header),
+    注入当前用户到上下文。access_token 过期时自动续期(非浏览器设备)。
+    """
+
+    async def dispatch(self, request: Request, call_next):
+        user = None
+        new_access_token = None
+        new_refresh_token = None
+        token = _extract_token(request)
+
+        if token:
+            try:
+                payload = verify_access_token(token)
+                user_info = get_user_by_id(payload["id"])
+                if user_info:
+                    user = {"id": user_info["id"], "username": user_info["username"]}
+            except jwt.ExpiredSignatureError:
+                # 过期 → 用 X-Refresh-Token 自动续期(对客户端透明)
+                user, new_access_token, new_refresh_token = _try_auto_refresh(request)
+            except Exception:
+                pass  # token 无效 → user 为 None,后续路由自行处理 401
+
+        # 把当前用户 "set" 进上下文,类似 ThreadLocal.set()
+        ctx_token = _current_user_var.set(user)
+        try:
+            response = await call_next(request)
+
+            # 自动续期成功 → 通过响应头把新 Token 带回客户端
+            if new_access_token:
+                response.headers["X-Access-Token"] = new_access_token
+            if new_refresh_token:
+                response.headers["X-Refresh-Token"] = new_refresh_token
+
+            return response
+        finally:
+            # 请求结束必须 reset,防止上下文泄漏到下一个请求
+            _current_user_var.reset(ctx_token)

BIN
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/data/trip_planner.db


+ 32 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/requirements.txt

@@ -0,0 +1,32 @@
+# HelloAgents框架 【修复:匹配项目1.0.2版本,删除无效protocols】
+#hello-agents==1.0.2
+
+# FastAPI和相关依赖
+fastapi>=0.115.0
+uvicorn[standard]>=0.32.0
+pydantic>=2.0.0
+pydantic-settings>=2.0.0
+
+# HTTP客户端
+httpx>=0.27.0
+aiohttp>=3.10.0
+
+# 环境变量管理
+python-dotenv>=1.0.0
+
+# CORS支持
+python-multipart>=0.0.9
+
+# 日志
+loguru>=0.7.0
+
+# RSA非对称加密(前端密码传输)
+cryptography>=41.0.0
+
+# MCP相关
+fastmcp>=2.0.0
+uv>=0.8.0
+
+# 其他工具
+python-dateutil>=2.8.2
+huggingface_hub>=0.25.0

+ 28 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/run.py

@@ -0,0 +1,28 @@
+"""启动脚本"""
+
+import sys
+sys.path.append(r"D:\learn-agent\hello-agents-1.0.2")
+import uvicorn
+from app.config import get_settings
+
+if __name__ == "__main__":
+    settings = get_settings()
+
+    # SSL配置(路径自动基于 backend/ 目录解析)
+    ssl_kwargs = {}
+    if settings.ssl_enabled:
+        ssl_kwargs["ssl_certfile"] = settings.get_ssl_certfile()
+        ssl_kwargs["ssl_keyfile"] = settings.get_ssl_keyfile()
+
+    protocol = "https" if settings.ssl_enabled else "http"
+    print(f"\n🔒 协议: {protocol.upper()}")
+
+    uvicorn.run(
+        "app.api.main:app",
+        host=settings.host,
+        port=settings.port,
+        reload=True,
+        log_level=settings.log_level.lower(),
+        **ssl_kwargs
+    )
+

+ 24 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/scripts/generate_certs.bat

@@ -0,0 +1,24 @@
+@echo off
+REM 生成自签名SSL证书用于本地HTTPS开发
+REM 使用前请确保OpenSSL已安装
+
+set CERT_DIR=%~dp0..\certs
+
+if not exist "%CERT_DIR%" mkdir "%CERT_DIR%"
+
+echo Generating self-signed SSL certificates...
+openssl req -x509 -newkey rsa:2048 -keyout "%CERT_DIR%\key.pem" -out "%CERT_DIR%\cert.pem" -days 365 -nodes -subj "//CN=localhost"
+
+if %ERRORLEVEL% EQU 0 (
+    echo.
+    echo ✅ SSL certificates generated successfully!
+    echo   Cert: %CERT_DIR%\cert.pem
+    echo   Key:  %CERT_DIR%\key.pem
+    echo.
+    echo To enable HTTPS, set in backend\.env:
+    echo   SSL_ENABLED=true
+) else (
+    echo.
+    echo ❌ Failed to generate certificates.
+    echo Please make sure OpenSSL is installed and available in PATH.
+)

+ 4 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/user_profiles/user_1.md

@@ -0,0 +1,4 @@
+# 用户旅行画像
+
+- 喜欢人多的地方
+- 喜欢自然风景好的地方

+ 5 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/backend/user_profiles/user_3.md

@@ -0,0 +1,5 @@
+# 用户旅行画像
+
+- 不喜欢人多的地方
+- 不喜欢商业化严重的景点
+- 喜欢自然风景

+ 7 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/.env.example

@@ -0,0 +1,7 @@
+# 后端API地址(HTTPS)
+VITE_API_BASE_URL=https://localhost:8000
+
+# 高德地图Web API Key
+VITE_AMAP_WEB_KEY=your_amap_web_api_key_here
+# 高德地图Web端JS API Key
+VITE_AMAP_WEB_JS_KEY=your_amap_web_js_key_here

+ 29 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/.gitignore

@@ -0,0 +1,29 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# Environment
+.env.local
+.env.*.local
+

+ 14 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/index.html

@@ -0,0 +1,14 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>HelloAgents智能旅行助手</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>
+

+ 2244 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/package-lock.json

@@ -0,0 +1,2244 @@
+{
+  "name": "helloagents-trip-planner-frontend",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "helloagents-trip-planner-frontend",
+      "version": "1.0.0",
+      "dependencies": {
+        "@amap/amap-jsapi-loader": "^1.0.1",
+        "ant-design-vue": "^4.2.6",
+        "axios": "^1.7.9",
+        "html2canvas": "^1.4.1",
+        "jspdf": "^3.0.3",
+        "vue": "^3.5.13",
+        "vue-router": "^4.5.0"
+      },
+      "devDependencies": {
+        "@types/node": "^22.10.5",
+        "@vitejs/plugin-vue": "^5.2.1",
+        "typescript": "^5.7.3",
+        "vite": "^6.0.7",
+        "vue-tsc": "^2.2.0"
+      }
+    },
+    "node_modules/@amap/amap-jsapi-loader": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@amap/amap-jsapi-loader/-/amap-jsapi-loader-1.0.1.tgz",
+      "integrity": "sha512-nPyLKt7Ow/ThHLkSvn2etQlUzqxmTVgK7bIgwdBRTg2HK5668oN7xVxkaiRe3YZEzGzfV2XgH5Jmu2T73ljejw==",
+      "license": "MIT"
+    },
+    "node_modules/@ant-design/colors": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-6.0.0.tgz",
+      "integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@ctrl/tinycolor": "^3.4.0"
+      }
+    },
+    "node_modules/@ant-design/icons-svg": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz",
+      "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==",
+      "license": "MIT"
+    },
+    "node_modules/@ant-design/icons-vue": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/@ant-design/icons-vue/-/icons-vue-7.0.1.tgz",
+      "integrity": "sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/colors": "^6.0.0",
+        "@ant-design/icons-svg": "^4.2.1"
+      },
+      "peerDependencies": {
+        "vue": ">=3.0.3"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
+      "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.28.4",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
+      "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.28.4"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/runtime": {
+      "version": "7.28.4",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
+      "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.28.4",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
+      "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@ctrl/tinycolor": {
+      "version": "3.6.1",
+      "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
+      "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/@emotion/hash": {
+      "version": "0.9.2",
+      "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
+      "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
+      "license": "MIT"
+    },
+    "node_modules/@emotion/unitless": {
+      "version": "0.8.1",
+      "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz",
+      "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==",
+      "license": "MIT"
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz",
+      "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz",
+      "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz",
+      "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz",
+      "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz",
+      "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz",
+      "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz",
+      "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz",
+      "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz",
+      "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz",
+      "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz",
+      "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz",
+      "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz",
+      "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz",
+      "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz",
+      "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz",
+      "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz",
+      "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz",
+      "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz",
+      "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz",
+      "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz",
+      "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz",
+      "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz",
+      "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz",
+      "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz",
+      "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz",
+      "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "license": "MIT"
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz",
+      "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz",
+      "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz",
+      "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz",
+      "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz",
+      "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz",
+      "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz",
+      "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz",
+      "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz",
+      "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz",
+      "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz",
+      "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz",
+      "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz",
+      "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz",
+      "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz",
+      "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz",
+      "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz",
+      "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz",
+      "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz",
+      "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz",
+      "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz",
+      "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz",
+      "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@simonwep/pickr": {
+      "version": "1.8.2",
+      "resolved": "https://registry.npmjs.org/@simonwep/pickr/-/pickr-1.8.2.tgz",
+      "integrity": "sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-js": "^3.15.1",
+        "nanopop": "^2.1.0"
+      }
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "22.18.10",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.10.tgz",
+      "integrity": "sha512-anNG/V/Efn/YZY4pRzbACnKxNKoBng2VTFydVu8RRs5hQjikP8CQfaeAV59VFSCzKNp90mXiVXW2QzV56rwMrg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@types/pako": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
+      "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
+      "license": "MIT"
+    },
+    "node_modules/@types/raf": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
+      "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/@types/trusted-types": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+      "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "5.2.4",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+      "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^5.0.0 || ^6.0.0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@volar/language-core": {
+      "version": "2.4.15",
+      "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
+      "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/source-map": "2.4.15"
+      }
+    },
+    "node_modules/@volar/source-map": {
+      "version": "2.4.15",
+      "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz",
+      "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@volar/typescript": {
+      "version": "2.4.15",
+      "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz",
+      "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/language-core": "2.4.15",
+        "path-browserify": "^1.0.1",
+        "vscode-uri": "^3.0.8"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.22",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.22.tgz",
+      "integrity": "sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.28.4",
+        "@vue/shared": "3.5.22",
+        "entities": "^4.5.0",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.22",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz",
+      "integrity": "sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.22",
+        "@vue/shared": "3.5.22"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.22",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
+      "integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.28.4",
+        "@vue/compiler-core": "3.5.22",
+        "@vue/compiler-dom": "3.5.22",
+        "@vue/compiler-ssr": "3.5.22",
+        "@vue/shared": "3.5.22",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.19",
+        "postcss": "^8.5.6",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.22",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.22.tgz",
+      "integrity": "sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.22",
+        "@vue/shared": "3.5.22"
+      }
+    },
+    "node_modules/@vue/compiler-vue2": {
+      "version": "2.7.16",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
+      "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "de-indent": "^1.0.2",
+        "he": "^1.2.0"
+      }
+    },
+    "node_modules/@vue/devtools-api": {
+      "version": "6.6.4",
+      "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+      "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+      "license": "MIT"
+    },
+    "node_modules/@vue/language-core": {
+      "version": "2.2.12",
+      "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz",
+      "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/language-core": "2.4.15",
+        "@vue/compiler-dom": "^3.5.0",
+        "@vue/compiler-vue2": "^2.7.16",
+        "@vue/shared": "^3.5.0",
+        "alien-signals": "^1.0.3",
+        "minimatch": "^9.0.3",
+        "muggle-string": "^0.4.1",
+        "path-browserify": "^1.0.1"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.22",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz",
+      "integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/shared": "3.5.22"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.22",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz",
+      "integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.22",
+        "@vue/shared": "3.5.22"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.22",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz",
+      "integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.22",
+        "@vue/runtime-core": "3.5.22",
+        "@vue/shared": "3.5.22",
+        "csstype": "^3.1.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.22",
+      "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz",
+      "integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.22",
+        "@vue/shared": "3.5.22"
+      },
+      "peerDependencies": {
+        "vue": "3.5.22"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.22",
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.22.tgz",
+      "integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==",
+      "license": "MIT"
+    },
+    "node_modules/alien-signals": {
+      "version": "1.0.13",
+      "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
+      "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/ant-design-vue": {
+      "version": "4.2.6",
+      "resolved": "https://registry.npmjs.org/ant-design-vue/-/ant-design-vue-4.2.6.tgz",
+      "integrity": "sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/colors": "^6.0.0",
+        "@ant-design/icons-vue": "^7.0.0",
+        "@babel/runtime": "^7.10.5",
+        "@ctrl/tinycolor": "^3.5.0",
+        "@emotion/hash": "^0.9.0",
+        "@emotion/unitless": "^0.8.0",
+        "@simonwep/pickr": "~1.8.0",
+        "array-tree-filter": "^2.1.0",
+        "async-validator": "^4.0.0",
+        "csstype": "^3.1.1",
+        "dayjs": "^1.10.5",
+        "dom-align": "^1.12.1",
+        "dom-scroll-into-view": "^2.0.0",
+        "lodash": "^4.17.21",
+        "lodash-es": "^4.17.15",
+        "resize-observer-polyfill": "^1.5.1",
+        "scroll-into-view-if-needed": "^2.2.25",
+        "shallow-equal": "^1.0.0",
+        "stylis": "^4.1.3",
+        "throttle-debounce": "^5.0.0",
+        "vue-types": "^3.0.0",
+        "warning": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=12.22.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/ant-design-vue"
+      },
+      "peerDependencies": {
+        "vue": ">=3.2.0"
+      }
+    },
+    "node_modules/array-tree-filter": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz",
+      "integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==",
+      "license": "MIT"
+    },
+    "node_modules/async-validator": {
+      "version": "4.2.5",
+      "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
+      "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
+      "license": "MIT"
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "license": "MIT"
+    },
+    "node_modules/axios": {
+      "version": "1.12.2",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
+      "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
+      "license": "MIT",
+      "dependencies": {
+        "follow-redirects": "^1.15.6",
+        "form-data": "^4.0.4",
+        "proxy-from-env": "^1.1.0"
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/base64-arraybuffer": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
+      "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6.0"
+      }
+    },
+    "node_modules/brace-expansion": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/canvg": {
+      "version": "3.0.11",
+      "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
+      "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@babel/runtime": "^7.12.5",
+        "@types/raf": "^3.4.0",
+        "core-js": "^3.8.3",
+        "raf": "^3.4.1",
+        "regenerator-runtime": "^0.13.7",
+        "rgbcolor": "^1.0.1",
+        "stackblur-canvas": "^2.0.0",
+        "svg-pathdata": "^6.0.3"
+      },
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/compute-scroll-into-view": {
+      "version": "1.0.20",
+      "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
+      "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==",
+      "license": "MIT"
+    },
+    "node_modules/core-js": {
+      "version": "3.46.0",
+      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz",
+      "integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/core-js"
+      }
+    },
+    "node_modules/css-line-break": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
+      "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
+      "license": "MIT",
+      "dependencies": {
+        "utrie": "^1.0.2"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+      "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+      "license": "MIT"
+    },
+    "node_modules/dayjs": {
+      "version": "1.11.18",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
+      "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
+      "license": "MIT"
+    },
+    "node_modules/de-indent": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
+      "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/dom-align": {
+      "version": "1.12.4",
+      "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz",
+      "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==",
+      "license": "MIT"
+    },
+    "node_modules/dom-scroll-into-view": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/dom-scroll-into-view/-/dom-scroll-into-view-2.0.1.tgz",
+      "integrity": "sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==",
+      "license": "MIT"
+    },
+    "node_modules/dompurify": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
+      "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
+      "license": "(MPL-2.0 OR Apache-2.0)",
+      "optional": true,
+      "optionalDependencies": {
+        "@types/trusted-types": "^2.0.7"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
+      "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.25.10",
+        "@esbuild/android-arm": "0.25.10",
+        "@esbuild/android-arm64": "0.25.10",
+        "@esbuild/android-x64": "0.25.10",
+        "@esbuild/darwin-arm64": "0.25.10",
+        "@esbuild/darwin-x64": "0.25.10",
+        "@esbuild/freebsd-arm64": "0.25.10",
+        "@esbuild/freebsd-x64": "0.25.10",
+        "@esbuild/linux-arm": "0.25.10",
+        "@esbuild/linux-arm64": "0.25.10",
+        "@esbuild/linux-ia32": "0.25.10",
+        "@esbuild/linux-loong64": "0.25.10",
+        "@esbuild/linux-mips64el": "0.25.10",
+        "@esbuild/linux-ppc64": "0.25.10",
+        "@esbuild/linux-riscv64": "0.25.10",
+        "@esbuild/linux-s390x": "0.25.10",
+        "@esbuild/linux-x64": "0.25.10",
+        "@esbuild/netbsd-arm64": "0.25.10",
+        "@esbuild/netbsd-x64": "0.25.10",
+        "@esbuild/openbsd-arm64": "0.25.10",
+        "@esbuild/openbsd-x64": "0.25.10",
+        "@esbuild/openharmony-arm64": "0.25.10",
+        "@esbuild/sunos-x64": "0.25.10",
+        "@esbuild/win32-arm64": "0.25.10",
+        "@esbuild/win32-ia32": "0.25.10",
+        "@esbuild/win32-x64": "0.25.10"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+      "license": "MIT"
+    },
+    "node_modules/fast-png": {
+      "version": "6.4.0",
+      "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
+      "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/pako": "^2.0.3",
+        "iobuffer": "^5.3.2",
+        "pako": "^2.1.0"
+      }
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fflate": {
+      "version": "0.8.2",
+      "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+      "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+      "license": "MIT"
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.15.11",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+      "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
+      "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/he": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+      "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "he": "bin/he"
+      }
+    },
+    "node_modules/html2canvas": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
+      "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
+      "license": "MIT",
+      "dependencies": {
+        "css-line-break": "^2.1.0",
+        "text-segmentation": "^1.0.3"
+      },
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "node_modules/iobuffer": {
+      "version": "5.4.0",
+      "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
+      "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
+      "license": "MIT"
+    },
+    "node_modules/is-plain-object": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz",
+      "integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "license": "MIT"
+    },
+    "node_modules/jspdf": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.3.tgz",
+      "integrity": "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.26.9",
+        "fast-png": "^6.2.0",
+        "fflate": "^0.8.1"
+      },
+      "optionalDependencies": {
+        "canvg": "^3.0.11",
+        "core-js": "^3.6.0",
+        "dompurify": "^3.2.4",
+        "html2canvas": "^1.0.0-rc.5"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+      "license": "MIT"
+    },
+    "node_modules/lodash-es": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
+      "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
+      "license": "MIT"
+    },
+    "node_modules/loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "license": "MIT",
+      "dependencies": {
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      },
+      "bin": {
+        "loose-envify": "cli.js"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.19",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
+      "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "9.0.5",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/muggle-string": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
+      "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/nanopop": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/nanopop/-/nanopop-2.4.2.tgz",
+      "integrity": "sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw==",
+      "license": "MIT"
+    },
+    "node_modules/pako": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
+      "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
+      "license": "(MIT AND Zlib)"
+    },
+    "node_modules/path-browserify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+      "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/performance-now": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+      "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+      "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.6",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+      "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+      "license": "MIT"
+    },
+    "node_modules/raf": {
+      "version": "3.4.1",
+      "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
+      "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "performance-now": "^2.1.0"
+      }
+    },
+    "node_modules/regenerator-runtime": {
+      "version": "0.13.11",
+      "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+      "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/resize-observer-polyfill": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
+      "license": "MIT"
+    },
+    "node_modules/rgbcolor": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
+      "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
+      "license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
+      "optional": true,
+      "engines": {
+        "node": ">= 0.8.15"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
+      "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.52.4",
+        "@rollup/rollup-android-arm64": "4.52.4",
+        "@rollup/rollup-darwin-arm64": "4.52.4",
+        "@rollup/rollup-darwin-x64": "4.52.4",
+        "@rollup/rollup-freebsd-arm64": "4.52.4",
+        "@rollup/rollup-freebsd-x64": "4.52.4",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.52.4",
+        "@rollup/rollup-linux-arm-musleabihf": "4.52.4",
+        "@rollup/rollup-linux-arm64-gnu": "4.52.4",
+        "@rollup/rollup-linux-arm64-musl": "4.52.4",
+        "@rollup/rollup-linux-loong64-gnu": "4.52.4",
+        "@rollup/rollup-linux-ppc64-gnu": "4.52.4",
+        "@rollup/rollup-linux-riscv64-gnu": "4.52.4",
+        "@rollup/rollup-linux-riscv64-musl": "4.52.4",
+        "@rollup/rollup-linux-s390x-gnu": "4.52.4",
+        "@rollup/rollup-linux-x64-gnu": "4.52.4",
+        "@rollup/rollup-linux-x64-musl": "4.52.4",
+        "@rollup/rollup-openharmony-arm64": "4.52.4",
+        "@rollup/rollup-win32-arm64-msvc": "4.52.4",
+        "@rollup/rollup-win32-ia32-msvc": "4.52.4",
+        "@rollup/rollup-win32-x64-gnu": "4.52.4",
+        "@rollup/rollup-win32-x64-msvc": "4.52.4",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/scroll-into-view-if-needed": {
+      "version": "2.2.31",
+      "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
+      "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
+      "license": "MIT",
+      "dependencies": {
+        "compute-scroll-into-view": "^1.0.20"
+      }
+    },
+    "node_modules/shallow-equal": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz",
+      "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==",
+      "license": "MIT"
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/stackblur-canvas": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
+      "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=0.1.14"
+      }
+    },
+    "node_modules/stylis": {
+      "version": "4.3.6",
+      "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
+      "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
+      "license": "MIT"
+    },
+    "node_modules/svg-pathdata": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
+      "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/text-segmentation": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
+      "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
+      "license": "MIT",
+      "dependencies": {
+        "utrie": "^1.0.2"
+      }
+    },
+    "node_modules/throttle-debounce": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
+      "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.22"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.15",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+      "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "devOptional": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/utrie": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
+      "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
+      "license": "MIT",
+      "dependencies": {
+        "base64-arraybuffer": "^1.0.2"
+      }
+    },
+    "node_modules/vite": {
+      "version": "6.3.6",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
+      "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.25.0",
+        "fdir": "^6.4.4",
+        "picomatch": "^4.0.2",
+        "postcss": "^8.5.3",
+        "rollup": "^4.34.9",
+        "tinyglobby": "^0.2.13"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+        "jiti": ">=1.21.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vscode-uri": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
+      "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/vue": {
+      "version": "3.5.22",
+      "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
+      "integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.22",
+        "@vue/compiler-sfc": "3.5.22",
+        "@vue/runtime-dom": "3.5.22",
+        "@vue/server-renderer": "3.5.22",
+        "@vue/shared": "3.5.22"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-router": {
+      "version": "4.5.1",
+      "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
+      "integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "vue": "^3.2.0"
+      }
+    },
+    "node_modules/vue-tsc": {
+      "version": "2.2.12",
+      "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz",
+      "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/typescript": "2.4.15",
+        "@vue/language-core": "2.2.12"
+      },
+      "bin": {
+        "vue-tsc": "bin/vue-tsc.js"
+      },
+      "peerDependencies": {
+        "typescript": ">=5.0.0"
+      }
+    },
+    "node_modules/vue-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/vue-types/-/vue-types-3.0.2.tgz",
+      "integrity": "sha512-IwUC0Aq2zwaXqy74h4WCvFCUtoV0iSWr0snWnE9TnU18S66GAQyqQbRf2qfJtUuiFsBf6qp0MEwdonlwznlcrw==",
+      "license": "MIT",
+      "dependencies": {
+        "is-plain-object": "3.0.1"
+      },
+      "engines": {
+        "node": ">=10.15.0"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.0"
+      }
+    },
+    "node_modules/warning": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+      "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.0.0"
+      }
+    }
+  }
+}

+ 27 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/package.json

@@ -0,0 +1,27 @@
+{
+  "name": "helloagents-trip-planner-frontend",
+  "private": true,
+  "version": "1.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vue-tsc && vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@amap/amap-jsapi-loader": "^1.0.1",
+    "ant-design-vue": "^4.2.6",
+    "axios": "^1.7.9",
+    "html2canvas": "^1.4.1",
+    "jspdf": "^3.0.3",
+    "vue": "^3.5.13",
+    "vue-router": "^4.5.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.10.5",
+    "@vitejs/plugin-vue": "^5.2.1",
+    "typescript": "^5.7.3",
+    "vite": "^6.0.7",
+    "vue-tsc": "^2.2.0"
+  }
+}

+ 105 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/src/App.vue

@@ -0,0 +1,105 @@
+<template>
+  <div id="app">
+    <a-layout style="min-height: 100vh">
+      <a-layout-header style="background: #001529; padding: 0 50px; display: flex; align-items: center; justify-content: space-between;">
+        <div style="color: white; font-size: 24px; font-weight: bold; cursor: pointer;" @click="goHome">
+          🌍 HelloAgents智能旅行助手
+        </div>
+        <div style="display: flex; align-items: center; gap: 12px;">
+          <a-button v-if="isLoggedIn" type="text" style="color: white;" @click="goHistory">
+            📋 历史记录
+          </a-button>
+          <a-button v-if="isLoggedIn" type="text" style="color: white;" @click="goChat">
+            💬 AI对话
+          </a-button>
+          <span v-if="isLoggedIn" style="color: rgba(255,255,255,0.65);">👤 {{ username }}</span>
+          <a-button v-if="!isLoggedIn" type="primary" ghost @click="goLogin">登录</a-button>
+          <a-button v-else type="text" style="color: rgba(255,255,255,0.65);" @click="handleLogout">退出</a-button>
+        </div>
+      </a-layout-header>
+      <a-layout-content style="padding: 24px">
+        <router-view />
+      </a-layout-content>
+      <a-layout-footer style="text-align: center">
+        HelloAgents智能旅行助手 ©2025 基于HelloAgents框架
+      </a-layout-footer>
+    </a-layout>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { message } from 'ant-design-vue'
+
+const router = useRouter()
+const isLoggedIn = ref(false)
+const username = ref('')
+
+/** 从 document.cookie 读取用户名(由后端设置,非 HttpOnly,零请求) */
+function getUsernameFromCookie(): string | null {
+  const match = document.cookie.match(/(?:^|;\s*)auth_username=([^;]*)/)
+  return match ? decodeURIComponent(match[1]) : null
+}
+
+function checkAuth() {
+  // 直接从 Cookie 读取,无需调 /api/auth/profile 接口
+  const name = getUsernameFromCookie()
+  isLoggedIn.value = !!name
+  username.value = name || ''
+}
+
+// 每次路由切换时检查 Cookie
+router.afterEach(() => {
+  checkAuth()
+})
+
+function goHome() {
+  router.push('/')
+}
+
+function goLogin() {
+  router.push('/login')
+}
+
+function goHistory() {
+  if (!isLoggedIn.value) {
+    message.warning('请先登录')
+    router.push('/login')
+    return
+  }
+  router.push('/history')
+}
+
+function goChat() {
+  if (!isLoggedIn.value) {
+    message.warning('请先登录')
+    router.push('/login')
+    return
+  }
+  router.push('/chat')
+}
+
+async function handleLogout() {
+  try {
+    await fetch(`${import.meta.env.VITE_API_BASE_URL || 'https://localhost:8000'}/api/auth/logout`, {
+      method: 'POST',
+      credentials: 'include',
+    })
+  } catch {}
+  localStorage.removeItem('auth_username')
+  isLoggedIn.value = false
+  username.value = ''
+  message.success('已退出登录')
+  router.push('/')
+}
+
+onMounted(checkAuth)
+</script>
+
+<style>
+#app {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
+    'Noto Sans', sans-serif;
+}
+</style>

+ 49 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/src/main.ts

@@ -0,0 +1,49 @@
+import { createApp } from 'vue'
+import { createRouter, createWebHistory } from 'vue-router'
+import Antd from 'ant-design-vue'
+import 'ant-design-vue/dist/reset.css'
+import App from './App.vue'
+import Home from './views/Home.vue'
+import Result from './views/Result.vue'
+import Login from './views/Login.vue'
+import History from './views/History.vue'
+import Chat from './views/Chat.vue'
+
+const router = createRouter({
+  history: createWebHistory(),
+  routes: [
+    {
+      path: '/',
+      name: 'Home',
+      component: Home
+    },
+    {
+      path: '/result',
+      name: 'Result',
+      component: Result
+    },
+    {
+      path: '/login',
+      name: 'Login',
+      component: Login
+    },
+    {
+      path: '/history',
+      name: 'History',
+      component: History
+    },
+    {
+      path: '/chat',
+      name: 'Chat',
+      component: Chat
+    }
+  ]
+})
+
+const app = createApp(App)
+
+app.use(router)
+app.use(Antd)
+
+app.mount('#app')
+

+ 155 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/src/services/api.ts

@@ -0,0 +1,155 @@
+import axios from 'axios'
+import type { TripFormData, TripPlanResponse } from '@/types'
+
+const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://localhost:8000'
+
+/**
+ * 导出 API 基础 URL,供各视图中的 fetch() 调用使用
+ */
+export const apiBaseUrl: string = API_BASE_URL
+
+/** 后端通过 HttpOnly Cookie 做认证,axios 需附带 cookie */
+const apiClient = axios.create({
+  baseURL: API_BASE_URL,
+  timeout: 300000,
+  withCredentials: true,
+  headers: { 'Content-Type': 'application/json' },
+})
+
+// 共享 Refresh Promise:多个 401 同时到来时共用一个刷新请求
+let _refreshPromise: Promise<boolean> | null = null
+
+/** 刷新Token(Cookie自动携带refresh_token) */
+async function tryRefresh(): Promise<boolean> {
+  try {
+    const res = await axios.post(`${API_BASE_URL}/api/auth/refresh`, {}, {
+      withCredentials: true,
+    })
+    return res.status === 200
+  } catch {
+    return false
+  }
+}
+
+/** 加锁执行刷新,同时段多个调用共享同一个 Promise */
+function acquireRefreshLock(): Promise<boolean> {
+  // 锁已被持有 → 返回同一个 promise,不发起新刷新
+  if (_refreshPromise) return _refreshPromise
+
+  // 加锁:创建新的刷新 promise,完成后自动释放锁
+  _refreshPromise = tryRefresh().finally(() => {
+    _refreshPromise = null
+  })
+  return _refreshPromise
+}
+
+/** 响应拦截器 — 401 自动刷新重试(刷新过程加锁,无竞态) */
+apiClient.interceptors.response.use(
+  (response) => response,
+  async (error) => {
+    const originalRequest = error.config
+    if (error.response?.status !== 401 || originalRequest._retry) {
+      return Promise.reject(error)
+    }
+    originalRequest._retry = true
+
+    // 获取刷新锁:第一个请求创建并持有锁(发起刷新)
+    // 后续请求共享同一把锁(等刷新完成),不会重复发起
+    const success = await acquireRefreshLock()
+
+    if (success) {
+      return apiClient(originalRequest)
+    }
+
+    // 刷新彻底失败 → 跳登录
+    window.location.href = '/login'
+    return Promise.reject(error)
+  }
+)
+
+
+/**
+ * 生成旅行计划
+ */
+export async function generateTripPlan(formData: TripFormData): Promise<TripPlanResponse> {
+  try {
+    const response = await apiClient.post<TripPlanResponse>('/api/trip/plan', formData)
+    return response.data
+  } catch (error: any) {
+    console.error('生成旅行计划失败:', error)
+    throw new Error(error.response?.data?.detail || error.message || '生成旅行计划失败')
+  }
+}
+
+/**
+ * 健康检查
+ */
+export async function healthCheck(): Promise<any> {
+  try {
+    const response = await apiClient.get('/health')
+    return response.data
+  } catch (error: any) {
+    console.error('健康检查失败:', error)
+    throw new Error(error.message || '健康检查失败')
+  }
+}
+
+// ============ 旅游AI对话 ============
+
+export interface ChatSession {
+  id: number
+  user_id: number
+  title: string
+  created_at: string
+  updated_at: string
+}
+
+export interface ChatMessage {
+  id: number
+  session_id: number
+  role: 'user' | 'assistant'
+  content: string
+  created_at: string
+}
+
+/**
+ * 获取会话列表
+ */
+export async function getChatSessions(): Promise<{ success: boolean; sessions: ChatSession[] }> {
+  const response = await apiClient.get('/api/chat/sessions')
+  return response.data
+}
+
+/**
+ * 创建新会话
+ */
+export async function createChatSession(): Promise<{ success: boolean; session: ChatSession }> {
+  const response = await apiClient.post('/api/chat/sessions')
+  return response.data
+}
+
+/**
+ * 删除会话
+ */
+export async function deleteChatSession(sessionId: number): Promise<{ success: boolean; message: string }> {
+  const response = await apiClient.delete(`/api/chat/sessions/${sessionId}`)
+  return response.data
+}
+
+/**
+ * 获取会话消息
+ */
+export async function getChatMessages(sessionId: number): Promise<{ success: boolean; messages: ChatMessage[] }> {
+  const response = await apiClient.get(`/api/chat/sessions/${sessionId}/messages`)
+  return response.data
+}
+
+/**
+ * 发送消息
+ */
+export async function sendChatMessage(sessionId: number, content: string): Promise<{ success: boolean; reply: string }> {
+  const response = await apiClient.post(`/api/chat/sessions/${sessionId}/messages`, { content })
+  return response.data
+}
+
+export default apiClient

+ 88 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/src/services/crypto.ts

@@ -0,0 +1,88 @@
+/**
+ * RSA 加密工具模块
+ * 使用浏览器 Web Crypto API 进行 RSA-OAEP 加密
+ */
+
+let _publicKeyCache: CryptoKey | null = null
+let _publicKeyPemCache: string | null = null
+
+/**
+ * 从后端获取 RSA 公钥(PEM 格式,带缓存)
+ */
+export async function fetchPublicKey(): Promise<string> {
+  if (_publicKeyPemCache) return _publicKeyPemCache
+
+  const baseUrl = import.meta.env.VITE_API_BASE_URL || 'https://localhost:8000'
+  const res = await fetch(`${baseUrl}/api/auth/public-key`, {
+    credentials: 'include',
+  })
+  if (!res.ok) throw new Error('获取公钥失败')
+  const data = await res.json()
+  if (!data.success || !data.public_key) throw new Error('公钥数据异常')
+
+  _publicKeyPemCache = data.public_key
+  return data.public_key
+}
+
+/**
+ * 将 PEM 格式公钥导入为 Web Crypto API 的 CryptoKey
+ */
+function pemToCryptoKey(pem: string): Promise<CryptoKey> {
+  // 移除 PEM 头尾和换行
+  const pemHeader = '-----BEGIN PUBLIC KEY-----'
+  const pemFooter = '-----END PUBLIC KEY-----'
+  const pemContents = pem.substring(pemHeader.length, pem.indexOf(pemFooter))
+  const binaryDer = Uint8Array.from(atob(pemContents.replace(/\s/g, '')), c => c.charCodeAt(0))
+
+  return crypto.subtle.importKey(
+    'spki',
+    binaryDer.buffer,
+    {
+      name: 'RSA-OAEP',
+      hash: { name: 'SHA-256' },
+    },
+    false,
+    ['encrypt'],
+  )
+}
+
+/**
+ * 使用 RSA 公钥加密明文密码
+ * @param password 明文密码
+ * @param publicKeyPem PEM 格式的公钥(若不传则自动从后端获取)
+ * @returns Base64 编码的密文
+ */
+export async function rsaEncrypt(password: string, publicKeyPem?: string): Promise<string> {
+  if (!publicKeyPem) {
+    publicKeyPem = await fetchPublicKey()
+  }
+
+  // 缓存 CryptoKey 避免重复导入
+  if (!_publicKeyCache) {
+    _publicKeyCache = await pemToCryptoKey(publicKeyPem)
+  }
+
+  const encrypted = await crypto.subtle.encrypt(
+    {
+      name: 'RSA-OAEP',
+    },
+    _publicKeyCache,
+    new TextEncoder().encode(password),
+  )
+
+  // ArrayBuffer → Base64
+  const bytes = new Uint8Array(encrypted)
+  let binary = ''
+  for (let i = 0; i < bytes.length; i++) {
+    binary += String.fromCharCode(bytes[i])
+  }
+  return btoa(binary)
+}
+
+/**
+ * 清除缓存的公钥(用于测试或重新获取)
+ */
+export function clearPublicKeyCache() {
+  _publicKeyCache = null
+  _publicKeyPemCache = null
+}

+ 108 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/src/types/index.ts

@@ -0,0 +1,108 @@
+// 类型定义
+
+export interface Location {
+  longitude: number
+  latitude: number
+}
+
+export interface Attraction {
+  name: string
+  address: string
+  location: Location
+  visit_duration: number
+  description: string
+  category?: string
+  rating?: number
+  image_url?: string
+  ticket_price?: number
+}
+
+export interface Meal {
+  type: 'breakfast' | 'lunch' | 'dinner' | 'snack'
+  name: string
+  address?: string
+  location?: Location
+  description?: string
+  estimated_cost?: number
+}
+
+export interface Hotel {
+  name: string
+  address: string
+  location?: Location
+  price_range: string
+  rating: string
+  distance: string
+  type: string
+  estimated_cost?: number
+}
+
+export interface Budget {
+  total_attractions: number
+  total_hotels: number
+  total_meals: number
+  total_transportation: number
+  total: number
+}
+
+export interface TransportSegment {
+  type: string
+  instruction: string
+  from_name: string
+  to_name: string
+  departure_time: string
+  duration: number
+  distance: number
+  route_detail?: string
+}
+
+export interface DayPlan {
+  date: string
+  day_index: number
+  description: string
+  transportation: string
+  transportation_details: TransportSegment[]
+  accommodation: string
+  hotel?: Hotel
+  attractions: Attraction[]
+  meals: Meal[]
+}
+
+export interface WeatherInfo {
+  date: string
+  day_weather: string
+  night_weather: string
+  day_temp: number
+  night_temp: number
+  wind_direction: string
+  wind_power: string
+}
+
+export interface TripPlan {
+  city: string
+  start_date: string
+  end_date: string
+  days: DayPlan[]
+  weather_info: WeatherInfo[]
+  overall_suggestions: string
+  budget?: Budget
+}
+
+export interface TripFormData {
+  city: string
+  start_date: string
+  end_date: string
+  travel_days: number
+  transportation: string
+  accommodation: string
+  preferences: string[]
+  traveler_group: string
+  free_text_input: string
+}
+
+export interface TripPlanResponse {
+  success: boolean
+  message: string
+  data?: TripPlan
+}
+

+ 993 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/src/views/Chat.vue

@@ -0,0 +1,993 @@
+<template>
+  <div class="chat-container">
+    <!-- 侧边栏:会话列表 -->
+    <div class="chat-sidebar" :class="{ collapsed: sidebarCollapsed }">
+      <div class="sidebar-header">
+        <div class="sidebar-title" v-show="!sidebarCollapsed">
+          <span class="sidebar-icon">💬</span>
+          <span>旅游AI对话</span>
+        </div>
+        <a-button
+          type="primary"
+          class="new-chat-btn"
+          @click="createNewSession"
+          :title="sidebarCollapsed ? '新建对话' : ''"
+        >
+          <template #icon><PlusOutlined /></template>
+          <span v-if="!sidebarCollapsed">新建对话</span>
+        </a-button>
+      </div>
+
+      <div class="session-list">
+        <a-spin :spinning="sessionsLoading" size="small">
+          <a-empty
+            v-if="!sessionsLoading && sessions.length === 0"
+            :description="sidebarCollapsed ? '' : '暂无对话'"
+            style="color: rgba(255,255,255,0.5);"
+          />
+
+          <div
+            v-for="session in sessions"
+            :key="session.id"
+            class="session-item"
+            :class="{ active: currentSessionId === session.id }"
+            @click="switchSession(session.id)"
+          >
+            <div class="session-info" v-show="!sidebarCollapsed">
+              <div class="session-title">{{ session.title }}</div>
+              <div class="session-time">{{ formatTime(session.updated_at) }}</div>
+            </div>
+            <div class="session-actions" v-show="!sidebarCollapsed">
+              <a-popconfirm
+                title="确定删除这个对话?"
+                ok-text="确定"
+                cancel-text="取消"
+                @confirm="deleteSession(session.id)"
+              >
+                <a-button
+                  size="small"
+                  type="text"
+                  danger
+                  @click.stop
+                >
+                  <template #icon><DeleteOutlined /></template>
+                </a-button>
+              </a-popconfirm>
+            </div>
+            <!-- 折叠状态下只显示图标 -->
+            <div class="session-icon-collapsed" v-show="sidebarCollapsed">
+              💬
+            </div>
+          </div>
+        </a-spin>
+      </div>
+
+      <!-- 折叠/展开按钮 -->
+      <div class="sidebar-collapse-btn" @click="sidebarCollapsed = !sidebarCollapsed">
+        <MenuFoldOutlined v-if="!sidebarCollapsed" />
+        <MenuUnfoldOutlined v-else />
+      </div>
+    </div>
+
+    <!-- 主聊天区域 -->
+    <div class="chat-main">
+      <!-- 消息区域 -->
+      <div class="messages-container" ref="messagesContainer" @scroll="handleScroll">
+        <!-- 欢迎提示 -->
+        <div v-if="!currentSessionId" class="welcome-section">
+          <div class="welcome-icon">🌍</div>
+          <h2 class="welcome-title">旅游AI顾问</h2>
+          <p class="welcome-desc">我是您的专属旅行规划助手,可以帮您解答任何旅行问题!</p>
+          <div class="welcome-suggestions">
+            <div class="suggestion-item" @click="quickQuestion('去北京旅游有什么必去的景点?')">
+              <span class="suggestion-icon">🏛️</span>
+              <span>北京必去景点</span>
+            </div>
+            <div class="suggestion-item" @click="quickQuestion('两个人去三亚旅游3天大概需要多少钱?')">
+              <span class="suggestion-icon">💰</span>
+              <span>三亚3天预算</span>
+            </div>
+            <div class="suggestion-item" @click="quickQuestion('带孩子去上海迪士尼有什么注意事项?')">
+              <span class="suggestion-icon">🎢</span>
+              <span>迪士尼攻略</span>
+            </div>
+            <div class="suggestion-item" @click="quickQuestion('成都美食有哪些推荐?')">
+              <span class="suggestion-icon">🍜</span>
+              <span>成都美食推荐</span>
+            </div>
+          </div>
+        </div>
+
+        <!-- 消息列表 -->
+        <template v-if="currentSessionId">
+          <div
+            v-for="msg in messages"
+            :key="msg.id"
+            class="message-item"
+            :class="msg.role"
+          >
+            <div class="message-avatar">
+              {{ msg.role === 'user' ? '👤' : '🤖' }}
+            </div>
+            <div class="message-content">
+              <div class="message-bubble">
+                <div class="message-text">{{ msg.content }}</div>
+              </div>
+              <div class="message-time">{{ formatTime(msg.created_at) }}</div>
+            </div>
+          </div>
+
+          <!-- AI思考中 -->
+          <div v-if="isLoading" class="message-item assistant">
+            <div class="message-avatar">🤖</div>
+            <div class="message-content">
+              <div class="message-bubble thinking-bubble">
+                <div class="thinking-dots">
+                  <span class="dot"></span>
+                  <span class="dot"></span>
+                  <span class="dot"></span>
+                </div>
+                <span class="thinking-text">思考中...</span>
+              </div>
+            </div>
+          </div>
+        </template>
+      </div>
+
+      <!-- 输入区域 -->
+      <div class="input-area" v-if="isLoggedIn">
+        <div class="input-wrapper">
+          <a-textarea
+            v-model:value="inputMessage"
+            placeholder="请输入您的旅行问题..."
+            :rows="1"
+            :auto-size="{ minRows: 1, maxRows: 4 }"
+            @pressEnter="onPressEnter"
+            :disabled="isLoading"
+            class="chat-input"
+            ref="inputRef"
+          />
+          <a-button
+            type="primary"
+            class="send-btn"
+            :loading="isLoading"
+            :disabled="!inputMessage.trim()"
+            @click="sendMessage"
+          >
+            <template #icon><SendOutlined /></template>
+          </a-button>
+        </div>
+        <div class="input-hint">
+          按 Enter 发送消息,AI只回答与旅游相关的问题
+        </div>
+      </div>
+
+      <!-- 未登录提示 -->
+      <div class="input-area" v-else>
+        <div class="login-tip">
+          <a-button type="primary" @click="goLogin">请先登录后使用AI对话功能</a-button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, nextTick, watch } from 'vue'
+import { useRouter } from 'vue-router'
+import { message } from 'ant-design-vue'
+import {
+  PlusOutlined,
+  DeleteOutlined,
+  SendOutlined,
+  MenuFoldOutlined,
+  MenuUnfoldOutlined,
+} from '@ant-design/icons-vue'
+
+const router = useRouter()
+const messagesContainer = ref<HTMLElement | null>(null)
+const inputRef = ref<any>(null)
+
+const isLoggedIn = ref(false)
+const sessions = ref<any[]>([])
+const currentSessionId = ref<number | null>(null)
+const messages = ref<any[]>([])
+const inputMessage = ref('')
+const isLoading = ref(false)
+const sessionsLoading = ref(false)
+const sidebarCollapsed = ref(false)
+
+const API_BASE = import.meta.env.VITE_API_BASE_URL || 'https://localhost:8000'
+
+/** Cookie 自动携带 token */
+async function api(path: string, options: RequestInit = {}) {
+  return fetch(`${API_BASE}${path}`, {
+    ...options,
+    credentials: 'include',
+    headers: { 'Content-Type': 'application/json', ...options.headers },
+  })
+}
+
+function goLogin() {
+  router.push('/login')
+}
+
+function formatTime(timeStr: string): string {
+  if (!timeStr) return ''
+  try {
+    const d = new Date(timeStr)
+    const now = new Date()
+    const isToday = d.toDateString() === now.toDateString()
+    const pad = (n: number) => String(n).padStart(2, '0')
+    const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
+    if (isToday) return time
+    const yesterday = new Date(now)
+    yesterday.setDate(yesterday.getDate() - 1)
+    if (d.toDateString() === yesterday.toDateString()) return `昨天 ${time}`
+    return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${time}`
+  } catch {
+    return timeStr
+  }
+}
+
+/** 从 Cookie 读取用户名(后端设置,非 HttpOnly,零请求) */
+function getUsernameFromCookie(): string | null {
+  const match = document.cookie.match(/(?:^|;\s*)auth_username=([^;]*)/)
+  return match ? decodeURIComponent(match[1]) : null
+}
+
+/** 检查登录状态(直接从 Cookie 判断,不再调 profile API) */
+function checkAuth() {
+  isLoggedIn.value = !!getUsernameFromCookie()
+  if (isLoggedIn.value) {
+    fetchSessions()
+  }
+}
+
+/** 获取会话列表 */
+async function fetchSessions() {
+  sessionsLoading.value = true
+  try {
+    const res = await api('/api/chat/sessions')
+    const data = await res.json()
+    if (data.success) {
+      sessions.value = data.sessions || []
+    }
+  } catch (e) {
+    console.error('获取会话列表失败:', e)
+  } finally {
+    sessionsLoading.value = false
+  }
+}
+
+/** 创建新会话 */
+async function createNewSession() {
+  if (!isLoggedIn.value) {
+    message.warning('请先登录')
+    return
+  }
+  try {
+    const res = await api('/api/chat/sessions', { method: 'POST' })
+    const data = await res.json()
+    if (data.success && data.session) {
+      sessions.value.unshift(data.session)
+      currentSessionId.value = data.session.id
+      messages.value = []
+      await nextTick()
+      focusInput()
+    }
+  } catch (e) {
+    message.error('创建会话失败')
+  }
+}
+
+/** 切换会话 */
+async function switchSession(sessionId: number) {
+  if (sessionId === currentSessionId.value) return
+  currentSessionId.value = sessionId
+  messages.value = []
+  isLoading.value = false
+  await fetchMessages(sessionId)
+  await nextTick()
+  scrollToBottom()
+  focusInput()
+}
+
+/** 获取消息列表 */
+async function fetchMessages(sessionId: number) {
+  try {
+    const res = await api(`/api/chat/sessions/${sessionId}/messages`)
+    const data = await res.json()
+    if (data.success) {
+      messages.value = data.messages || []
+      await nextTick()
+      scrollToBottom()
+    }
+  } catch (e) {
+    console.error('获取消息失败:', e)
+  }
+}
+
+/** 删除会话 */
+async function deleteSession(sessionId: number) {
+  try {
+    const res = await api(`/api/chat/sessions/${sessionId}`, { method: 'DELETE' })
+    if (res.ok) {
+      sessions.value = sessions.value.filter(s => s.id !== sessionId)
+      if (currentSessionId.value === sessionId) {
+        currentSessionId.value = null
+        messages.value = []
+      }
+      message.success('会话已删除')
+    }
+  } catch {
+    message.error('删除失败')
+  }
+}
+
+/** 处理回车键(在 Ant Design 的 keydown 中阻止换行插入) */
+function onPressEnter(e: KeyboardEvent) {
+  e.preventDefault()
+  sendMessage()
+}
+
+/** 发送消息(流式SSE) */
+async function sendMessage() {
+  const content = inputMessage.value.trim()
+  if (!content || isLoading.value || !currentSessionId.value) return
+
+  // 先显示用户消息
+  const userMsg = {
+    id: Date.now() + 1,
+    session_id: currentSessionId.value,
+    role: 'user',
+    content: content,
+    created_at: new Date().toISOString(),
+  }
+  messages.value.push(userMsg)
+
+  // === 立即清空输入框 ===
+  inputMessage.value = ''
+  // 直接操作 DOM + 触发 input 事件,确保 Ant Design 内部状态同步
+  const ta = document.querySelector('.chat-input textarea') as HTMLTextAreaElement | null
+  if (ta) {
+    ta.value = ''
+    ta.dispatchEvent(new Event('input', { bubbles: true }))
+  }
+
+  isLoading.value = true
+
+  await nextTick()
+  scrollToBottom()
+
+  // 创建占位的AI消息(初始内容为空,流式填充)
+  const aiMsgId = Date.now() + 1
+  const aiMsg = {
+    id: aiMsgId,
+    session_id: currentSessionId.value,
+    role: 'assistant',
+    content: '',
+    created_at: new Date().toISOString(),
+  }
+  messages.value.push(aiMsg)
+
+  try {
+    // 使用 fetch + ReadableStream 读取 SSE 流
+    const response = await fetch(`${API_BASE}/api/chat/sessions/${currentSessionId.value}/messages`, {
+      method: 'POST',
+      credentials: 'include',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ content }),
+    })
+
+    if (!response.ok) {
+      const errData = await response.json().catch(() => null)
+      throw new Error(errData?.detail || `请求失败 (${response.status})`)
+    }
+
+    const reader = response.body!.getReader()
+    const decoder = new TextDecoder()
+    let buffer = ''
+
+    while (true) {
+      const { done, value } = await reader.read()
+      if (done) break
+
+      buffer += decoder.decode(value, { stream: true })
+
+      // 按行解析 SSE 事件
+      const lines = buffer.split('\n')
+      buffer = lines.pop() || '' // 保留未完成的行
+
+      for (const line of lines) {
+        if (line.startsWith('data: ')) {
+          const dataStr = line.slice(6).trim()
+          if (!dataStr) continue
+
+          try {
+            const event = JSON.parse(dataStr)
+
+            if (event.type === 'token') {
+              // 追加 token 到 AI 消息
+              aiMsg.content += event.content
+              // 触发响应式更新(直接修改引用的内容)
+              messages.value = [...messages.value]
+              scrollToBottom()
+            } else if (event.type === 'done') {
+              // 流式完成
+              if (event.title) {
+                // 更新会话标题
+                const session = sessions.value.find(s => s.id === currentSessionId.value)
+                if (session) {
+                  session.title = event.title
+                  session.updated_at = new Date().toISOString()
+                }
+              }
+            } else if (event.type === 'error') {
+              // AI回复出错,显示错误信息
+              if (!aiMsg.content) {
+                aiMsg.content = event.content
+                messages.value = [...messages.value]
+              }
+            }
+          } catch {
+            // 忽略解析错误的行
+          }
+        }
+      }
+    }
+  } catch (e: any) {
+    // 如果完全没有收到任何回复,显示错误
+    if (!aiMsg.content) {
+      aiMsg.content = '抱歉,网络连接失败,请检查后端是否启动。'
+      messages.value = [...messages.value]
+    }
+    console.error('流式请求失败:', e)
+  } finally {
+    isLoading.value = false
+    // 兜底:再次确保输入框被清空
+    inputMessage.value = ''
+    const ta2 = document.querySelector('.chat-input textarea') as HTMLTextAreaElement | null
+    if (ta2 && ta2.value !== '') {
+      ta2.value = ''
+      ta2.dispatchEvent(new Event('input', { bubbles: true }))
+    }
+    // 更新会话时间
+    const session = sessions.value.find(s => s.id === currentSessionId.value)
+    if (session) {
+      session.updated_at = new Date().toISOString()
+    }
+    await nextTick()
+    scrollToBottom()
+    focusInput()
+  }
+}
+
+/** 快捷提问 */
+function quickQuestion(q: string) {
+  if (!isLoggedIn.value) {
+    message.warning('请先登录')
+    router.push('/login')
+    return
+  }
+
+  if (!currentSessionId.value) {
+    // 自动创建新会话
+    createNewSession().then(() => {
+      nextTick(() => {
+        // 等待会话创建完成并切换后,再发送消息
+        setTimeout(() => {
+          inputMessage.value = q
+          sendMessage()
+        }, 300)
+      })
+    })
+    return
+  }
+
+  inputMessage.value = q
+  sendMessage()
+}
+
+/** 滚动到底部 */
+function scrollToBottom() {
+  nextTick(() => {
+    if (messagesContainer.value) {
+      messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
+    }
+  })
+}
+
+/** 滚动事件 */
+function handleScroll() {
+  // 可用于懒加载更多历史消息(未来扩展)
+}
+
+/** 聚焦输入框 */
+function focusInput() {
+  nextTick(() => {
+    try {
+      const textarea = document.querySelector('.chat-input textarea') as HTMLTextAreaElement
+      if (textarea) textarea.focus()
+    } catch {}
+  })
+}
+
+/** 监听当前会话ID变化 */
+watch(currentSessionId, (newId) => {
+  if (newId) {
+    // 重新获取消息
+    fetchMessages(newId)
+  }
+})
+
+onMounted(() => {
+  checkAuth()
+})
+</script>
+
+<style scoped>
+.chat-container {
+  display: flex;
+  height: calc(100vh - 134px); /* header + footer + padding */
+  background: #f5f7fa;
+  border-radius: 16px;
+  overflow: hidden;
+  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
+  max-width: 1400px;
+  margin: 0 auto;
+}
+
+/* ============ 侧边栏 ============ */
+.chat-sidebar {
+  width: 280px;
+  background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
+  color: white;
+  display: flex;
+  flex-direction: column;
+  transition: width 0.3s ease;
+  position: relative;
+  flex-shrink: 0;
+}
+
+.chat-sidebar.collapsed {
+  width: 60px;
+}
+
+.sidebar-header {
+  padding: 16px;
+  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+  flex-shrink: 0;
+}
+
+.sidebar-title {
+  font-size: 18px;
+  font-weight: 600;
+  margin-bottom: 12px;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.sidebar-icon {
+  font-size: 24px;
+}
+
+.new-chat-btn {
+  width: 100%;
+  border-radius: 8px;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  border: none;
+  height: 40px;
+  font-size: 14px;
+}
+
+.chat-sidebar.collapsed .new-chat-btn {
+  width: 40px;
+  padding: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin: 0 auto;
+}
+
+/* 会话列表 */
+.session-list {
+  flex: 1;
+  overflow-y: auto;
+  padding: 8px;
+}
+
+.session-list::-webkit-scrollbar {
+  width: 4px;
+}
+
+.session-list::-webkit-scrollbar-thumb {
+  background: rgba(255, 255, 255, 0.2);
+  border-radius: 2px;
+}
+
+.session-item {
+  display: flex;
+  align-items: center;
+  padding: 12px;
+  border-radius: 8px;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  margin-bottom: 4px;
+  gap: 8px;
+}
+
+.session-item:hover {
+  background: rgba(255, 255, 255, 0.1);
+}
+
+.session-item.active {
+  background: rgba(102, 126, 234, 0.3);
+  border: 1px solid rgba(102, 126, 234, 0.5);
+}
+
+.session-info {
+  flex: 1;
+  min-width: 0;
+}
+
+.session-title {
+  font-size: 14px;
+  font-weight: 500;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-bottom: 2px;
+}
+
+.session-time {
+  font-size: 11px;
+  color: rgba(255, 255, 255, 0.5);
+}
+
+.session-actions {
+  opacity: 0;
+  transition: opacity 0.2s;
+  flex-shrink: 0;
+}
+
+.session-item:hover .session-actions {
+  opacity: 1;
+}
+
+.session-icon-collapsed {
+  font-size: 20px;
+  margin: 0 auto;
+}
+
+/* 折叠按钮 */
+.sidebar-collapse-btn {
+  padding: 12px;
+  text-align: center;
+  cursor: pointer;
+  border-top: 1px solid rgba(255, 255, 255, 0.1);
+  color: rgba(255, 255, 255, 0.6);
+  transition: color 0.2s;
+  flex-shrink: 0;
+}
+
+.sidebar-collapse-btn:hover {
+  color: white;
+}
+
+/* ============ 主聊天区域 ============ */
+.chat-main {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  background: white;
+  min-width: 0;
+}
+
+/* 消息容器 */
+.messages-container {
+  flex: 1;
+  overflow-y: auto;
+  padding: 24px;
+  background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
+}
+
+.messages-container::-webkit-scrollbar {
+  width: 6px;
+}
+
+.messages-container::-webkit-scrollbar-thumb {
+  background: #d0d5dd;
+  border-radius: 3px;
+}
+
+/* 欢迎区域 */
+.welcome-section {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+  text-align: center;
+  padding: 40px;
+}
+
+.welcome-icon {
+  font-size: 80px;
+  margin-bottom: 16px;
+  animation: float 3s ease-in-out infinite;
+}
+
+@keyframes float {
+  0%, 100% { transform: translateY(0); }
+  50% { transform: translateY(-20px); }
+}
+
+.welcome-title {
+  font-size: 32px;
+  font-weight: 700;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+  background-clip: text;
+  margin-bottom: 12px;
+}
+
+.welcome-desc {
+  font-size: 16px;
+  color: #666;
+  margin-bottom: 32px;
+}
+
+.welcome-suggestions {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 12px;
+  max-width: 500px;
+  width: 100%;
+}
+
+.suggestion-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 16px 20px;
+  background: white;
+  border: 2px solid #e8e8e8;
+  border-radius: 12px;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  font-size: 14px;
+  font-weight: 500;
+  color: #333;
+}
+
+.suggestion-item:hover {
+  border-color: #667eea;
+  transform: translateY(-2px);
+  box-shadow: 0 4px 16px rgba(102, 126, 234, 0.15);
+}
+
+.suggestion-icon {
+  font-size: 24px;
+}
+
+/* 消息项 */
+.message-item {
+  display: flex;
+  gap: 12px;
+  margin-bottom: 24px;
+  animation: fadeInUp 0.3s ease-out;
+}
+
+@keyframes fadeInUp {
+  from {
+    opacity: 0;
+    transform: translateY(10px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+.message-item.user {
+  flex-direction: row-reverse;
+}
+
+.message-avatar {
+  width: 40px;
+  height: 40px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 20px;
+  background: #f0f0f0;
+  flex-shrink: 0;
+}
+
+.message-item.user .message-avatar {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+}
+
+.message-content {
+  max-width: 70%;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.message-item.user .message-content {
+  align-items: flex-end;
+}
+
+.message-bubble {
+  padding: 14px 18px;
+  border-radius: 18px;
+  line-height: 1.6;
+  font-size: 15px;
+  word-break: break-word;
+  white-space: pre-wrap;
+}
+
+.message-item.assistant .message-bubble {
+  background: white;
+  border: 1px solid #e8e8e8;
+  border-top-left-radius: 4px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+  color: #333;
+}
+
+.message-item.user .message-bubble {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: white;
+  border-top-right-radius: 4px;
+}
+
+.message-time {
+  font-size: 11px;
+  color: #999;
+  padding: 0 8px;
+}
+
+/* 思考中动画 */
+.thinking-bubble {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-width: 80px;
+}
+
+.thinking-dots {
+  display: flex;
+  gap: 4px;
+}
+
+.dot {
+  width: 8px;
+  height: 8px;
+  background: #667eea;
+  border-radius: 50%;
+  animation: thinking 1.4s infinite ease-in-out;
+}
+
+.dot:nth-child(1) {
+  animation-delay: -0.32s;
+}
+.dot:nth-child(2) {
+  animation-delay: -0.16s;
+}
+.dot:nth-child(3) {
+  animation-delay: 0s;
+}
+
+@keyframes thinking {
+  0%, 80%, 100% {
+    transform: scale(0);
+    opacity: 0.3;
+  }
+  40% {
+    transform: scale(1);
+    opacity: 1;
+  }
+}
+
+.thinking-text {
+  font-size: 14px;
+  color: #999;
+}
+
+/* 输入区域 */
+.input-area {
+  padding: 16px 24px;
+  border-top: 1px solid #e8e8e8;
+  background: white;
+}
+
+.input-wrapper {
+  display: flex;
+  gap: 12px;
+  align-items: flex-end;
+}
+
+.chat-input {
+  flex: 1;
+  border-radius: 12px;
+  border: 2px solid #e8e8e8;
+  transition: all 0.3s ease;
+  font-size: 15px;
+  padding: 10px 16px;
+  resize: none;
+}
+
+.chat-input:focus {
+  border-color: #667eea;
+  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+.send-btn {
+  height: 44px;
+  width: 44px;
+  border-radius: 12px;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  border: none;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+
+.send-btn:hover {
+  transform: translateY(-1px);
+  box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
+}
+
+.input-hint {
+  margin-top: 8px;
+  font-size: 12px;
+  color: #bbb;
+  text-align: center;
+}
+
+.login-tip {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  padding: 16px;
+}
+
+.login-tip .ant-btn {
+  border-radius: 8px;
+  height: 48px;
+  font-size: 16px;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  border: none;
+}
+
+/* 响应式 */
+@media (max-width: 768px) {
+  .chat-container {
+    height: calc(100vh - 100px);
+    border-radius: 0;
+  }
+
+  .chat-sidebar {
+    width: 60px;
+  }
+
+  .chat-sidebar.collapsed {
+    width: 0;
+    overflow: hidden;
+  }
+
+  .message-content {
+    max-width: 85%;
+  }
+
+  .welcome-suggestions {
+    grid-template-columns: 1fr;
+  }
+}
+</style>

+ 174 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/src/views/History.vue

@@ -0,0 +1,174 @@
+<template>
+  <div class="history-container">
+    <div class="page-header">
+      <h2>📋 我的历史行程</h2>
+      <a-button @click="goBack">← 返回首页</a-button>
+    </div>
+
+    <a-spin :spinning="loading">
+      <a-empty v-if="!loading && records.length === 0" description="暂无历史记录">
+        <template #image>
+          <div style="font-size: 80px;">🗺️</div>
+        </template>
+        <a-button type="primary" @click="goBack">去创建行程</a-button>
+      </a-empty>
+
+      <a-list v-else :data-source="records" :grid="{ gutter: 16, column: 2 }">
+        <template #renderItem="{ item }">
+          <a-list-item>
+            <a-card class="history-card" hoverable @click="viewDetail(item)">
+              <div class="history-header">
+                <span class="history-city">{{ item.city }}</span>
+                <a-tag color="blue">{{ item.travel_days }}天</a-tag>
+              </div>
+              <div class="history-meta">
+                <div>📅 {{ item.start_date }} ~ {{ item.end_date }}</div>
+                <div v-if="item.traveler_group">👥 {{ item.traveler_group }}</div>
+                <div class="history-time">🕐 {{ item.created_at }}</div>
+              </div>
+              <div class="history-prefs" v-if="item.preferences">
+                <a-tag v-for="p in item.preferences.split(',')" :key="p" color="purple">{{ p }}</a-tag>
+              </div>
+              <template #actions>
+                <a-button type="link" danger @click.stop="deleteRecord(item)">删除</a-button>
+              </template>
+            </a-card>
+          </a-list-item>
+        </template>
+      </a-list>
+    </a-spin>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { message, Modal } from 'ant-design-vue'
+
+const router = useRouter()
+const loading = ref(false)
+const records = ref<any[]>([])
+
+/** Cookie 自动携带 token,无需手动传 header */
+async function api(path: string, options: RequestInit = {}) {
+  const baseUrl = import.meta.env.VITE_API_BASE_URL || 'https://localhost:8000'
+  return fetch(`${baseUrl}${path}`, {
+    ...options,
+    credentials: 'include',
+    headers: { 'Content-Type': 'application/json', ...options.headers },
+  })
+}
+
+async function fetchHistory() {
+  loading.value = true
+  try {
+    const res = await api('/api/history')
+    const data = await res.json()
+    if (res.ok) {
+      records.value = data.records || []
+    } else {
+      if (res.status === 401) {
+        // Cookie 过期
+        localStorage.removeItem('auth_username')
+        router.push('/login')
+        return
+      }
+      message.error(data.detail || '获取历史记录失败')
+    }
+  } catch (e) {
+    message.error('网络错误')
+  } finally {
+    loading.value = false
+  }
+}
+
+async function viewDetail(item: any) {
+  try {
+    const res = await api(`/api/history/${item.id}`)
+    const data = await res.json()
+    if (res.ok && data.record) {
+      sessionStorage.setItem('tripPlan', JSON.stringify(data.record.plan_data))
+      sessionStorage.setItem('travelerGroup', data.record.traveler_group || '')
+      router.push('/result')
+    } else {
+      message.error('获取行程详情失败')
+    }
+  } catch (e) {
+    message.error('网络错误')
+  }
+}
+
+function deleteRecord(item: any) {
+  Modal.confirm({
+    title: `删除${item.city}行程?`,
+    content: '删除后不可恢复',
+    okText: '删除',
+    okType: 'danger',
+    async onOk() {
+      try {
+        const res = await api(`/api/history/${item.id}`, { method: 'DELETE' })
+        if (res.ok) {
+          message.success('已删除')
+          records.value = records.value.filter((r: any) => r.id !== item.id)
+        } else {
+          message.error('删除失败')
+        }
+      } catch (e) {
+        message.error('网络错误')
+      }
+    }
+  })
+}
+
+function goBack() {
+  router.push('/')
+}
+
+onMounted(fetchHistory)
+</script>
+
+<style scoped>
+.history-container {
+  max-width: 1000px;
+  margin: 0 auto;
+  padding: 20px;
+}
+.page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 24px;
+}
+.history-card {
+  border-radius: 12px;
+  cursor: pointer;
+  transition: all 0.3s ease;
+}
+.history-card:hover {
+  transform: translateY(-4px);
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+}
+.history-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12px;
+}
+.history-city {
+  font-size: 20px;
+  font-weight: bold;
+  color: #333;
+}
+.history-meta {
+  color: #666;
+  font-size: 14px;
+  line-height: 2;
+}
+.history-time {
+  color: #999;
+  font-size: 12px;
+}
+.history-prefs {
+  margin-top: 8px;
+}
+</style>

+ 737 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/src/views/Home.vue

@@ -0,0 +1,737 @@
+<template>
+  <div class="home-container">
+    <!-- 背景装饰 -->
+    <div class="bg-decoration">
+      <div class="circle circle-1"></div>
+      <div class="circle circle-2"></div>
+      <div class="circle circle-3"></div>
+    </div>
+
+    <!-- 页面标题 -->
+    <div class="page-header">
+      <div class="icon-wrapper">
+        <span class="icon">✈️</span>
+      </div>
+      <h1 class="page-title">智能旅行助手</h1>
+      <p class="page-subtitle">基于AI的个性化旅行规划,让每一次出行都完美无忧</p>
+    </div>
+
+    <a-card class="form-card" :bordered="false">
+      <a-form
+        :model="formData"
+        layout="vertical"
+        @finish="handleSubmit"
+      >
+        <!-- 第一步:目的地和日期 -->
+        <div class="form-section">
+          <div class="section-header">
+            <span class="section-icon">📍</span>
+            <span class="section-title">目的地与日期</span>
+          </div>
+
+          <a-row :gutter="24">
+            <a-col :span="8">
+              <a-form-item name="city" :rules="[{ required: true, message: '请输入目的地城市' }]">
+                <template #label>
+                  <span class="form-label">目的地城市</span>
+                </template>
+                <a-input
+                  v-model:value="formData.city"
+                  placeholder="例如: 北京"
+                  size="large"
+                  class="custom-input"
+                >
+                  <template #prefix>
+                    <span style="color: #1890ff;">🏙️</span>
+                  </template>
+                </a-input>
+              </a-form-item>
+            </a-col>
+            <a-col :span="6">
+              <a-form-item name="start_date" :rules="[{ required: true, message: '请选择开始日期' }]">
+                <template #label>
+                  <span class="form-label">开始日期</span>
+                </template>
+                <a-date-picker
+                  v-model:value="formData.start_date"
+                  style="width: 100%"
+                  size="large"
+                  class="custom-input"
+                  placeholder="选择日期"
+                />
+              </a-form-item>
+            </a-col>
+            <a-col :span="6">
+              <a-form-item name="end_date" :rules="[{ required: true, message: '请选择结束日期' }]">
+                <template #label>
+                  <span class="form-label">结束日期</span>
+                </template>
+                <a-date-picker
+                  v-model:value="formData.end_date"
+                  style="width: 100%"
+                  size="large"
+                  class="custom-input"
+                  placeholder="选择日期"
+                />
+              </a-form-item>
+            </a-col>
+            <a-col :span="4">
+              <a-form-item>
+                <template #label>
+                  <span class="form-label">旅行天数</span>
+                </template>
+                <div class="days-display-compact">
+                  <span class="days-value">{{ formData.travel_days }}</span>
+                  <span class="days-unit">天</span>
+                </div>
+              </a-form-item>
+            </a-col>
+          </a-row>
+        </div>
+
+        <!-- 第二步:偏好设置 -->
+        <div class="form-section">
+          <div class="section-header">
+            <span class="section-icon">⚙️</span>
+            <span class="section-title">偏好设置</span>
+          </div>
+
+          <a-row :gutter="24">
+            <a-col :span="8">
+              <a-form-item name="transportation">
+                <template #label>
+                  <span class="form-label">交通方式</span>
+                </template>
+                <a-select v-model:value="formData.transportation" size="large" class="custom-select">
+                  <a-select-option value="公共交通">🚇 公共交通</a-select-option>
+                  <a-select-option value="自驾">🚗 自驾</a-select-option>
+                  <a-select-option value="步行">🚶 步行</a-select-option>
+                  <a-select-option value="混合">🔀 混合</a-select-option>
+                </a-select>
+              </a-form-item>
+            </a-col>
+            <a-col :span="8">
+              <a-form-item name="accommodation">
+                <template #label>
+                  <span class="form-label">住宿偏好</span>
+                </template>
+                <a-select v-model:value="formData.accommodation" size="large" class="custom-select">
+                  <a-select-option value="经济型酒店">💰 经济型酒店</a-select-option>
+                  <a-select-option value="舒适型酒店">🏨 舒适型酒店</a-select-option>
+                  <a-select-option value="豪华酒店">⭐ 豪华酒店</a-select-option>
+                  <a-select-option value="民宿">🏡 民宿</a-select-option>
+                </a-select>
+              </a-form-item>
+            </a-col>
+            <a-col :span="8">
+              <a-form-item name="preferences">
+                <template #label>
+                  <span class="form-label">旅行偏好</span>
+                </template>
+                <div class="preference-tags">
+                  <a-checkbox-group v-model:value="formData.preferences" class="custom-checkbox-group">
+                    <a-checkbox value="历史文化" class="preference-tag">🏛️ 历史文化</a-checkbox>
+                    <a-checkbox value="自然风光" class="preference-tag">🏞️ 自然风光</a-checkbox>
+                    <a-checkbox value="美食" class="preference-tag">🍜 美食</a-checkbox>
+                    <a-checkbox value="购物" class="preference-tag">🛍️ 购物</a-checkbox>
+                    <a-checkbox value="艺术" class="preference-tag">🎨 艺术</a-checkbox>
+                    <a-checkbox value="休闲" class="preference-tag">☕ 休闲</a-checkbox>
+                  </a-checkbox-group>
+                </div>
+              </a-form-item>
+            </a-col>
+          </a-row>
+        </div>
+
+        <!-- 第三步:出行人群 -->
+        <div class="form-section">
+          <div class="section-header">
+            <span class="section-icon">👥</span>
+            <span class="section-title">出行人群与场景</span>
+          </div>
+
+          <a-row :gutter="24">
+            <a-col :span="12">
+              <a-form-item name="traveler_group">
+                <template #label>
+                  <span class="form-label">出行人群</span>
+                </template>
+                <a-select v-model:value="formData.traveler_group" size="large" class="custom-select" placeholder="选择出行人群">
+                  <a-select-option value="独自旅行">🧑 独自旅行</a-select-option>
+                  <a-select-option value="情侣夫妻">💑 情侣/夫妻</a-select-option>
+                  <a-select-option value="朋友结伴">👫 朋友结伴</a-select-option>
+                  <a-select-option value="家庭亲子">👨‍👩‍👧‍👦 家庭亲子</a-select-option>
+                  <a-select-option value="公司团建">🏢 公司团建</a-select-option>
+                  <a-select-option value="老年旅行">👴 老年旅行</a-select-option>
+                  <a-select-option value="研学旅行">📚 研学旅行</a-select-option>
+                </a-select>
+              </a-form-item>
+            </a-col>
+            <a-col :span="12">
+              <a-form-item label="选择后AI将根据该人群特点定制行程">
+                <template #label>
+                  <span class="form-label" style="color: #999; font-weight: normal;">不同人群的行程差异</span>
+                </template>
+                <div class="group-hint">
+                  <span v-if="formData.traveler_group === '独自旅行'">🎒 推荐青旅/经济住宿,安排社交友好的活动</span>
+                  <span v-else-if="formData.traveler_group === '情侣夫妻'">💕 推荐浪漫餐厅、观景台,安排双人体验项目</span>
+                  <span v-else-if="formData.traveler_group === '朋友结伴'">🎉 安排集体活动、娱乐项目,推荐互动体验</span>
+                  <span v-else-if="formData.traveler_group === '家庭亲子'">🧸 推荐亲子景点、儿童友好餐厅,节奏宽松</span>
+                  <span v-else-if="formData.traveler_group === '公司团建'">🤝 推荐团建场地、团队活动,兼顾会议与休闲</span>
+                  <span v-else-if="formData.traveler_group === '老年旅行'">🌿 行程舒缓,景点平坦少爬坡,推荐养生餐饮</span>
+                  <span v-else-if="formData.traveler_group === '研学旅行'">🎓 安排博物馆、科技馆、文化遗址等教育性景点</span>
+                  <span v-else style="color: #bbb;">选择出行人群,获得个性化推荐</span>
+                </div>
+              </a-form-item>
+            </a-col>
+          </a-row>
+        </div>
+
+        <!-- 第四步:额外要求 -->
+        <div class="form-section">
+          <div class="section-header">
+            <span class="section-icon">💬</span>
+            <span class="section-title">额外要求</span>
+          </div>
+
+          <a-form-item name="free_text_input">
+            <a-textarea
+              v-model:value="formData.free_text_input"
+              placeholder="请输入您的额外要求,例如:想去看升旗、需要无障碍设施、对海鲜过敏等..."
+              :rows="3"
+              size="large"
+              class="custom-textarea"
+            />
+          </a-form-item>
+        </div>
+
+        <!-- 提交按钮 -->
+        <a-form-item>
+          <a-button
+            type="primary"
+            html-type="submit"
+            :loading="loading"
+            size="large"
+            block
+            class="submit-button"
+          >
+            <template v-if="!loading">
+              <span class="button-icon">🚀</span>
+              <span>开始规划我的旅行</span>
+            </template>
+            <template v-else>
+              <span>正在生成中...</span>
+            </template>
+          </a-button>
+        </a-form-item>
+
+        <!-- 加载进度条 -->
+        <a-form-item v-if="loading">
+          <div class="loading-container">
+            <a-progress
+              :percent="loadingProgress"
+              status="active"
+              :stroke-color="{
+                '0%': '#667eea',
+                '100%': '#764ba2',
+              }"
+              :stroke-width="10"
+            />
+            <p class="loading-status">
+              {{ loadingStatus }}
+            </p>
+          </div>
+        </a-form-item>
+      </a-form>
+    </a-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, watch } from 'vue'
+import { useRouter } from 'vue-router'
+import { message } from 'ant-design-vue'
+import { generateTripPlan } from '@/services/api'
+import type { TripFormData } from '@/types'
+import type { Dayjs } from 'dayjs'
+
+const router = useRouter()
+const loading = ref(false)
+const loadingProgress = ref(0)
+const loadingStatus = ref('')
+
+const formData = reactive<TripFormData & { start_date: Dayjs | null; end_date: Dayjs | null }>({
+  city: '',
+  start_date: null,
+  end_date: null,
+  travel_days: 1,
+  transportation: '公共交通',
+  accommodation: '经济型酒店',
+  preferences: [],
+  traveler_group: '',
+  free_text_input: ''
+})
+
+// 监听日期变化,自动计算旅行天数
+watch([() => formData.start_date, () => formData.end_date], ([start, end]) => {
+  if (start && end) {
+    const days = end.diff(start, 'day') + 1
+    if (days > 0 && days <= 30) {
+      formData.travel_days = days
+    } else if (days > 30) {
+      message.warning('旅行天数不能超过30天')
+      formData.end_date = null
+    } else {
+      message.warning('结束日期不能早于开始日期')
+      formData.end_date = null
+    }
+  }
+})
+
+const handleSubmit = async () => {
+  if (!formData.start_date || !formData.end_date) {
+    message.error('请选择日期')
+    return
+  }
+
+  // 检查登录(通过cookie)
+  const loggedIn = localStorage.getItem('auth_username')
+  if (!loggedIn) {
+    message.warning('请先登录后再生成旅行计划')
+    router.push('/login')
+    return
+  }
+
+  loading.value = true
+  loadingProgress.value = 0
+  loadingStatus.value = '正在初始化...'
+
+  // 模拟进度更新
+  const progressInterval = setInterval(() => {
+    if (loadingProgress.value < 90) {
+      loadingProgress.value += 10
+
+      // 更新状态文本
+      if (loadingProgress.value <= 30) {
+        loadingStatus.value = '🔍 正在搜索景点...'
+      } else if (loadingProgress.value <= 50) {
+        loadingStatus.value = '🌤️ 正在查询天气...'
+      } else if (loadingProgress.value <= 70) {
+        loadingStatus.value = '🏨 正在推荐酒店...'
+      } else {
+        loadingStatus.value = '📋 正在生成行程计划...'
+      }
+    }
+  }, 500)
+
+  try {
+    const requestData: TripFormData = {
+      city: formData.city,
+      start_date: formData.start_date.format('YYYY-MM-DD'),
+      end_date: formData.end_date.format('YYYY-MM-DD'),
+      travel_days: formData.travel_days,
+      transportation: formData.transportation,
+      accommodation: formData.accommodation,
+      preferences: formData.preferences,
+      traveler_group: formData.traveler_group,
+      free_text_input: formData.free_text_input
+    }
+
+    const response = await generateTripPlan(requestData)
+
+    clearInterval(progressInterval)
+    loadingProgress.value = 100
+    loadingStatus.value = '✅ 完成!'
+
+    if (response.success && response.data) {
+      // 保存到sessionStorage
+      sessionStorage.setItem('tripPlan', JSON.stringify(response.data))
+      sessionStorage.setItem('travelerGroup', formData.traveler_group)
+
+      message.success('旅行计划生成成功!')
+
+      // 如果已登录,自动保存到历史记录
+      const loggedIn = localStorage.getItem('auth_username')
+      if (loggedIn) {
+      const baseUrl = import.meta.env.VITE_API_BASE_URL || 'https://localhost:8000'
+        fetch(`${baseUrl}/api/history`, {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          credentials: 'include',
+          body: JSON.stringify({
+            city: requestData.city,
+            start_date: requestData.start_date,
+            end_date: requestData.end_date,
+            travel_days: requestData.travel_days,
+            preferences: requestData.preferences,
+            traveler_group: requestData.traveler_group,
+            plan_data: response.data,
+          }),
+        }).catch(() => {}) // 静默保存,不阻塞跳转
+      }
+
+      // 短暂延迟后跳转
+      setTimeout(() => {
+        router.push('/result')
+      }, 500)
+    } else {
+      message.error(response.message || '生成失败')
+    }
+  } catch (error: any) {
+    clearInterval(progressInterval)
+    message.error(error.message || '生成旅行计划失败,请稍后重试')
+  } finally {
+    setTimeout(() => {
+      loading.value = false
+      loadingProgress.value = 0
+      loadingStatus.value = ''
+    }, 1000)
+  }
+}
+</script>
+
+<style scoped>
+.home-container {
+  min-height: 100vh;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  padding: 60px 20px;
+  position: relative;
+  overflow: hidden;
+}
+
+/* 背景装饰 */
+.bg-decoration {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  pointer-events: none;
+  overflow: hidden;
+}
+
+.circle {
+  position: absolute;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.1);
+  animation: float 20s infinite ease-in-out;
+}
+
+.circle-1 {
+  width: 300px;
+  height: 300px;
+  top: -100px;
+  left: -100px;
+  animation-delay: 0s;
+}
+
+.circle-2 {
+  width: 200px;
+  height: 200px;
+  top: 50%;
+  right: -50px;
+  animation-delay: 5s;
+}
+
+.circle-3 {
+  width: 150px;
+  height: 150px;
+  bottom: -50px;
+  left: 30%;
+  animation-delay: 10s;
+}
+
+@keyframes float {
+  0%, 100% {
+    transform: translateY(0) rotate(0deg);
+  }
+  50% {
+    transform: translateY(-30px) rotate(180deg);
+  }
+}
+
+/* 页面标题 */
+.page-header {
+  text-align: center;
+  margin-bottom: 50px;
+  animation: fadeInDown 0.8s ease-out;
+  position: relative;
+  z-index: 1;
+}
+
+.icon-wrapper {
+  margin-bottom: 20px;
+}
+
+.icon {
+  font-size: 80px;
+  display: inline-block;
+  animation: bounce 2s infinite;
+}
+
+@keyframes bounce {
+  0%, 100% {
+    transform: translateY(0);
+  }
+  50% {
+    transform: translateY(-20px);
+  }
+}
+
+.page-title {
+  font-size: 56px;
+  font-weight: 800;
+  color: #ffffff;
+  margin-bottom: 16px;
+  text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.3);
+  letter-spacing: 2px;
+}
+
+.page-subtitle {
+  font-size: 20px;
+  color: rgba(255, 255, 255, 0.95);
+  margin: 0;
+  font-weight: 300;
+}
+
+/* 表单卡片 */
+.form-card {
+  max-width: 1400px;
+  margin: 0 auto;
+  border-radius: 24px;
+  box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4);
+  animation: fadeInUp 0.8s ease-out;
+  position: relative;
+  z-index: 1;
+  backdrop-filter: blur(10px);
+  background: rgba(255, 255, 255, 0.98) !important;
+}
+
+/* 表单分区 */
+.form-section {
+  margin-bottom: 32px;
+  padding: 24px;
+  background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
+  border-radius: 16px;
+  border: 1px solid #e8e8e8;
+  transition: all 0.3s ease;
+}
+
+.form-section:hover {
+  box-shadow: 0 8px 24px rgba(102, 126, 234, 0.15);
+  transform: translateY(-2px);
+}
+
+.section-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20px;
+  padding-bottom: 12px;
+  border-bottom: 2px solid #667eea;
+}
+
+.section-icon {
+  font-size: 24px;
+  margin-right: 12px;
+}
+
+.section-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: #333;
+}
+
+/* 表单标签 */
+.form-label {
+  font-size: 15px;
+  font-weight: 500;
+  color: #555;
+}
+
+/* 自定义输入框 */
+.custom-input :deep(.ant-input),
+.custom-input :deep(.ant-picker) {
+  border-radius: 12px;
+  border: 2px solid #e8e8e8;
+  transition: all 0.3s ease;
+}
+
+.custom-input :deep(.ant-input:hover),
+.custom-input :deep(.ant-picker:hover) {
+  border-color: #667eea;
+}
+
+.custom-input :deep(.ant-input:focus),
+.custom-input :deep(.ant-picker-focused) {
+  border-color: #667eea;
+  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+/* 自定义选择框 */
+.custom-select :deep(.ant-select-selector) {
+  border-radius: 12px !important;
+  border: 2px solid #e8e8e8 !important;
+  transition: all 0.3s ease;
+}
+
+.custom-select:hover :deep(.ant-select-selector) {
+  border-color: #667eea !important;
+}
+
+.custom-select :deep(.ant-select-focused .ant-select-selector) {
+  border-color: #667eea !important;
+  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1) !important;
+}
+
+/* 天数显示 - 紧凑版 */
+.days-display-compact {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 40px;
+  padding: 8px 16px;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  border-radius: 12px;
+  color: white;
+}
+
+.days-display-compact .days-value {
+  font-size: 24px;
+  font-weight: 700;
+  margin-right: 4px;
+}
+
+.days-display-compact .days-unit {
+  font-size: 14px;
+}
+
+/* 偏好标签 */
+.preference-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+/* 人群选择提示 */
+.group-hint {
+  height: 40px;
+  display: flex;
+  align-items: center;
+  padding: 8px 16px;
+  background: linear-gradient(135deg, #f0f4ff 0%, #e8eeff 100%);
+  border-radius: 12px;
+  border: 1px dashed #667eea;
+  color: #555;
+  font-size: 14px;
+}
+
+.custom-checkbox-group {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  width: 100%;
+}
+
+.preference-tag :deep(.ant-checkbox-wrapper) {
+  margin: 0 !important;
+  padding: 8px 16px;
+  border: 2px solid #e8e8e8;
+  border-radius: 20px;
+  transition: all 0.3s ease;
+  background: white;
+  font-size: 14px;
+}
+
+.preference-tag :deep(.ant-checkbox-wrapper:hover) {
+  border-color: #667eea;
+  background: #f5f7ff;
+}
+
+.preference-tag :deep(.ant-checkbox-wrapper-checked) {
+  border-color: #667eea;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: white;
+}
+
+/* 自定义文本域 */
+.custom-textarea :deep(.ant-input) {
+  border-radius: 12px;
+  border: 2px solid #e8e8e8;
+  transition: all 0.3s ease;
+}
+
+.custom-textarea :deep(.ant-input:hover) {
+  border-color: #667eea;
+}
+
+.custom-textarea :deep(.ant-input:focus) {
+  border-color: #667eea;
+  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+/* 提交按钮 */
+.submit-button {
+  height: 56px;
+  border-radius: 28px;
+  font-size: 18px;
+  font-weight: 600;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  border: none;
+  box-shadow: 0 8px 24px rgba(102, 126, 234, 0.4);
+  transition: all 0.3s ease;
+}
+
+.submit-button:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 12px 32px rgba(102, 126, 234, 0.5);
+}
+
+.submit-button:active {
+  transform: translateY(0);
+}
+
+.button-icon {
+  margin-right: 8px;
+  font-size: 20px;
+}
+
+/* 加载容器 */
+.loading-container {
+  text-align: center;
+  padding: 24px;
+  background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
+  border-radius: 16px;
+  border: 2px dashed #667eea;
+}
+
+.loading-status {
+  margin-top: 16px;
+  color: #667eea;
+  font-size: 18px;
+  font-weight: 500;
+}
+
+/* 动画 */
+@keyframes fadeInDown {
+  from {
+    opacity: 0;
+    transform: translateY(-30px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@keyframes fadeInUp {
+  from {
+    opacity: 0;
+    transform: translateY(30px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+</style>
+

+ 142 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/src/views/Login.vue

@@ -0,0 +1,142 @@
+<template>
+  <div class="login-container">
+    <a-card class="login-card" :title="isRegister ? '注册' : '登录'">
+      <template #extra>
+        <a-button type="link" @click="isRegister = !isRegister">
+          {{ isRegister ? '已有账号?去登录' : '没有账号?去注册' }}
+        </a-button>
+      </template>
+
+      <a-form :model="form" :rules="rules" layout="vertical" @finish="handleSubmit">
+        <a-form-item label="用户名" name="username">
+          <a-input v-model:value="form.username" placeholder="请输入用户名" size="large" />
+        </a-form-item>
+
+        <a-form-item label="密码" name="password">
+          <a-input-password v-model:value="form.password" placeholder="请输入密码" size="large" />
+        </a-form-item>
+
+        <a-form-item v-if="isRegister" label="确认密码" name="confirmPassword">
+          <a-input-password v-model:value="form.confirmPassword" placeholder="请再次输入密码" size="large" />
+        </a-form-item>
+
+        <a-form-item>
+          <a-button type="primary" html-type="submit" :loading="loading" block size="large">
+            {{ isRegister ? '注册' : '登录' }}
+          </a-button>
+        </a-form-item>
+      </a-form>
+
+      <div v-if="error" class="error-msg">{{ error }}</div>
+    </a-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { message } from 'ant-design-vue'
+import { rsaEncrypt, fetchPublicKey } from '@/services/crypto'
+
+const router = useRouter()
+const isRegister = ref(false)
+const loading = ref(false)
+const error = ref('')
+
+// 提前获取 RSA 公钥,提升用户体验
+let publicKeyPem: string | undefined
+onMounted(async () => {
+  try {
+    publicKeyPem = await fetchPublicKey()
+  } catch {
+    // 公钥获取失败不影响后续操作(encrypt 时会再试)
+  }
+})
+
+const form = reactive({
+  username: '',
+  password: '',
+  confirmPassword: '',
+})
+
+const rules = {
+  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
+  password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
+  confirmPassword: [
+    { required: true, message: '请确认密码', trigger: 'blur' },
+    {
+      validator: (_: any, value: string) => {
+        if (isRegister.value && value !== form.password) {
+          return Promise.reject('两次密码不一致')
+        }
+        return Promise.resolve()
+      },
+      trigger: 'blur'
+    }
+  ],
+}
+
+async function handleSubmit() {
+  if (isRegister.value && form.password !== form.confirmPassword) {
+    error.value = '两次密码不一致'
+    return
+  }
+
+  loading.value = true
+  error.value = ''
+
+  try {
+    // RSA 加密密码
+    const encryptedPassword = await rsaEncrypt(form.password, publicKeyPem)
+
+    const endpoint = isRegister.value ? 'register' : 'login'
+    const baseUrl = import.meta.env.VITE_API_BASE_URL || 'https://localhost:8000'
+    const res = await fetch(`${baseUrl}/api/auth/${endpoint}`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      credentials: 'include',  // 让 cookie 可被设置
+      body: JSON.stringify({
+        username: form.username,
+        encrypted_password: encryptedPassword,
+      }),
+    })
+    const data = await res.json()
+
+    if (!res.ok) {
+      error.value = data.detail || '操作失败'
+      return
+    }
+
+    // 保存登录用户名(前端显示用,不影响认证)
+    localStorage.setItem('auth_username', data.username)
+
+    message.success(isRegister.value ? '注册成功' : '登录成功')
+
+    // 跳转到首页(带上登录标记)
+    router.push('/')
+  } catch (e: any) {
+    error.value = '网络错误,请检查后端是否启动'
+  } finally {
+    loading.value = false
+  }
+}
+</script>
+
+<style scoped>
+.login-container {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  min-height: 60vh;
+}
+.login-card {
+  width: 420px;
+  border-radius: 12px;
+  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
+}
+.error-msg {
+  color: #ff4d4f;
+  text-align: center;
+  margin-top: 8px;
+}
+</style>

+ 1640 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/src/views/Result.vue

@@ -0,0 +1,1640 @@
+<template>
+  <div class="result-container">
+    <!-- 页面头部 -->
+    <div class="page-header">
+      <a-button class="back-button" size="large" @click="goBack">
+        ← 返回首页
+      </a-button>
+      <a-space size="middle">
+        <a-button v-if="!editMode" @click="toggleEditMode" type="default">
+          ✏️ 编辑行程
+        </a-button>
+        <a-button v-else @click="saveChanges" type="primary">
+          💾 保存修改
+        </a-button>
+        <a-button v-if="editMode" @click="cancelEdit" type="default">
+          ❌ 取消编辑
+        </a-button>
+
+        <!-- 导出按钮 -->
+        <a-dropdown v-if="!editMode">
+          <template #overlay>
+            <a-menu>
+              <a-menu-item key="image" @click="exportAsImage">
+                📷 导出为图片
+              </a-menu-item>
+              <a-menu-item key="pdf" @click="exportAsPDF">
+                📄 导出为PDF
+              </a-menu-item>
+            </a-menu>
+          </template>
+          <a-button type="default">
+            📥 导出行程 <DownOutlined />
+          </a-button>
+        </a-dropdown>
+      </a-space>
+    </div>
+
+    <div v-if="tripPlan" class="content-wrapper">
+      <!-- 侧边导航 -->
+      <div class="side-nav">
+        <a-affix :offset-top="80">
+          <a-menu mode="inline" :selected-keys="[activeSection]" @click="scrollToSection">
+            <a-menu-item key="overview">
+              <span>📋 行程概览</span>
+            </a-menu-item>
+            <a-menu-item key="budget" v-if="tripPlan.budget">
+              <span>💰 预算明细</span>
+            </a-menu-item>
+            <a-menu-item key="map">
+              <span>📍 景点地图</span>
+            </a-menu-item>
+            <a-sub-menu key="days" title="📅 每日行程">
+              <a-menu-item v-for="(day, index) in tripPlan.days" :key="`day-${index}`">
+                第{{ day.day_index + 1 }}天
+              </a-menu-item>
+            </a-sub-menu>
+            <a-menu-item key="weather" v-if="tripPlan.weather_info && tripPlan.weather_info.length > 0">
+              <span>🌤️ 天气信息</span>
+            </a-menu-item>
+          </a-menu>
+        </a-affix>
+      </div>
+
+      <!-- 主内容区 -->
+      <div class="main-content">
+        <!-- 顶部信息区:左侧概览+预算,右侧地图 -->
+        <div class="top-info-section">
+          <!-- 左侧:行程概览和预算明细 -->
+          <div class="left-info">
+            <!-- 行程概览 -->
+            <a-card id="overview" :title="`${tripPlan.city}旅行计划`" :bordered="false" class="overview-card">
+              <div class="overview-content">
+                <div class="info-item">
+                  <span class="info-label">📅 日期:</span>
+                  <span class="info-value">{{ tripPlan.start_date }} 至 {{ tripPlan.end_date }}</span>
+                </div>
+                <div class="info-item">
+                  <span class="info-label">👥 出行人群:</span>
+                  <span class="info-value">
+                    <a-tag v-if="travelerGroup" color="purple" class="group-tag">{{ travelerGroup }}</a-tag>
+                    <span v-else style="color: #999;">未指定</span>
+                  </span>
+                </div>
+                <div class="info-item">
+                  <span class="info-label">💡 建议:</span>
+                  <span class="info-value">{{ tripPlan.overall_suggestions }}</span>
+                </div>
+              </div>
+            </a-card>
+
+            <!-- 预算明细 -->
+            <a-card id="budget" v-if="tripPlan.budget" title="💰 预算明细" :bordered="false" class="budget-card">
+              <div class="budget-grid">
+                <div class="budget-item">
+                  <div class="budget-label">景点门票</div>
+                  <div class="budget-value">¥{{ tripPlan.budget.total_attractions }}</div>
+                </div>
+                <div class="budget-item">
+                  <div class="budget-label">酒店住宿</div>
+                  <div class="budget-value">¥{{ tripPlan.budget.total_hotels }}</div>
+                </div>
+                <div class="budget-item">
+                  <div class="budget-label">餐饮费用</div>
+                  <div class="budget-value">¥{{ tripPlan.budget.total_meals }}</div>
+                </div>
+                <div class="budget-item">
+                  <div class="budget-label">交通费用</div>
+                  <div class="budget-value">¥{{ tripPlan.budget.total_transportation }}</div>
+                </div>
+              </div>
+              <div class="budget-total">
+                <span class="total-label">预估总费用</span>
+                <span class="total-value">¥{{ tripPlan.budget.total }}</span>
+              </div>
+            </a-card>
+          </div>
+
+          <!-- 右侧:地图 -->
+          <div class="right-map">
+            <a-card id="map" title="📍 景点地图" :bordered="false" class="map-card">
+              <div id="amap-container" style="width: 100%; height: 100%"></div>
+            </a-card>
+          </div>
+        </div>
+
+        <!-- 每日行程:可折叠 -->
+        <a-card title="📅 每日行程" :bordered="false" class="days-card">
+          <a-collapse v-model:activeKey="activeDays" accordion>
+            <a-collapse-panel
+              v-for="(day, index) in tripPlan.days"
+              :key="index"
+              :id="`day-${index}`"
+            >
+              <template #header>
+                <div class="day-header">
+                  <span class="day-title">第{{ day.day_index + 1 }}天</span>
+                  <span class="day-date">{{ day.date }}</span>
+                </div>
+              </template>
+
+              <!-- 行程基本信息 -->
+              <div class="day-info">
+                <div class="info-row">
+                  <span class="label">📝 行程描述:</span>
+                  <span class="value">{{ day.description }}</span>
+                </div>
+                <div class="info-row">
+                  <span class="label">🚗 交通方式:</span>
+                  <span class="value">{{ day.transportation }}</span>
+                </div>
+                <div class="info-row">
+                  <span class="label">🏨 住宿:</span>
+                  <span class="value">{{ day.accommodation }}</span>
+                </div>
+              </div>
+
+              <!-- 详细交通信息 -->
+              <a-divider v-if="day.transportation_details && day.transportation_details.length > 0" orientation="left">🚗 详细交通指南</a-divider>
+              <div v-if="day.transportation_details && day.transportation_details.length > 0" class="transport-timeline">
+                <div
+                  v-for="(seg, segIndex) in day.transportation_details"
+                  :key="segIndex"
+                  class="transport-item"
+                >
+                  <div class="transport-dot">
+                    <span class="transport-icon">{{ getTransportIcon(seg.type) }}</span>
+                  </div>
+                  <div class="transport-content">
+                    <div class="transport-header">
+                      <a-tag :color="getTransportColor(seg.type)" class="transport-tag">{{ seg.type }}</a-tag>
+                    </div>
+                    <div class="transport-instruction">{{ seg.instruction }}</div>
+                    <div class="transport-meta">
+                      <span class="transport-route">
+                        <span class="transport-stop">{{ seg.from_name }}</span>
+                        <span class="transport-arrow"> → </span>
+                        <span class="transport-stop">{{ seg.to_name }}</span>
+                      </span>
+                    </div>
+                    <div class="transport-stats">
+                      <span v-if="seg.duration" class="stat-item">⏱ {{ seg.duration }}分钟</span>
+                      <span v-if="seg.distance" class="stat-item">📏 {{ formatDistance(seg.distance) }}</span>
+                      <span v-if="seg.route_detail" class="stat-item route-detail">{{ seg.route_detail }}</span>
+                    </div>
+                  </div>
+                  <!-- 连线 -->
+                  <div v-if="segIndex < day.transportation_details.length - 1" class="transport-line"></div>
+                </div>
+              </div>
+
+              <!-- 景点安排 -->
+              <a-divider orientation="left">🎯 景点安排</a-divider>
+              <a-list
+                :data-source="day.attractions"
+                :grid="{ gutter: 16, column: 2 }"
+              >
+                <template #renderItem="{ item, index }">
+                  <a-list-item>
+                    <a-card :title="item.name" size="small" class="attraction-card">
+                      <!-- 编辑模式下的操作按钮 -->
+                      <template #extra v-if="editMode">
+                        <a-space>
+                          <a-button
+                            size="small"
+                            @click="moveAttraction(day.day_index, index, 'up')"
+                            :disabled="index === 0"
+                          >
+                            ↑
+                          </a-button>
+                          <a-button
+                            size="small"
+                            @click="moveAttraction(day.day_index, index, 'down')"
+                            :disabled="index === day.attractions.length - 1"
+                          >
+                            ↓
+                          </a-button>
+                          <a-button
+                            size="small"
+                            danger
+                            @click="deleteAttraction(day.day_index, index)"
+                          >
+                            🗑️
+                          </a-button>
+                        </a-space>
+                      </template>
+
+                      <!-- 景点图片 -->
+                      <div class="attraction-image-wrapper">
+                        <img
+                          :src="getAttractionImage(item.name, index)"
+                          :alt="item.name"
+                          class="attraction-image"
+                          @error="handleImageError"
+                        />
+                        <div class="attraction-badge">
+                          <span class="badge-number">{{ index + 1 }}</span>
+                        </div>
+                        <div v-if="item.ticket_price" class="price-tag">
+                          ¥{{ item.ticket_price }}
+                        </div>
+                      </div>
+
+                      <!-- 编辑模式下可编辑的字段 -->
+                      <div v-if="editMode">
+                        <p><strong>地址:</strong></p>
+                        <a-input v-model:value="item.address" size="small" style="margin-bottom: 8px" />
+
+                        <p><strong>游览时长(分钟):</strong></p>
+                        <a-input-number v-model:value="item.visit_duration" :min="10" :max="480" size="small" style="width: 100%; margin-bottom: 8px" />
+
+                        <p><strong>描述:</strong></p>
+                        <a-textarea v-model:value="item.description" :rows="2" size="small" style="margin-bottom: 8px" />
+                      </div>
+
+                      <!-- 查看模式 -->
+                      <div v-else>
+                        <p><strong>地址:</strong> {{ item.address }}</p>
+                        <p><strong>游览时长:</strong> {{ item.visit_duration }}分钟</p>
+                        <p><strong>描述:</strong> {{ item.description }}</p>
+                        <p v-if="item.rating"><strong>评分:</strong> {{ item.rating }}⭐</p>
+                      </div>
+                    </a-card>
+                  </a-list-item>
+                </template>
+              </a-list>
+
+              <!-- 酒店推荐 -->
+              <a-divider v-if="day.hotel" orientation="left">🏨 住宿推荐</a-divider>
+              <a-card v-if="day.hotel" size="small" class="hotel-card">
+                <template #title>
+                  <span class="hotel-title">{{ day.hotel.name }}</span>
+                </template>
+                <a-descriptions :column="2" size="small">
+                  <a-descriptions-item label="地址">{{ day.hotel.address }}</a-descriptions-item>
+                  <a-descriptions-item label="类型">{{ day.hotel.type }}</a-descriptions-item>
+                  <a-descriptions-item label="价格范围">{{ day.hotel.price_range }}</a-descriptions-item>
+                  <a-descriptions-item label="评分">{{ day.hotel.rating }}⭐</a-descriptions-item>
+                  <a-descriptions-item label="距离" :span="2">{{ day.hotel.distance }}</a-descriptions-item>
+                </a-descriptions>
+              </a-card>
+
+              <!-- 餐饮安排 -->
+              <a-divider orientation="left">🍽️ 餐饮安排</a-divider>
+              <a-descriptions :column="1" bordered size="small">
+                <a-descriptions-item
+                  v-for="meal in day.meals"
+                  :key="meal.type"
+                  :label="getMealLabel(meal.type)"
+                >
+                  {{ meal.name }}
+                  <span v-if="meal.description"> - {{ meal.description }}</span>
+                </a-descriptions-item>
+              </a-descriptions>
+            </a-collapse-panel>
+          </a-collapse>
+        </a-card>
+
+        <a-card id="weather" v-if="tripPlan.weather_info && tripPlan.weather_info.length > 0" title="天气信息" style="margin-top: 20px" :bordered="false">
+        <a-list
+          :data-source="tripPlan.weather_info"
+          :grid="{ gutter: 16, column: 3 }"
+        >
+          <template #renderItem="{ item }">
+            <a-list-item>
+              <a-card size="small" class="weather-card">
+                <div class="weather-date">{{ item.date }}</div>
+                <div class="weather-info-row">
+                  <span class="weather-icon">☀️</span>
+                  <div>
+                    <div class="weather-label">白天</div>
+                    <div class="weather-value">{{ item.day_weather }} {{ item.day_temp }}°C</div>
+                  </div>
+                </div>
+                <div class="weather-info-row">
+                  <span class="weather-icon">🌙</span>
+                  <div>
+                    <div class="weather-label">夜间</div>
+                    <div class="weather-value">{{ item.night_weather }} {{ item.night_temp }}°C</div>
+                  </div>
+                </div>
+                <div class="weather-wind">
+                  💨 {{ item.wind_direction }} {{ item.wind_power }}
+                </div>
+              </a-card>
+            </a-list-item>
+          </template>
+        </a-list>
+        </a-card>
+      </div>
+    </div>
+
+    <a-empty v-else description="没有找到旅行计划数据">
+      <template #image>
+        <div style="font-size: 80px;">🗺️</div>
+      </template>
+      <template #description>
+        <span style="color: #999;">暂无旅行计划数据,请先创建行程</span>
+      </template>
+      <a-button type="primary" @click="goBack">返回首页创建行程</a-button>
+    </a-empty>
+
+    <!-- 回到顶部按钮 -->
+    <a-back-top :visibility-height="300">
+      <div class="back-top-button">
+        ↑
+      </div>
+    </a-back-top>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, nextTick } from 'vue'
+import { useRouter } from 'vue-router'
+import { message } from 'ant-design-vue'
+import { DownOutlined } from '@ant-design/icons-vue'
+import AMapLoader from '@amap/amap-jsapi-loader'
+import html2canvas from 'html2canvas'
+import jsPDF from 'jspdf'
+import type { TripPlan } from '@/types'
+
+const router = useRouter()
+const tripPlan = ref<TripPlan | null>(null)
+const travelerGroup = ref('')
+const editMode = ref(false)
+const originalPlan = ref<TripPlan | null>(null)
+const attractionPhotos = ref<Record<string, string>>({})
+const activeSection = ref('overview')
+const activeDays = ref<number[]>([0]) // 默认展开第一天
+let map: any = null
+
+onMounted(async () => {
+  const data = sessionStorage.getItem('tripPlan')
+  const group = sessionStorage.getItem('travelerGroup')
+  if (group) {
+    travelerGroup.value = group
+  }
+  if (data) {
+    tripPlan.value = JSON.parse(data)
+    // 加载景点图片
+    await loadAttractionPhotos()
+    // 等待DOM渲染完成后初始化地图
+    await nextTick()
+    initMap()
+  }
+})
+
+const goBack = () => {
+  router.push('/')
+}
+
+// 交通方式图标映射
+const getTransportIcon = (type: string): string => {
+  const icons: Record<string, string> = {
+    '步行': '🚶',
+    '公交': '🚌',
+    '地铁': '🚇',
+    '出租车': '🚕',
+    '自驾': '🚗',
+    '骑行': '🚲',
+    '轮渡': '⛴️',
+    '缆车': '🚡'
+  }
+  return icons[type] || '🚗'
+}
+
+// 交通方式颜色映射
+const getTransportColor = (type: string): string => {
+  const colors: Record<string, string> = {
+    '步行': 'green',
+    '公交': 'blue',
+    '地铁': 'purple',
+    '出租车': 'orange',
+    '自驾': 'cyan',
+    '骑行': 'lime',
+    '轮渡': 'geekblue',
+    '缆车': 'gold'
+  }
+  return colors[type] || 'default'
+}
+
+// 格式化距离
+const formatDistance = (meters: number): string => {
+  if (meters >= 1000) {
+    return `${(meters / 1000).toFixed(1)}公里`
+  }
+  return `${meters}米`
+}
+
+// 滚动到指定区域
+const scrollToSection = ({ key }: { key: string }) => {
+  activeSection.value = key
+  const element = document.getElementById(key)
+  if (element) {
+    element.scrollIntoView({ behavior: 'smooth', block: 'start' })
+  }
+}
+
+// 切换编辑模式
+const toggleEditMode = () => {
+  editMode.value = true
+  // 保存原始数据用于取消编辑
+  originalPlan.value = JSON.parse(JSON.stringify(tripPlan.value))
+  message.info('进入编辑模式')
+}
+
+// 保存修改
+const saveChanges = () => {
+  editMode.value = false
+  // 更新sessionStorage
+  if (tripPlan.value) {
+    sessionStorage.setItem('tripPlan', JSON.stringify(tripPlan.value))
+  }
+  message.success('修改已保存')
+
+  // 重新初始化地图以反映更改
+  if (map) {
+    map.destroy()
+  }
+  nextTick(() => {
+    initMap()
+  })
+}
+
+// 取消编辑
+const cancelEdit = () => {
+  if (originalPlan.value) {
+    tripPlan.value = JSON.parse(JSON.stringify(originalPlan.value))
+  }
+  editMode.value = false
+  message.info('已取消编辑')
+}
+
+// 删除景点
+const deleteAttraction = (dayIndex: number, attrIndex: number) => {
+  if (!tripPlan.value) return
+
+  const day = tripPlan.value.days[dayIndex]
+  if (day.attractions.length <= 1) {
+    message.warning('每天至少需要保留一个景点')
+    return
+  }
+
+  day.attractions.splice(attrIndex, 1)
+  message.success('景点已删除')
+}
+
+// 移动景点顺序
+const moveAttraction = (dayIndex: number, attrIndex: number, direction: 'up' | 'down') => {
+  if (!tripPlan.value) return
+
+  const day = tripPlan.value.days[dayIndex]
+  const attractions = day.attractions
+
+  if (direction === 'up' && attrIndex > 0) {
+    [attractions[attrIndex], attractions[attrIndex - 1]] = [attractions[attrIndex - 1], attractions[attrIndex]]
+  } else if (direction === 'down' && attrIndex < attractions.length - 1) {
+    [attractions[attrIndex], attractions[attrIndex + 1]] = [attractions[attrIndex + 1], attractions[attrIndex]]
+  }
+}
+
+const getMealLabel = (type: string): string => {
+  const labels: Record<string, string> = {
+    breakfast: '早餐',
+    lunch: '午餐',
+    dinner: '晚餐',
+    snack: '小吃'
+  }
+  return labels[type] || type
+}
+
+// 加载所有景点图片
+const loadAttractionPhotos = async () => {
+  if (!tripPlan.value) return
+
+  const promises: Promise<void>[] = []
+
+  tripPlan.value.days.forEach(day => {
+    day.attractions.forEach(attraction => {
+      const baseUrl = import.meta.env.VITE_API_BASE_URL || 'https://localhost:8000'
+      const promise = fetch(`${baseUrl}/api/poi/photo?name=${encodeURIComponent(attraction.name)}`)
+        .then(res => res.json())
+        .then(data => {
+          if (data.success && data.data.photo_url) {
+            attractionPhotos.value[attraction.name] = data.data.photo_url
+          }
+        })
+        .catch(err => {
+          console.error(`获取${attraction.name}图片失败:`, err)
+        })
+
+      promises.push(promise)
+    })
+  })
+
+  await Promise.all(promises)
+}
+
+// 获取景点图片
+const getAttractionImage = (name: string, index: number): string => {
+  // 如果已加载真实图片,返回真实图片
+  if (attractionPhotos.value[name]) {
+    return attractionPhotos.value[name]
+  }
+
+  // 返回一个纯色占位图(避免跨域问题)
+  const colors = [
+    { start: '#667eea', end: '#764ba2' },
+    { start: '#f093fb', end: '#f5576c' },
+    { start: '#4facfe', end: '#00f2fe' },
+    { start: '#43e97b', end: '#38f9d7' },
+    { start: '#fa709a', end: '#fee140' }
+  ]
+  const colorIndex = index % colors.length
+  const { start, end } = colors[colorIndex]
+
+  // 使用base64编码避免中文问题
+  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300">
+    <defs>
+      <linearGradient id="grad${index}" x1="0%" y1="0%" x2="100%" y2="100%">
+        <stop offset="0%" style="stop-color:${start};stop-opacity:1" />
+        <stop offset="100%" style="stop-color:${end};stop-opacity:1" />
+      </linearGradient>
+    </defs>
+    <rect width="400" height="300" fill="url(#grad${index})"/>
+    <text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="sans-serif" font-size="24" font-weight="bold" fill="white">${name}</text>
+  </svg>`
+
+  return `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svg)))}`
+}
+
+// 图片加载失败时的处理
+const handleImageError = (event: Event) => {
+  const img = event.target as HTMLImageElement
+  // 使用灰色占位图
+  img.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="400" height="300"%3E%3Crect width="400" height="300" fill="%23f0f0f0"/%3E%3Ctext x="50%25" y="50%25" dominant-baseline="middle" text-anchor="middle" font-family="sans-serif" font-size="18" fill="%23999"%3E图片加载失败%3C/text%3E%3C/svg%3E'
+}
+
+
+
+// 导出为图片
+const exportAsImage = async () => {
+  try {
+    message.loading({ content: '正在生成图片...', key: 'export', duration: 0 })
+
+    const element = document.querySelector('.main-content') as HTMLElement
+    if (!element) {
+      throw new Error('未找到内容元素')
+    }
+
+    // 创建一个独立的容器
+    const exportContainer = document.createElement('div')
+    exportContainer.style.width = element.offsetWidth + 'px'
+    exportContainer.style.backgroundColor = '#f5f7fa'
+    exportContainer.style.padding = '20px'
+
+    // 复制所有内容
+    exportContainer.innerHTML = element.innerHTML
+
+    // 处理地图截图
+    const mapContainer = document.getElementById('amap-container')
+    if (mapContainer && map) {
+      const mapCanvas = mapContainer.querySelector('canvas')
+      if (mapCanvas) {
+        const mapSnapshot = mapCanvas.toDataURL('image/png')
+        const exportMapContainer = exportContainer.querySelector('#amap-container')
+        if (exportMapContainer) {
+          exportMapContainer.innerHTML = `<img src="${mapSnapshot}" style="width:100%;height:100%;object-fit:cover;" />`
+        }
+      }
+    }
+
+    // 移除所有ant-card类,替换为纯div
+    const cards = exportContainer.querySelectorAll('.ant-card')
+    cards.forEach((card) => {
+      const cardEl = card as HTMLElement
+      try {
+        cardEl.className = '' // 移除所有类
+        cardEl.style.setProperty('background-color', '#ffffff')
+        cardEl.style.setProperty('border-radius', '12px')
+        cardEl.style.setProperty('box-shadow', '0 4px 12px rgba(0, 0, 0, 0.1)')
+        cardEl.style.setProperty('margin-bottom', '20px')
+        cardEl.style.setProperty('overflow', 'hidden')
+      } catch (err) {
+        console.error('设置卡片样式失败:', err)
+      }
+    })
+
+    // 处理卡片头部
+    const cardHeads = exportContainer.querySelectorAll('.ant-card-head')
+    cardHeads.forEach((head) => {
+      const headEl = head as HTMLElement
+      try {
+        headEl.style.setProperty('background-color', '#667eea')
+        headEl.style.setProperty('color', '#ffffff')
+        headEl.style.setProperty('padding', '16px 24px')
+        headEl.style.setProperty('font-size', '18px')
+        headEl.style.setProperty('font-weight', '600')
+      } catch (err) {
+        console.error('设置卡片头部样式失败:', err)
+      }
+    })
+
+    // 处理卡片内容
+    const cardBodies = exportContainer.querySelectorAll('.ant-card-body')
+    cardBodies.forEach((body) => {
+      const bodyEl = body as HTMLElement
+      bodyEl.style.setProperty('background-color', '#ffffff')
+      bodyEl.style.setProperty('padding', '24px')
+    })
+
+    // 处理酒店卡片头部
+    const hotelCards = exportContainer.querySelectorAll('.hotel-card')
+    hotelCards.forEach((card) => {
+      const head = card.querySelector('.ant-card-head') as HTMLElement
+      if (head) {
+        head.style.setProperty('background-color', '#1976d2')
+      }
+      (card as HTMLElement).style.setProperty('background-color', '#e3f2fd')
+    })
+
+    // 处理天气卡片
+    const weatherCards = exportContainer.querySelectorAll('.weather-card')
+    weatherCards.forEach((card) => {
+      (card as HTMLElement).style.setProperty('background-color', '#e0f7fa')
+    })
+
+    // 处理预算总计
+    const budgetTotal = exportContainer.querySelector('.budget-total')
+    if (budgetTotal) {
+      const el = budgetTotal as HTMLElement
+      el.style.setProperty('background-color', '#667eea')
+      el.style.setProperty('color', '#ffffff')
+      el.style.setProperty('padding', '20px')
+      el.style.setProperty('border-radius', '12px')
+      el.style.setProperty('margin-bottom', '20px')
+    }
+
+    // 处理预算项
+    const budgetItems = exportContainer.querySelectorAll('.budget-item')
+    budgetItems.forEach((item) => {
+      const el = item as HTMLElement
+      el.style.setProperty('background-color', '#f5f7fa')
+      el.style.setProperty('padding', '16px')
+      el.style.setProperty('border-radius', '8px')
+      el.style.setProperty('margin-bottom', '12px')
+    })
+
+    // 添加到body(隐藏)
+    exportContainer.style.position = 'absolute'
+    exportContainer.style.left = '-9999px'
+    document.body.appendChild(exportContainer)
+
+    const canvas = await html2canvas(exportContainer, {
+      backgroundColor: '#f5f7fa',
+      scale: 2,
+      logging: false,
+      useCORS: true,
+      allowTaint: true
+    })
+
+    // 移除容器
+    document.body.removeChild(exportContainer)
+
+    // 转换为图片并下载
+    const link = document.createElement('a')
+    link.download = `旅行计划_${tripPlan.value?.city}_${new Date().getTime()}.png`
+    link.href = canvas.toDataURL('image/png')
+    link.click()
+
+    message.success({ content: '图片导出成功!', key: 'export' })
+  } catch (error: any) {
+    console.error('导出图片失败:', error)
+    message.error({ content: `导出图片失败: ${error.message}`, key: 'export' })
+  }
+}
+
+// 导出为PDF
+const exportAsPDF = async () => {
+  try {
+    message.loading({ content: '正在生成PDF...', key: 'export', duration: 0 })
+
+    const element = document.querySelector('.main-content') as HTMLElement
+    if (!element) {
+      throw new Error('未找到内容元素')
+    }
+
+    // 创建一个独立的容器
+    const exportContainer = document.createElement('div')
+    exportContainer.style.width = element.offsetWidth + 'px'
+    exportContainer.style.backgroundColor = '#f5f7fa'
+    exportContainer.style.padding = '20px'
+
+    // 复制所有内容
+    exportContainer.innerHTML = element.innerHTML
+
+    // 处理地图截图
+    const mapContainer = document.getElementById('amap-container')
+    if (mapContainer && map) {
+      const mapCanvas = mapContainer.querySelector('canvas')
+      if (mapCanvas) {
+        const mapSnapshot = mapCanvas.toDataURL('image/png')
+        const exportMapContainer = exportContainer.querySelector('#amap-container')
+        if (exportMapContainer) {
+          exportMapContainer.innerHTML = `<img src="${mapSnapshot}" style="width:100%;height:100%;object-fit:cover;" />`
+        }
+      }
+    }
+
+    // 移除所有ant-card类,替换为纯div
+    const cards = exportContainer.querySelectorAll('.ant-card')
+    cards.forEach((card) => {
+      const cardEl = card as HTMLElement
+      try {
+        cardEl.className = ''
+        cardEl.style.setProperty('background-color', '#ffffff')
+        cardEl.style.setProperty('border-radius', '12px')
+        cardEl.style.setProperty('box-shadow', '0 4px 12px rgba(0, 0, 0, 0.1)')
+        cardEl.style.setProperty('margin-bottom', '20px')
+        cardEl.style.setProperty('overflow', 'hidden')
+      } catch (err) {
+        console.error('设置卡片样式失败:', err)
+      }
+    })
+
+    // 处理卡片头部
+    const cardHeads = exportContainer.querySelectorAll('.ant-card-head')
+    cardHeads.forEach((head) => {
+      const headEl = head as HTMLElement
+      try {
+        headEl.style.setProperty('background-color', '#667eea')
+        headEl.style.setProperty('color', '#ffffff')
+        headEl.style.setProperty('padding', '16px 24px')
+        headEl.style.setProperty('font-size', '18px')
+        headEl.style.setProperty('font-weight', '600')
+      } catch (err) {
+        console.error('设置卡片头部样式失败:', err)
+      }
+    })
+
+    // 处理卡片内容
+    const cardBodies = exportContainer.querySelectorAll('.ant-card-body')
+    cardBodies.forEach((body) => {
+      const bodyEl = body as HTMLElement
+      bodyEl.style.setProperty('background-color', '#ffffff')
+      bodyEl.style.setProperty('padding', '24px')
+    })
+
+    // 处理酒店卡片头部
+    const hotelCards = exportContainer.querySelectorAll('.hotel-card')
+    hotelCards.forEach((card) => {
+      const head = card.querySelector('.ant-card-head') as HTMLElement
+      if (head) {
+        head.style.setProperty('background-color', '#1976d2')
+      }
+      (card as HTMLElement).style.setProperty('background-color', '#e3f2fd')
+    })
+
+    // 处理天气卡片
+    const weatherCards = exportContainer.querySelectorAll('.weather-card')
+    weatherCards.forEach((card) => {
+      (card as HTMLElement).style.setProperty('background-color', '#e0f7fa')
+    })
+
+    // 处理预算总计
+    const budgetTotal = exportContainer.querySelector('.budget-total')
+    if (budgetTotal) {
+      const el = budgetTotal as HTMLElement
+      el.style.setProperty('background-color', '#667eea')
+      el.style.setProperty('color', '#ffffff')
+      el.style.setProperty('padding', '20px')
+      el.style.setProperty('border-radius', '12px')
+      el.style.setProperty('margin-bottom', '20px')
+    }
+
+    // 处理预算项
+    const budgetItems = exportContainer.querySelectorAll('.budget-item')
+    budgetItems.forEach((item) => {
+      const el = item as HTMLElement
+      el.style.setProperty('background-color', '#f5f7fa')
+      el.style.setProperty('padding', '16px')
+      el.style.setProperty('border-radius', '8px')
+      el.style.setProperty('margin-bottom', '12px')
+    })
+
+    // 添加到body(隐藏)
+    exportContainer.style.position = 'absolute'
+    exportContainer.style.left = '-9999px'
+    document.body.appendChild(exportContainer)
+
+    const canvas = await html2canvas(exportContainer, {
+      backgroundColor: '#f5f7fa',
+      scale: 2,
+      logging: false,
+      useCORS: true,
+      allowTaint: true
+    })
+
+    // 移除容器
+    document.body.removeChild(exportContainer)
+
+    const imgData = canvas.toDataURL('image/png')
+    const pdf = new jsPDF({
+      orientation: 'portrait',
+      unit: 'mm',
+      format: 'a4'
+    })
+
+    const imgWidth = 210 // A4宽度(mm)
+    const imgHeight = (canvas.height * imgWidth) / canvas.width
+
+    // 如果内容高度超过一页,分页处理
+    let heightLeft = imgHeight
+    let position = 0
+
+    pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
+    heightLeft -= 297 // A4高度
+
+    while (heightLeft > 0) {
+      position = heightLeft - imgHeight
+      pdf.addPage()
+      pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
+      heightLeft -= 297
+    }
+
+    pdf.save(`旅行计划_${tripPlan.value?.city}_${new Date().getTime()}.pdf`)
+
+    message.success({ content: 'PDF导出成功!', key: 'export' })
+  } catch (error: any) {
+    console.error('导出PDF失败:', error)
+    message.error({ content: `导出PDF失败: ${error.message}`, key: 'export' })
+  }
+}
+
+// 截取地图图片
+const captureMapImage = async () => {
+  if (!map) return
+
+  try {
+    // 获取地图容器
+    const mapContainer = document.getElementById('amap-container')
+    if (!mapContainer) return
+
+    // 使用高德地图的截图功能
+    const mapCanvas = mapContainer.querySelector('canvas')
+    if (mapCanvas) {
+      // 创建一个img元素替换地图容器
+      const img = document.createElement('img')
+      img.src = mapCanvas.toDataURL('image/png')
+      img.style.width = '100%'
+      img.style.height = '500px'
+      img.style.objectFit = 'cover'
+      img.id = 'map-snapshot'
+
+      // 隐藏原地图,显示截图
+      mapContainer.style.display = 'none'
+      mapContainer.parentElement?.appendChild(img)
+    }
+  } catch (error) {
+    console.error('截取地图失败:', error)
+  }
+}
+
+// 恢复地图
+const restoreMap = () => {
+  const mapContainer = document.getElementById('amap-container')
+  const snapshot = document.getElementById('map-snapshot')
+
+  if (mapContainer) {
+    mapContainer.style.display = 'block'
+  }
+
+  if (snapshot) {
+    snapshot.remove()
+  }
+}
+
+// 初始化地图
+const initMap = async () => {
+  try {
+    const AMap = await AMapLoader.load({
+      key: import.meta.env.VITE_AMAP_WEB_JS_KEY,  // 高德地图Web端(JS API) Key
+      version: '2.0',
+      plugins: ['AMap.Marker', 'AMap.Polyline', 'AMap.InfoWindow']
+    })
+
+    // 创建地图实例
+    map = new AMap.Map('amap-container', {
+      zoom: 12,
+      center: [116.397128, 39.916527], // 默认中心点(北京)
+      viewMode: '3D'
+    })
+
+    // 添加景点标记
+    addAttractionMarkers(AMap)
+
+    message.success('地图加载成功')
+  } catch (error) {
+    console.error('地图加载失败:', error)
+    message.error('地图加载失败')
+  }
+}
+
+// 添加景点标记
+const addAttractionMarkers = (AMap: any) => {
+  if (!tripPlan.value) return
+
+  const markers: any[] = []
+  const allAttractions: any[] = []
+
+  // 收集所有景点
+  tripPlan.value.days.forEach((day, dayIndex) => {
+    day.attractions.forEach((attraction, attrIndex) => {
+      if (attraction.location && attraction.location.longitude && attraction.location.latitude) {
+        allAttractions.push({
+          ...attraction,
+          dayIndex,
+          attrIndex
+        })
+      }
+    })
+  })
+
+  // 创建标记
+  allAttractions.forEach((attraction, index) => {
+    const marker = new AMap.Marker({
+      position: [attraction.location.longitude, attraction.location.latitude],
+      title: attraction.name,
+      label: {
+        content: `<div style="background: #4CAF50; color: white; padding: 4px 8px; border-radius: 4px; font-size: 12px;">${index + 1}</div>`,
+        offset: new AMap.Pixel(0, -30)
+      }
+    })
+
+    // 创建信息窗口
+    const infoWindow = new AMap.InfoWindow({
+      content: `
+        <div style="padding: 10px;">
+          <h4 style="margin: 0 0 8px 0;">${attraction.name}</h4>
+          <p style="margin: 4px 0;"><strong>地址:</strong> ${attraction.address}</p>
+          <p style="margin: 4px 0;"><strong>游览时长:</strong> ${attraction.visit_duration}分钟</p>
+          <p style="margin: 4px 0;"><strong>描述:</strong> ${attraction.description}</p>
+          <p style="margin: 4px 0; color: #1890ff;"><strong>第${attraction.dayIndex + 1}天 景点${attraction.attrIndex + 1}</strong></p>
+        </div>
+      `,
+      offset: new AMap.Pixel(0, -30)
+    })
+
+    // 点击标记显示信息窗口
+    marker.on('click', () => {
+      infoWindow.open(map, marker.getPosition())
+    })
+
+    markers.push(marker)
+  })
+
+  // 添加标记到地图
+  map.add(markers)
+
+  // 自动调整视野以包含所有标记
+  if (allAttractions.length > 0) {
+    map.setFitView(markers)
+  }
+
+  // 绘制路线
+  drawRoutes(AMap, allAttractions)
+}
+
+// 绘制路线
+const drawRoutes = (AMap: any, attractions: any[]) => {
+  if (attractions.length < 2) return
+
+  // 按天分组绘制路线
+  const dayGroups: any = {}
+  attractions.forEach(attr => {
+    if (!dayGroups[attr.dayIndex]) {
+      dayGroups[attr.dayIndex] = []
+    }
+    dayGroups[attr.dayIndex].push(attr)
+  })
+
+  // 为每天的景点绘制路线
+  Object.values(dayGroups).forEach((dayAttractions: any) => {
+    if (dayAttractions.length < 2) return
+
+    const path = dayAttractions.map((attr: any) => [
+      attr.location.longitude,
+      attr.location.latitude
+    ])
+
+    const polyline = new AMap.Polyline({
+      path: path,
+      strokeColor: '#1890ff',
+      strokeWeight: 4,
+      strokeOpacity: 0.8,
+      strokeStyle: 'solid',
+      showDir: true // 显示方向箭头
+    })
+
+    map.add(polyline)
+  })
+}
+</script>
+
+<style scoped>
+.result-container {
+  min-height: 100vh;
+  background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
+  padding: 40px 20px;
+}
+
+.page-header {
+  max-width: 1200px;
+  margin: 0 auto 30px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  animation: fadeInDown 0.6s ease-out;
+}
+
+.back-button {
+  border-radius: 8px;
+  font-weight: 500;
+}
+
+/* 内容布局 */
+.content-wrapper {
+  max-width: 1400px;
+  margin: 0 auto;
+  display: flex;
+  gap: 24px;
+}
+
+.side-nav {
+  width: 240px;
+  flex-shrink: 0;
+}
+
+.side-nav :deep(.ant-menu) {
+  border-radius: 12px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+  background: white;
+}
+
+.side-nav :deep(.ant-menu-item) {
+  margin: 4px 8px;
+  border-radius: 8px;
+  transition: all 0.3s ease;
+}
+
+.side-nav :deep(.ant-menu-item-selected) {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: white;
+}
+
+.side-nav :deep(.ant-menu-item:hover) {
+  background: rgba(102, 126, 234, 0.1);
+}
+
+.main-content {
+  flex: 1;
+  min-width: 0;
+}
+
+/* 景点图片样式 */
+.attraction-image-wrapper {
+  position: relative;
+  margin-bottom: 12px;
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+.attraction-image {
+  width: 100%;
+  height: 200px;
+  object-fit: cover;
+  transition: transform 0.3s ease;
+}
+
+.attraction-image-wrapper:hover .attraction-image {
+  transform: scale(1.05);
+}
+
+.attraction-badge {
+  position: absolute;
+  top: 12px;
+  left: 12px;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: white;
+  width: 36px;
+  height: 36px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: bold;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+}
+
+.badge-number {
+  font-size: 18px;
+}
+
+.price-tag {
+  position: absolute;
+  top: 12px;
+  right: 12px;
+  background: rgba(255, 77, 79, 0.9);
+  color: white;
+  padding: 4px 12px;
+  border-radius: 12px;
+  font-weight: bold;
+  font-size: 14px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+}
+
+/* 天气卡片样式 */
+.weather-card {
+  background: linear-gradient(135deg, #e0f7fa 0%, #b2ebf2 100%);
+  border: none !important;
+  transition: all 0.3s ease;
+}
+
+.weather-card:hover {
+  transform: translateY(-4px);
+  box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
+}
+
+.weather-date {
+  font-size: 16px;
+  font-weight: bold;
+  color: #00796b;
+  margin-bottom: 12px;
+  text-align: center;
+}
+
+.weather-info-row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 8px;
+}
+
+.weather-icon {
+  font-size: 24px;
+}
+
+.weather-label {
+  font-size: 12px;
+  color: #666;
+}
+
+.weather-value {
+  font-size: 16px;
+  font-weight: 600;
+  color: #00796b;
+}
+
+.weather-wind {
+  margin-top: 8px;
+  padding-top: 8px;
+  border-top: 1px solid rgba(0, 121, 107, 0.2);
+  text-align: center;
+  color: #00796b;
+  font-size: 14px;
+}
+
+/* 回到顶部按钮 */
+.back-top-button {
+  width: 50px;
+  height: 50px;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: white;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 24px;
+  font-weight: bold;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+  cursor: pointer;
+  transition: all 0.3s ease;
+}
+
+.back-top-button:hover {
+  transform: scale(1.1);
+  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
+}
+
+/* 酒店卡片样式 */
+.hotel-card {
+  background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
+  border: none !important;
+}
+
+.hotel-card :deep(.ant-card-head) {
+  background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
+}
+
+.hotel-title {
+  color: white !important;
+  font-weight: 600;
+}
+
+/* 顶部信息区布局 */
+.top-info-section {
+  display: flex;
+  gap: 20px;
+  margin-bottom: 20px;
+}
+
+.left-info {
+  flex: 0 0 400px;
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+}
+
+.right-map {
+  flex: 1;
+}
+
+/* 行程概览卡片 */
+.overview-card {
+  height: fit-content;
+}
+
+.overview-content {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.info-item {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.info-label {
+  font-size: 14px;
+  font-weight: 600;
+  color: #666;
+}
+
+.info-value {
+  font-size: 15px;
+  color: #333;
+  line-height: 1.6;
+}
+
+.group-tag {
+  font-size: 14px;
+  padding: 2px 12px;
+  border-radius: 12px;
+}
+
+/* 预算卡片 */
+.budget-card {
+  height: fit-content;
+}
+
+.budget-grid {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 16px;
+  margin-bottom: 16px;
+}
+
+.budget-item {
+  text-align: center;
+  padding: 12px;
+  background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
+  border-radius: 8px;
+  border: 1px solid #e8e8e8;
+}
+
+.budget-label {
+  font-size: 13px;
+  color: #666;
+  margin-bottom: 8px;
+}
+
+.budget-value {
+  font-size: 20px;
+  font-weight: 700;
+  color: #1890ff;
+}
+
+.budget-total {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16px;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  border-radius: 8px;
+  color: white;
+}
+
+.total-label {
+  font-size: 16px;
+  font-weight: 600;
+}
+
+.total-value {
+  font-size: 28px;
+  font-weight: 700;
+}
+
+/* 地图卡片 */
+.map-card {
+  height: 100%;
+  min-height: 500px;
+}
+
+.map-card :deep(.ant-card-body) {
+  height: calc(100% - 57px);
+  padding: 0;
+}
+
+/* 每日行程卡片 */
+.days-card {
+  margin-top: 20px;
+}
+
+.day-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  width: 100%;
+}
+
+.day-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: #333;
+}
+
+.day-date {
+  font-size: 14px;
+  color: #999;
+}
+
+.day-info {
+  margin-bottom: 20px;
+  padding: 16px;
+  background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
+  border-radius: 8px;
+  border: 1px solid #e8e8e8;
+}
+
+.info-row {
+  display: flex;
+  gap: 12px;
+  margin-bottom: 8px;
+}
+
+.info-row:last-child {
+  margin-bottom: 0;
+}
+
+.info-row .label {
+  font-weight: 600;
+  color: #666;
+  min-width: 100px;
+}
+
+.info-row .value {
+  color: #333;
+  flex: 1;
+}
+
+/* 卡片样式优化 */
+:deep(.ant-card) {
+  border-radius: 12px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+  margin-bottom: 20px;
+  transition: all 0.3s ease;
+  animation: fadeInUp 0.6s ease-out;
+}
+
+:deep(.ant-card:hover) {
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+}
+
+:deep(.ant-card-head) {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: white !important;
+  border-radius: 12px 12px 0 0;
+  font-weight: 600;
+}
+
+:deep(.ant-card-head-title) {
+  color: white !important;
+  font-size: 18px;
+}
+
+:deep(.ant-card-head-title span) {
+  color: white !important;
+}
+
+/* Collapse样式 */
+:deep(.ant-collapse) {
+  border: none;
+  background: transparent;
+}
+
+:deep(.ant-collapse-item) {
+  margin-bottom: 16px;
+  border: 1px solid #e8e8e8;
+  border-radius: 12px;
+  overflow: hidden;
+}
+
+:deep(.ant-collapse-header) {
+  background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
+  padding: 16px 20px !important;
+  font-weight: 600;
+}
+
+:deep(.ant-collapse-content) {
+  border-top: 1px solid #e8e8e8;
+}
+
+:deep(.ant-collapse-content-box) {
+  padding: 20px;
+}
+
+/* 交通信息时间线 */
+.transport-timeline {
+  padding: 16px 0;
+  position: relative;
+}
+
+.transport-item {
+  display: flex;
+  align-items: flex-start;
+  gap: 16px;
+  padding: 12px 16px;
+  margin-bottom: 4px;
+  background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
+  border-radius: 12px;
+  border: 1px solid #e8eeff;
+  transition: all 0.3s ease;
+  position: relative;
+}
+
+.transport-item:hover {
+  transform: translateX(4px);
+  box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
+}
+
+.transport-dot {
+  flex-shrink: 0;
+  width: 40px;
+  height: 40px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: white;
+  border-radius: 50%;
+  box-shadow: 0 2px 8px rgba(102, 126, 234, 0.2);
+  z-index: 2;
+}
+
+.transport-icon {
+  font-size: 20px;
+}
+
+.transport-content {
+  flex: 1;
+  min-width: 0;
+}
+
+.transport-header {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 6px;
+}
+
+.transport-tag {
+  font-size: 12px;
+  padding: 0 8px;
+  border-radius: 4px;
+}
+
+.transport-time {
+  font-size: 13px;
+  color: #667eea;
+  font-weight: 500;
+}
+
+.transport-instruction {
+  font-size: 15px;
+  font-weight: 500;
+  color: #333;
+  margin-bottom: 6px;
+  line-height: 1.5;
+}
+
+.transport-meta {
+  margin-bottom: 4px;
+}
+
+.transport-route {
+  font-size: 13px;
+  color: #666;
+}
+
+.transport-stop {
+  font-weight: 500;
+  color: #555;
+  padding: 2px 6px;
+  background: white;
+  border-radius: 4px;
+}
+
+.transport-arrow {
+  color: #667eea;
+  margin: 0 4px;
+}
+
+.transport-stats {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  margin-top: 4px;
+}
+
+.stat-item {
+  font-size: 12px;
+  color: #888;
+  display: flex;
+  align-items: center;
+  gap: 2px;
+}
+
+.route-detail {
+  color: #667eea;
+  font-style: italic;
+}
+
+/* 统计卡片样式 */
+:deep(.ant-statistic-title) {
+  font-size: 14px;
+  color: #666;
+  margin-bottom: 8px;
+}
+
+:deep(.ant-statistic-content) {
+  font-size: 24px;
+  font-weight: 600;
+  color: #1890ff;
+}
+
+/* 景点卡片样式 */
+:deep(.ant-list-item) {
+  transition: all 0.3s ease;
+}
+
+:deep(.ant-list-item:hover) {
+  transform: scale(1.02);
+}
+
+/* 动画 */
+@keyframes fadeInDown {
+  from {
+    opacity: 0;
+    transform: translateY(-20px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@keyframes fadeInUp {
+  from {
+    opacity: 0;
+    transform: translateY(20px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+  .result-container {
+    padding: 20px 10px;
+  }
+
+  .page-header {
+    flex-direction: column;
+    gap: 16px;
+  }
+}
+</style>
+

+ 32 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/tsconfig.json

@@ -0,0 +1,32 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "lib": ["ES2020", "DOM", "DOM.Iterable"],
+    "skipLibCheck": true,
+
+    /* Bundler mode */
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "isolatedModules": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+    "jsx": "preserve",
+
+    /* Linting */
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true,
+    "noUncheckedSideEffectImports": true,
+
+    /* Path mapping */
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["src/*"]
+    }
+  },
+  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
+}
+

+ 29 - 0
Co-creation-projects/2248652135-Trip-Planner-Pro/frontend/vite.config.ts

@@ -0,0 +1,29 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import { resolve } from 'path'
+import fs from 'fs'
+
+// https://vite.dev/config/
+export default defineConfig({
+  plugins: [vue()],
+  resolve: {
+    alias: {
+      '@': resolve(__dirname, 'src')
+    }
+  },
+  server: {
+    port: 5173,
+    https: {
+      cert: fs.readFileSync(resolve(__dirname, '../backend/certs/cert.pem')),
+      key: fs.readFileSync(resolve(__dirname, '../backend/certs/key.pem')),
+    },
+    proxy: {
+      '/api': {
+        target: 'https://localhost:8000',
+        changeOrigin: true,
+        secure: false,  // 开发环境使用自签名证书,设为false跳过校验
+      }
+    }
+  }
+})
+