diff --git a/.kiro/specs/pncyssd-refactor/design.md b/.kiro/specs/pncyssd-refactor/design.md new file mode 100644 index 0000000..bf07b1b --- /dev/null +++ b/.kiro/specs/pncyssd-refactor/design.md @@ -0,0 +1,405 @@ +# Design Document: PncyssD 页面重构 + +## Overview + +本设计文档描述了将 PncyssD 原型页面重构为 course-web 项目规范的 React 组件的技术方案。重构将保持原有的视觉设计风格(深色主题、玻璃拟态、橙色强调色)和后端接口调用逻辑,同时采用 React + Vite + Tailwind CSS 的现代前端架构。 + +## Architecture + +### 整体架构 + +``` +PncyssD/ → course-web/src/ +├── index.html ├── main.jsx (入口) +├── index.js (App 入口) ├── App.jsx (路由控制) +├── state.js (状态管理) ├── utils/store.js (已存在) +├── login.js (登录页) ├── pages/LoginPage.jsx (重构) +├── onboarding.js (引导页) ├── pages/OnboardingPage.jsx (重构) +├── dashboard.js (仪表盘) ├── pages/DashboardPage.jsx (重构) +├── components.js (UI组件) ├── components/ui/*.jsx (复用) +├── api.js (AI服务) ├── utils/aiLogic.js (重构) +└── style.css (样式) └── index.css (合并样式) +``` + +### 页面流程 + +```mermaid +stateDiagram-v2 + [*] --> LandingPage: 首次访问 + LandingPage --> LoginPage: 点击登录 + LoginPage --> OnboardingPage: 登录成功(新用户) + LoginPage --> DashboardPage: 登录成功(老用户) + OnboardingPage --> DashboardPage: 完成引导 + DashboardPage --> TimelineView: 默认视图 + DashboardPage --> ScriptView: 点击爽文剧本 + DashboardPage --> PathView: 点击实现路径 +``` + +## Components and Interfaces + +### 1. 登录页面组件 (LoginPage.jsx) + +```jsx +/** + * 登录页面组件 + * 提供手机号验证码登录功能 + */ +interface LoginPageProps { + onLoginSuccess: () => void; // 登录成功回调 + onBack: () => void; // 返回首页回调 +} + +// 内部状态 +interface LoginState { + phone: string; // 手机号 + code: string; // 验证码 + countdown: number; // 倒计时秒数 + loading: boolean; // 登录中状态 + error: string; // 错误信息 +} +``` + +### 2. 引导页面组件 (OnboardingPage.jsx) + +```jsx +/** + * 引导流程页面组件 + * 5步骤用户信息采集 + */ +interface OnboardingPageProps { + onFinish: () => void; // 完成引导回调 +} + +// 步骤数据结构 +interface OnboardingFormData { + // Step 1: 基本信息 + nickname: string; + gender: 'male' | 'female' | 'secret'; + zodiac: string; + mbti: string; + hobbies: string[]; + + // Step 2-4: 人生记忆 + history: { + childhood: { date: string; content: string }; + peak: { date: string; content: string }; + valley: { date: string; content: string }; + }; + + // Step 5: 未来愿景 + futureVision: string; +} + +// 灵感标签配置 +const INSPIRATION_TAGS = { + childhood: ['秋千', '晚霞', '糖果', '奔跑', '蝉鸣', '雨后泥土', '旧书包', '风筝'], + peak: ['海浪', '拥抱', '掌声', '晨曦', '破土而出', '默契', '星空', '释放'], + valley: ['落叶', '雨伞', '长廊', '深呼吸', '自愈', '沉潜', '坚韧', '等待', '破茧'] +}; +``` + +### 3. 仪表盘页面组件 (DashboardPage.jsx) + +```jsx +/** + * 仪表盘页面组件 + * 包含导航栏和内容区域 + */ +interface DashboardState { + activeTab: 'timeline' | 'script' | 'path'; + isMobileMenuOpen: boolean; + isUserMenuOpen: boolean; + isMusicPlaying: boolean; +} + +// 导航项配置 +const NAV_ITEMS = [ + { id: 'timeline', icon: BookOpen, label: '生命长河' }, + { id: 'script', icon: Film, label: '爽文剧本' }, + { id: 'path', icon: Map, label: '实现路径' } +]; +``` + +### 4. 生命长河视图组件 (TimelineView.jsx) + +```jsx +/** + * 生命长河视图组件 + * 时间线形式展示人生事件 + */ +interface LifeEvent { + id: number; + title: string; + time: string; + content: string; + aiFeedback: string; +} + +interface TimelineViewProps { + events: LifeEvent[]; + onAddEvent: (event: Omit) => Promise; +} +``` + +### 5. 爽文剧本视图组件 (ScriptView.jsx) + +```jsx +/** + * 爽文剧本视图组件 + * AI生成个性化剧本 + */ +interface Script { + id: number; + theme: string; + style: string; + length: string; + content: string; + date: string; +} + +interface ScriptParams { + theme: string; + style: '都市' | '古风' | '爱情' | '科幻' | '喜剧' | '悬疑' | '恐怖'; + length: '短' | '中' | '长'; +} + +interface ScriptViewProps { + scripts: Script[]; + selectedScriptId: number | null; + userProfile: UserProfile; + onGenerateScript: (params: ScriptParams) => Promise; + onSelectScript: (id: number) => void; + onSwitchToPath: () => void; +} +``` + +### 6. 实现路径视图组件 (PathView.jsx) + +```jsx +/** + * 实现路径视图组件 + * 将剧本转化为行动计划 + */ +interface PathStep { + title: string; + content: string; +} + +interface PathViewProps { + selectedScript: Script | null; + path: PathStep[] | null; + onGeneratePath: () => Promise; + onSwitchToScript: () => void; +} +``` + +### 7. 用户菜单组件 (UserMenu.jsx) + +```jsx +/** + * 用户菜单弹窗组件 + * 查看和编辑用户资料 + */ +interface UserMenuProps { + isOpen: boolean; + onClose: () => void; + onLogout: () => void; +} + +interface UserMenuState { + isEditing: boolean; + editData: Partial; +} +``` + +## Data Models + +### Store 数据结构 + +```javascript +const STORE_SCHEMA = { + onboardingComplete: false, // 是否完成引导 + audioMuted: true, // 音乐是否静音 + userProfile: { + nickname: "", + gender: "secret", + zodiac: "", + mbti: "", + hobbies: [], + history: { + childhood: { date: "", content: "" }, + peak: { date: "", content: "" }, + valley: { date: "", content: "" } + }, + futureVision: "" + }, + lifeTimeline: [], // 生命事件列表 + generatedScripts: [], // 生成的剧本列表 + paths: [], // 实现路径列表 + selectedScriptId: null, // 当前选中的剧本ID + selectedPath: null // 当前选中的路径 +}; +``` + +### API 接口 + +```javascript +// 认证接口 (保持不变) +POST /auth/sms-code?phone={phone} // 发送验证码 +POST /auth/login // 登录 + Body: { phone, smsCode } + Response: { accessToken } + +// 用户资料接口 (保持不变) +GET /user/profile // 获取用户资料 +POST /user/profile // 创建用户资料 +PUT /user/profile // 更新用户资料 + +// AI 服务接口 (OpenRouter) +POST https://openrouter.ai/api/v1/chat/completions + Headers: { Authorization: Bearer {API_KEY} } + Body: { model, messages } +``` + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: 手机号格式验证 + +*For any* 输入字符串,手机号验证函数应当仅对11位数字且以1开头的字符串返回 true,对其他所有输入返回 false。 + +**Validates: Requirements 1.2** + +### Property 2: 步骤导航数据完整性 + +*For any* 引导流程中的步骤切换操作(前进或后退),用户在各步骤填写的数据应当被完整保留,不会因为步骤切换而丢失。 + +**Validates: Requirements 2.7, 2.8** + +### Property 3: 导航视图切换一致性 + +*For any* 仪表盘导航项点击操作,当前激活的视图应当与点击的导航项对应,且导航项的高亮状态应当正确反映当前视图。 + +**Validates: Requirements 3.3** + +### Property 4: 事件列表渲染完整性 + +*For any* 生命事件数组,时间线视图应当渲染所有事件,且每个事件卡片应当包含标题、时间、内容和 AI 洞察四个字段。 + +**Validates: Requirements 4.4** + +### Property 5: 事件时间排序正确性 + +*For any* 包含多个事件的生命事件数组,时间线视图应当按时间倒序排列事件,即最新的事件显示在最前面。 + +**Validates: Requirements 4.6** + +### Property 6: 数据持久化往返一致性 + +*For any* 有效的用户数据对象,保存到 localStorage 后再读取,应当得到与原始数据等价的对象。 + +**Validates: Requirements 10.1, 10.2, 10.3** + +## Error Handling + +### 网络错误处理 + +```javascript +// API 调用统一错误处理 +try { + const response = await request.post('/auth/login', data); + // 处理成功响应 +} catch (error) { + if (error.response) { + // 服务器返回错误 + showToast(error.response.data.message || '请求失败'); + } else if (error.request) { + // 网络错误 + showToast('网络连接异常,请检查网络设置'); + } else { + // 其他错误 + showToast('发生未知错误'); + } +} +``` + +### 表单验证错误 + +```javascript +// 表单验证规则 +const VALIDATION_RULES = { + phone: { + required: true, + pattern: /^1[3-9]\d{9}$/, + message: '请输入正确的手机号' + }, + code: { + required: true, + length: 6, + message: '请输入6位验证码' + }, + nickname: { + required: true, + maxLength: 20, + message: '昵称不能为空且不超过20字' + } +}; +``` + +### 存储错误处理 + +```javascript +// localStorage 存储错误处理 +try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); +} catch (error) { + if (error.name === 'QuotaExceededError') { + showToast('存储空间不足,部分数据可能无法保存'); + } +} +``` + +## Testing Strategy + +### 单元测试 + +使用 Vitest 进行单元测试,覆盖以下场景: + +1. **工具函数测试** + - 手机号格式验证函数 + - 数据序列化/反序列化函数 + - 日期格式化函数 + +2. **组件渲染测试** + - 各页面组件的基本渲染 + - 条件渲染逻辑(空状态、加载状态) + - 用户交互响应 + +3. **状态管理测试** + - Store 的 CRUD 操作 + - 数据持久化逻辑 + +### 属性测试 + +使用 fast-check 进行属性测试,验证以下属性: + +1. **Property 1**: 手机号验证 - 生成各种字符串测试验证函数 +2. **Property 2**: 步骤导航 - 生成随机步骤切换序列测试数据保留 +3. **Property 5**: 事件排序 - 生成随机事件数组测试排序正确性 +4. **Property 6**: 数据持久化 - 生成随机数据对象测试往返一致性 + +### 测试配置 + +```javascript +// vitest.config.js +export default { + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.js'] + } +}; + +// 属性测试最小迭代次数: 100 +``` diff --git a/.kiro/specs/pncyssd-refactor/requirements.md b/.kiro/specs/pncyssd-refactor/requirements.md new file mode 100644 index 0000000..5e82ed5 --- /dev/null +++ b/.kiro/specs/pncyssd-refactor/requirements.md @@ -0,0 +1,149 @@ +# Requirements Document + +## Introduction + +本文档定义了将 PncyssD 目录下的原型页面按照 course-web 项目规范进行重构的需求。PncyssD 是一个"人生轨迹"应用原型,包含登录、引导注册、仪表盘等核心页面。重构目标是将原生 ES Modules + Tailwind CSS 的实现迁移到 React + Vite 架构,同时保持原有的视觉设计风格和后端接口调用逻辑。 + +## Glossary + +- **PncyssD_App**: 原型应用,包含登录、引导、仪表盘等页面的原生 JavaScript 实现 +- **Course_Web**: 目标项目,使用 React + Vite + Tailwind CSS 的现代前端架构 +- **Glassmorphism**: 玻璃拟态设计风格,使用模糊背景和半透明效果 +- **Onboarding_Flow**: 引导流程,包含5个步骤的用户信息采集 +- **Dashboard**: 仪表盘页面,包含生命长河、爽文剧本、实现路径三个功能模块 +- **AI_Service**: AI 服务接口,用于生成分析、剧本和路径规划 + +## Requirements + +### Requirement 1: 登录页面重构 + +**User Story:** As a 用户, I want 通过手机号验证码登录系统, so that 我可以安全地访问我的人生轨迹数据。 + +#### Acceptance Criteria + +1. WHEN 用户访问登录页面 THEN THE PncyssD_App SHALL 显示玻璃拟态风格的登录卡片,包含标题"欢迎回来"和副标题"开启你的数字生命档案" +2. WHEN 用户输入手机号 THEN THE PncyssD_App SHALL 验证手机号格式为11位数字 +3. WHEN 用户点击获取验证码按钮 THEN THE PncyssD_App SHALL 调用后端 `/auth/sms-code` 接口发送验证码 +4. WHEN 验证码发送成功 THEN THE PncyssD_App SHALL 显示60秒倒计时并禁用获取按钮 +5. WHEN 用户输入正确的验证码并点击登录 THEN THE PncyssD_App SHALL 调用后端 `/auth/login` 接口进行验证 +6. WHEN 登录成功 THEN THE PncyssD_App SHALL 保存 token 到 localStorage 并跳转到引导页面 +7. IF 登录失败 THEN THE PncyssD_App SHALL 显示错误提示信息 +8. THE PncyssD_App SHALL 在登录按钮上显示"开启旅程"文字和橙色渐变样式 + +### Requirement 2: 引导流程页面重构 + +**User Story:** As a 新用户, I want 通过分步骤的引导流程填写个人信息, so that 系统可以了解我并提供个性化服务。 + +#### Acceptance Criteria + +1. THE PncyssD_App SHALL 提供5个步骤的引导流程:基本信息、童年记忆、开心经历、低谷时光、未来愿景 +2. WHEN 用户进入步骤1 THEN THE PncyssD_App SHALL 显示"你是谁?"标题和昵称、性别、MBTI、星座、兴趣爱好输入字段 +3. WHEN 用户进入步骤2 THEN THE PncyssD_App SHALL 显示"那段纯真的时光"标题和童年记忆的日期、描述输入字段 +4. WHEN 用户进入步骤3 THEN THE PncyssD_App SHALL 显示"光芒闪耀的时刻"标题和开心经历的日期、描述输入字段 +5. WHEN 用户进入步骤4 THEN THE PncyssD_App SHALL 显示"在暗夜中潜行"标题和低谷时光的日期、描述输入字段 +6. WHEN 用户进入步骤5 THEN THE PncyssD_App SHALL 显示"未来想成为谁?"标题和未来愿景、理想生活状态输入字段 +7. WHEN 用户点击下一步 THEN THE PncyssD_App SHALL 保存当前步骤数据并切换到下一步骤 +8. WHEN 用户点击返回 THEN THE PncyssD_App SHALL 返回上一步骤并保留已填写数据 +9. THE PncyssD_App SHALL 在页面底部显示步骤指示器,当前步骤高亮显示 +10. WHEN 用户完成所有步骤并点击"开启人生" THEN THE PncyssD_App SHALL 调用后端接口保存用户资料并跳转到仪表盘 +11. THE PncyssD_App SHALL 在记忆描述字段下方显示灵感标签(如:秋千、晚霞、糖果等),点击可快速插入文字 + +### Requirement 3: 仪表盘页面重构 + +**User Story:** As a 已登录用户, I want 在仪表盘中管理我的人生轨迹数据, so that 我可以回顾过去、创造未来。 + +#### Acceptance Criteria + +1. THE PncyssD_App SHALL 显示左侧导航栏,包含"生命长河"、"爽文剧本"、"实现路径"三个导航项 +2. THE PncyssD_App SHALL 在导航栏顶部显示用户头像、昵称和 MBTI/星座信息 +3. WHEN 用户点击导航项 THEN THE PncyssD_App SHALL 切换到对应的内容视图并高亮当前导航项 +4. THE PncyssD_App SHALL 在导航栏底部显示音乐播放控制按钮和重置数据按钮 +5. WHEN 用户点击用户头像区域 THEN THE PncyssD_App SHALL 显示用户菜单弹窗 + +### Requirement 4: 生命长河视图重构 + +**User Story:** As a 用户, I want 记录和查看我的人生重要事件, so that 我可以回顾和反思我的人生轨迹。 + +#### Acceptance Criteria + +1. THE PncyssD_App SHALL 显示"生命长河"标题和"记录足迹"按钮 +2. WHEN 用户点击"记录足迹"按钮 THEN THE PncyssD_App SHALL 显示事件录入弹窗,包含标题、时间、详情输入字段 +3. WHEN 用户提交事件 THEN THE PncyssD_App SHALL 调用 AI 服务分析事件并生成疗愈反馈 +4. THE PncyssD_App SHALL 以时间线形式展示所有已记录的事件,每个事件卡片包含标题、时间、内容和 AI 洞察 +5. WHILE 事件列表为空 THEN THE PncyssD_App SHALL 显示空状态提示"此间尚无回响,等待你执笔..." +6. THE PncyssD_App SHALL 按时间倒序排列事件列表 + +### Requirement 5: 爽文剧本视图重构 + +**User Story:** As a 用户, I want 基于我的人设生成个性化的人生剧本, so that 我可以获得激励和方向感。 + +#### Acceptance Criteria + +1. THE PncyssD_App SHALL 在左侧显示角色设定卡片,展示用户的昵称、星座、MBTI、兴趣爱好 +2. THE PncyssD_App SHALL 提供剧本主题输入框和叙事风格、篇幅选择器 +3. WHEN 用户点击"开启天命编撰"按钮 THEN THE PncyssD_App SHALL 调用 AI 服务生成剧本内容 +4. WHILE 剧本生成中 THEN THE PncyssD_App SHALL 显示加载状态"编撰中..." +5. THE PncyssD_App SHALL 在右侧显示生成的剧本内容,使用章节标题格式化显示 +6. THE PncyssD_App SHALL 在左侧显示历史卷轴列表,点击可切换查看不同剧本 +7. WHILE 无剧本生成 THEN THE PncyssD_App SHALL 显示空状态提示 + +### Requirement 6: 实现路径视图重构 + +**User Story:** As a 用户, I want 将剧本转化为可执行的行动计划, so that 我可以一步步实现我的人生目标。 + +#### Acceptance Criteria + +1. IF 用户未生成剧本 THEN THE PncyssD_App SHALL 显示提示"先生成剧本,方能洞察路径"并提供跳转按钮 +2. WHEN 用户已有剧本 THEN THE PncyssD_App SHALL 显示"实现路径"标题和"开启人生导航"按钮 +3. WHEN 用户点击"开启人生导航"按钮 THEN THE PncyssD_App SHALL 调用 AI 服务基于剧本生成行动路径 +4. THE PncyssD_App SHALL 以阶段卡片形式展示路径,每个阶段包含序号、标题和具体建议 +5. WHEN 路径已生成 THEN THE PncyssD_App SHALL 将按钮文字改为"重新推演" + +### Requirement 7: 用户资料管理重构 + +**User Story:** As a 用户, I want 查看和编辑我的个人资料, so that 我可以保持信息的准确性。 + +#### Acceptance Criteria + +1. WHEN 用户点击用户头像 THEN THE PncyssD_App SHALL 显示用户资料弹窗 +2. THE PncyssD_App SHALL 在弹窗中显示用户头像、昵称、MBTI、星座、生命足迹数量、天命卷轴数量 +3. WHEN 用户点击"编辑资料"按钮 THEN THE PncyssD_App SHALL 切换到编辑模式,显示可编辑的表单字段 +4. WHEN 用户保存修改 THEN THE PncyssD_App SHALL 调用后端接口更新用户资料 +5. WHEN 用户点击"清除数据并退出"按钮 THEN THE PncyssD_App SHALL 确认后清除本地存储并刷新页面 + +### Requirement 8: 视觉设计规范 + +**User Story:** As a 用户, I want 体验一致的视觉设计风格, so that 我可以获得沉浸式的使用体验。 + +#### Acceptance Criteria + +1. THE PncyssD_App SHALL 使用深色背景配色方案,主色调为 `#0a0c10` 到 `#1a1c2c` +2. THE PncyssD_App SHALL 使用橙色 `#FFAB91` 作为强调色 +3. THE PncyssD_App SHALL 使用玻璃拟态效果(backdrop-blur、半透明背景、细边框) +4. THE PncyssD_App SHALL 使用 Noto Sans SC 和 Noto Serif SC 字体 +5. THE PncyssD_App SHALL 在背景中显示动态渐变光晕效果 +6. THE PncyssD_App SHALL 使用圆角 32px 的卡片设计 +7. THE PncyssD_App SHALL 使用平滑的页面切换动画(淡入淡出、上下滑动) + +### Requirement 9: 响应式布局 + +**User Story:** As a 用户, I want 在不同设备上都能正常使用应用, so that 我可以随时随地记录我的人生轨迹。 + +#### Acceptance Criteria + +1. THE PncyssD_App SHALL 在移动端显示汉堡菜单按钮,点击展开侧边导航 +2. THE PncyssD_App SHALL 在移动端将仪表盘布局调整为单列显示 +3. THE PncyssD_App SHALL 在移动端调整卡片圆角为 20px +4. THE PncyssD_App SHALL 在移动端隐藏导航项的文字标签,只显示图标 + +### Requirement 10: 数据持久化 + +**User Story:** As a 用户, I want 我的数据能够被安全保存, so that 我不会丢失我的人生记录。 + +#### Acceptance Criteria + +1. THE PncyssD_App SHALL 使用 localStorage 存储用户数据,key 为 `lifeTrajectory_v3_data` +2. THE PncyssD_App SHALL 在数据变更时自动保存到 localStorage +3. THE PncyssD_App SHALL 在页面加载时从 localStorage 恢复数据 +4. THE PncyssD_App SHALL 在登录成功后同步数据到后端服务器 +5. IF localStorage 存储失败 THEN THE PncyssD_App SHALL 显示存储空间不足的提示 diff --git a/.kiro/specs/pncyssd-refactor/tasks.md b/.kiro/specs/pncyssd-refactor/tasks.md new file mode 100644 index 0000000..e51510c --- /dev/null +++ b/.kiro/specs/pncyssd-refactor/tasks.md @@ -0,0 +1,173 @@ +# Implementation Plan: PncyssD 页面重构 + +## Overview + +本实现计划将 PncyssD 原型页面按照 course-web 项目规范进行重构。采用渐进式重构策略,按页面顺序逐一重构,确保每个页面完成后可独立运行和测试。 + +## Tasks + +- [x] 1. 项目准备和基础设施 + - [x] 1.1 更新 store.js 数据结构 + - 添加 PncyssD 所需的数据字段(selectedScriptId, selectedPath 等) + - 确保与现有 course-web 数据结构兼容 + - _Requirements: 10.1, 10.2, 10.3_ + - [x] 1.2 编写数据持久化属性测试 + - **Property 6: 数据持久化往返一致性** + - **Validates: Requirements 10.1, 10.2, 10.3** + - [x] 1.3 创建 AI 服务模块 + - 在 utils/aiLogic.js 中添加 analyzeLifeEvent、generateEpicScript、generatePath 函数 + - 保持与原 PncyssD/api.js 相同的 API 调用逻辑 + - _Requirements: 4.3, 5.3, 6.3_ + +- [x] 2. 登录页面重构 + - [x] 2.1 重构 LoginPage.jsx 组件 + - 实现手机号验证码登录表单 + - 添加玻璃拟态卡片样式 + - 实现验证码倒计时功能 + - 调用后端 /auth/sms-code 和 /auth/login 接口 + - _Requirements: 1.1, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8_ + - [x] 2.2 编写手机号验证属性测试 + - **Property 1: 手机号格式验证** + - **Validates: Requirements 1.2** + - [x] 2.3 Checkpoint - 验证登录页面功能 + - 确保登录流程正常工作,验证码发送和登录成功跳转 + +- [x] 3. 引导流程页面重构 + - [x] 3.1 重构 OnboardingPage.jsx 步骤1(基本信息) + - 实现"你是谁?"步骤 + - 添加昵称、性别、MBTI、星座、兴趣爱好输入字段 + - 实现步骤指示器组件 + - _Requirements: 2.1, 2.2, 2.9_ + - [x] 3.2 重构 OnboardingPage.jsx 步骤2-4(人生记忆) + - 实现童年记忆、开心经历、低谷时光三个步骤 + - 添加日期选择器和文本描述输入 + - 实现灵感标签功能(点击插入文字) + - _Requirements: 2.3, 2.4, 2.5, 2.11_ + - [x] 3.3 重构 OnboardingPage.jsx 步骤5(未来愿景) + - 实现"未来想成为谁?"步骤 + - 添加未来愿景和理想生活状态输入 + - 实现完成引导并保存数据到后端 + - _Requirements: 2.6, 2.10_ + - [x] 3.4 实现步骤导航逻辑 + - 实现下一步/返回按钮功能 + - 确保步骤切换时数据保留 + - _Requirements: 2.7, 2.8_ + - [x] 3.5 编写步骤导航属性测试 + - **Property 2: 步骤导航数据完整性** + - **Validates: Requirements 2.7, 2.8** + - [x] 3.6 Checkpoint - 验证引导流程功能 + - 确保5个步骤正常切换,数据正确保存 + +- [x] 4. 仪表盘页面重构 + - [x] 4.1 重构 DashboardPage.jsx 布局结构 + - 实现左侧导航栏 + 右侧内容区域布局 + - 添加用户信息卡片(头像、昵称、MBTI/星座) + - 实现导航项切换和高亮状态 + - _Requirements: 3.1, 3.2, 3.3_ + - [x] 4.2 编写导航切换属性测试 + - **Property 3: 导航视图切换一致性** + - **Validates: Requirements 3.3** + - [x] 4.3 实现音乐播放控制和重置功能 + - 添加音乐播放/暂停按钮 + - 添加重置数据按钮(确认后清除 localStorage) + - _Requirements: 3.4_ + - [x] 4.4 实现移动端响应式布局 + - 添加汉堡菜单按钮 + - 实现侧边栏抽屉效果 + - _Requirements: 9.1_ + - [x] 4.5 Checkpoint - 验证仪表盘布局 + - 确保导航切换正常,响应式布局正确 + +- [x] 5. 生命长河视图重构 + - [x] 5.1 重构 TimelineView.jsx 组件 + - 实现时间线布局和事件卡片样式 + - 添加"记录足迹"按钮和事件录入弹窗 + - 实现空状态显示 + - _Requirements: 4.1, 4.2, 4.5_ + - [x] 5.2 实现事件提交和 AI 分析 + - 调用 AI 服务分析事件并生成疗愈反馈 + - 保存事件到 Store + - _Requirements: 4.3_ + - [x] 5.3 实现事件列表渲染和排序 + - 按时间倒序排列事件 + - 渲染事件卡片(标题、时间、内容、AI 洞察) + - _Requirements: 4.4, 4.6_ + - [x] 5.4 编写事件列表属性测试 + - **Property 4: 事件列表渲染完整性** + - **Validates: Requirements 4.4** + - [x] 5.5 编写事件排序属性测试 + - **Property 5: 事件时间排序正确性** + - **Validates: Requirements 4.6** + - [x] 5.6 Checkpoint - 验证生命长河功能 + - 确保事件录入、AI 分析、列表显示正常 + +- [x] 6. 爽文剧本视图重构 + - [x] 6.1 重构 ScriptView.jsx 左侧面板 + - 实现角色设定卡片(显示用户信息) + - 添加剧本主题输入框 + - 添加叙事风格和篇幅选择器 + - 实现历史卷轴列表 + - _Requirements: 5.1, 5.2, 5.6_ + - [x] 6.2 实现剧本生成功能 + - 调用 AI 服务生成剧本 + - 实现加载状态显示 + - 保存剧本到 Store + - _Requirements: 5.3, 5.4_ + - [x] 6.3 重构 ScriptView.jsx 右侧面板 + - 实现剧本内容展示(章节格式化) + - 实现空状态显示 + - 实现剧本切换功能 + - _Requirements: 5.5, 5.7_ + - [x] 6.4 Checkpoint - 验证爽文剧本功能 + - 确保剧本生成、显示、切换正常 + +- [x] 7. 实现路径视图重构 + - [x] 7.1 重构 PathView.jsx 组件 + - 实现无剧本时的提示状态 + - 添加"开启人生导航"按钮 + - _Requirements: 6.1, 6.2_ + - [x] 7.2 实现路径生成功能 + - 调用 AI 服务基于剧本生成路径 + - 实现阶段卡片展示 + - 实现按钮文字切换(开启/重新推演) + - _Requirements: 6.3, 6.4, 6.5_ + - [x] 7.3 Checkpoint - 验证实现路径功能 + - 确保路径生成和显示正常 + +- [x] 8. 用户资料管理重构 + - [x] 8.1 重构 UserMenu.jsx 组件 + - 实现用户资料弹窗 + - 显示用户头像、昵称、MBTI、星座 + - 显示生命足迹数量和天命卷轴数量 + - _Requirements: 7.1, 7.2_ + - [x] 8.2 实现资料编辑功能 + - 实现编辑模式切换 + - 调用后端接口更新用户资料 + - _Requirements: 7.3, 7.4_ + - [x] 8.3 实现退出登录功能 + - 实现确认弹窗 + - 清除 localStorage 并刷新页面 + - _Requirements: 7.5_ + - [x] 8.4 Checkpoint - 验证用户资料管理功能 + - 确保查看、编辑、退出功能正常 + +- [x] 9. 最终集成和测试 + - [x] 9.1 更新 App.jsx 路由逻辑 + - 整合所有重构的页面组件 + - 确保页面跳转逻辑正确 + - [x] 9.2 样式优化和一致性检查 + - 确保所有页面使用统一的视觉风格 + - 检查响应式布局在各设备上的表现 + - _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7_ + - [x] 9.3 Final Checkpoint - 完整功能验收 + - 运行所有测试确保通过 + - 手动测试完整用户流程 + +## Notes + +- All tasks are required for complete implementation +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate universal correctness properties +- Unit tests validate specific examples and edge cases +- 后端接口调用逻辑保持不变,仅重构前端组件 diff --git a/PncyssD/README.md b/PncyssD/README.md index ef2cda4..3b31117 100644 --- a/PncyssD/README.md +++ b/PncyssD/README.md @@ -14,3 +14,6 @@ - 动画:GSAP - AI:OpenRouter (DeepSeek) - 存储:LocalStorage + +## 本地启动 +本地启动访问命令: python3 -m http.server 8081 \ No newline at end of file diff --git a/backend-single/src/main/java/com/emotion/controller/EpicScriptController.java b/backend-single/src/main/java/com/emotion/controller/EpicScriptController.java new file mode 100644 index 0000000..99a684e --- /dev/null +++ b/backend-single/src/main/java/com/emotion/controller/EpicScriptController.java @@ -0,0 +1,107 @@ +package com.emotion.controller; + +import com.emotion.common.PageResult; +import com.emotion.common.Result; +import com.emotion.dto.request.EpicScriptCreateRequest; +import com.emotion.dto.request.EpicScriptPageRequest; +import com.emotion.dto.request.EpicScriptUpdateRequest; +import com.emotion.dto.response.EpicScriptResponse; +import com.emotion.service.EpicScriptService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; + +/** + * 爽文剧本控制器 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@RestController +@RequestMapping("/epicScript") +public class EpicScriptController { + + @Autowired + private EpicScriptService epicScriptService; + + /** + * 分页查询当前用户的爽文剧本 + */ + @GetMapping(value = "/page") + public Result> getPage(@Validated EpicScriptPageRequest request) { + PageResult pageResult = epicScriptService.getPageByCurrentUser(request); + return Result.success(pageResult); + } + + /** + * 获取当前用户的所有爽文剧本列表 + */ + @GetMapping(value = "/listAll") + public Result> getList() { + List scripts = epicScriptService.getListByCurrentUser(); + return Result.success(scripts); + } + + /** + * 根据ID获取爽文剧本详情 + */ + @GetMapping(value = "/detail") + public Result getById(@RequestParam String id) { + EpicScriptResponse script = epicScriptService.getScriptById(id); + if (script == null) { + return Result.notFound("爽文剧本不存在"); + } + return Result.success(script); + } + + /** + * 创建爽文剧本 + */ + @PostMapping(value = "/create") + public Result create(@Valid @RequestBody EpicScriptCreateRequest request) { + EpicScriptResponse script = epicScriptService.createScript(request); + if (script == null) { + return Result.error("创建失败"); + } + return Result.success(script); + } + + /** + * 更新爽文剧本 + */ + @PutMapping(value = "/update") + public Result update(@Valid @RequestBody EpicScriptUpdateRequest request) { + EpicScriptResponse script = epicScriptService.updateScript(request); + if (script == null) { + return Result.error("更新失败"); + } + return Result.success(script); + } + + /** + * 选中剧本(取消其他选中状态) + */ + @PutMapping(value = "/select") + public Result select(@RequestParam String id) { + EpicScriptResponse script = epicScriptService.selectScript(id); + if (script == null) { + return Result.error("选中失败"); + } + return Result.success(script); + } + + /** + * 删除爽文剧本 + */ + @DeleteMapping(value = "/delete") + public Result delete(@RequestParam String id) { + boolean deleted = epicScriptService.deleteScript(id); + if (!deleted) { + return Result.error("删除失败"); + } + return Result.success(); + } +} diff --git a/backend-single/src/main/java/com/emotion/controller/LifeEventController.java b/backend-single/src/main/java/com/emotion/controller/LifeEventController.java new file mode 100644 index 0000000..b57d758 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/controller/LifeEventController.java @@ -0,0 +1,95 @@ +package com.emotion.controller; + +import com.emotion.common.PageResult; +import com.emotion.common.Result; +import com.emotion.dto.request.LifeEventCreateRequest; +import com.emotion.dto.request.LifeEventPageRequest; +import com.emotion.dto.request.LifeEventUpdateRequest; +import com.emotion.dto.response.LifeEventResponse; +import com.emotion.service.LifeEventService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; + +/** + * 生命事件控制器 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@RestController +@RequestMapping("/lifeEvent") +public class LifeEventController { + + @Autowired + private LifeEventService lifeEventService; + + /** + * 分页查询当前用户的生命事件 + */ + @GetMapping(value = "/page") + public Result> getPage(@Validated LifeEventPageRequest request) { + PageResult pageResult = lifeEventService.getPageByCurrentUser(request); + return Result.success(pageResult); + } + + /** + * 获取当前用户的所有生命事件列表 + */ + @GetMapping(value = "/list") + public Result> getList() { + List events = lifeEventService.getListByCurrentUser(); + return Result.success(events); + } + + /** + * 根据ID获取生命事件详情 + */ + @GetMapping(value = "/detail") + public Result getById(@RequestParam String id) { + LifeEventResponse event = lifeEventService.getEventById(id); + if (event == null) { + return Result.notFound("生命事件不存在"); + } + return Result.success(event); + } + + /** + * 创建生命事件 + */ + @PostMapping(value = "/create") + public Result create(@Valid @RequestBody LifeEventCreateRequest request) { + LifeEventResponse event = lifeEventService.createEvent(request); + if (event == null) { + return Result.error("创建失败"); + } + return Result.success(event); + } + + /** + * 更新生命事件 + */ + @PutMapping(value = "/update") + public Result update(@Valid @RequestBody LifeEventUpdateRequest request) { + LifeEventResponse event = lifeEventService.updateEvent(request); + if (event == null) { + return Result.error("更新失败"); + } + return Result.success(event); + } + + /** + * 删除生命事件 + */ + @DeleteMapping(value = "/delete") + public Result delete(@RequestParam String id) { + boolean deleted = lifeEventService.deleteEvent(id); + if (!deleted) { + return Result.error("删除失败"); + } + return Result.success(); + } +} diff --git a/backend-single/src/main/java/com/emotion/controller/LifePathController.java b/backend-single/src/main/java/com/emotion/controller/LifePathController.java new file mode 100644 index 0000000..0d4d727 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/controller/LifePathController.java @@ -0,0 +1,107 @@ +package com.emotion.controller; + +import com.emotion.common.PageResult; +import com.emotion.common.Result; +import com.emotion.dto.request.LifePathCreateRequest; +import com.emotion.dto.request.LifePathPageRequest; +import com.emotion.dto.request.LifePathUpdateRequest; +import com.emotion.dto.response.LifePathResponse; +import com.emotion.service.LifePathService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; + +/** + * 实现路径控制器 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@RestController +@RequestMapping("/lifePath") +public class LifePathController { + + @Autowired + private LifePathService lifePathService; + + /** + * 分页查询当前用户的实现路径 + */ + @GetMapping(value = "/page") + public Result> getPage(@Validated LifePathPageRequest request) { + PageResult pageResult = lifePathService.getPageByCurrentUser(request); + return Result.success(pageResult); + } + + /** + * 获取当前用户的所有实现路径列表 + */ + @GetMapping(value = "/listAll") + public Result> getList() { + List paths = lifePathService.getListByCurrentUser(); + return Result.success(paths); + } + + /** + * 根据剧本ID获取实现路径 + */ + @GetMapping(value = "/byScript") + public Result getByScriptId(@RequestParam String scriptId) { + LifePathResponse path = lifePathService.getByScriptId(scriptId); + if (path == null) { + return Result.notFound("实现路径不存在"); + } + return Result.success(path); + } + + /** + * 根据ID获取实现路径详情 + */ + @GetMapping(value = "/detail") + public Result getById(@RequestParam String id) { + LifePathResponse path = lifePathService.getPathById(id); + if (path == null) { + return Result.notFound("实现路径不存在"); + } + return Result.success(path); + } + + /** + * 创建实现路径 + */ + @PostMapping(value = "/create") + public Result create(@Valid @RequestBody LifePathCreateRequest request) { + LifePathResponse path = lifePathService.createPath(request); + if (path == null) { + return Result.error("创建失败"); + } + return Result.success(path); + } + + /** + * 更新实现路径 + */ + @PutMapping(value = "/update") + public Result update(@Valid @RequestBody LifePathUpdateRequest request) { + LifePathResponse path = lifePathService.updatePath(request); + if (path == null) { + return Result.error("更新失败"); + } + return Result.success(path); + } + + /** + * 删除实现路径 + */ + @DeleteMapping(value = "/delete") + public Result delete(@RequestParam String id) { + boolean deleted = lifePathService.deletePath(id); + if (!deleted) { + return Result.error("删除失败"); + } + return Result.success(); + } +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/EpicScriptCreateRequest.java b/backend-single/src/main/java/com/emotion/dto/request/EpicScriptCreateRequest.java new file mode 100644 index 0000000..07de510 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/EpicScriptCreateRequest.java @@ -0,0 +1,71 @@ +package com.emotion.dto.request; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; +import java.util.Map; + +/** + * 爽文剧本创建请求 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class EpicScriptCreateRequest extends BaseRequest { + + /** + * 剧本标题 + */ + @NotBlank(message = "剧本标题不能为空") + @Size(max = 200, message = "剧本标题长度不能超过200个字符") + private String title; + + /** + * 剧本主题/渴望 + */ + private String theme; + + /** + * 剧本风格: career-职场逆袭, love-情感圆满, fantasy-玄幻觉醒 + */ + private String style; + + /** + * 篇幅长度: medium-标准篇, long-长篇 + */ + private String length; + + /** + * 序幕:低谷回响 + */ + private String plotIntro; + + /** + * 转折:契机出现 + */ + private String plotTurning; + + /** + * 高潮:命运抉择 + */ + private String plotClimax; + + /** + * 结局:新的开始 + */ + private String plotEnding; + + /** + * 完整剧情JSON结构 + */ + private Map plotJson; + + /** + * 是否当前选中 + */ + private Boolean isSelected; +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/EpicScriptPageRequest.java b/backend-single/src/main/java/com/emotion/dto/request/EpicScriptPageRequest.java new file mode 100644 index 0000000..74373f0 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/EpicScriptPageRequest.java @@ -0,0 +1,26 @@ +package com.emotion.dto.request; + +import com.emotion.common.BasePageRequest; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 爽文剧本分页查询请求 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class EpicScriptPageRequest extends BasePageRequest { + + /** + * 剧本风格筛选 + */ + private String style; + + /** + * 篇幅长度筛选 + */ + private String length; +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/EpicScriptUpdateRequest.java b/backend-single/src/main/java/com/emotion/dto/request/EpicScriptUpdateRequest.java new file mode 100644 index 0000000..c85c1dc --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/EpicScriptUpdateRequest.java @@ -0,0 +1,76 @@ +package com.emotion.dto.request; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; +import java.util.Map; + +/** + * 爽文剧本更新请求 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class EpicScriptUpdateRequest extends BaseRequest { + + /** + * 剧本ID + */ + @NotBlank(message = "剧本ID不能为空") + private String id; + + /** + * 剧本标题 + */ + @Size(max = 200, message = "剧本标题长度不能超过200个字符") + private String title; + + /** + * 剧本主题/渴望 + */ + private String theme; + + /** + * 剧本风格: career-职场逆袭, love-情感圆满, fantasy-玄幻觉醒 + */ + private String style; + + /** + * 篇幅长度: medium-标准篇, long-长篇 + */ + private String length; + + /** + * 序幕:低谷回响 + */ + private String plotIntro; + + /** + * 转折:契机出现 + */ + private String plotTurning; + + /** + * 高潮:命运抉择 + */ + private String plotClimax; + + /** + * 结局:新的开始 + */ + private String plotEnding; + + /** + * 完整剧情JSON结构 + */ + private Map plotJson; + + /** + * 是否当前选中 + */ + private Boolean isSelected; +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/LifeEventCreateRequest.java b/backend-single/src/main/java/com/emotion/dto/request/LifeEventCreateRequest.java new file mode 100644 index 0000000..7ab6556 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/LifeEventCreateRequest.java @@ -0,0 +1,62 @@ +package com.emotion.dto.request; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; +import java.util.List; + +/** + * 生命事件创建请求 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class LifeEventCreateRequest extends BaseRequest { + + /** + * 事件类型: daily_log-日常记录, milestone-里程碑 + */ + private String eventType; + + /** + * 事件日期 (ISO格式字符串) + */ + private String eventDate; + + /** + * 事件标题 + */ + @NotBlank(message = "事件标题不能为空") + @Size(max = 200, message = "事件标题长度不能超过200个字符") + private String title; + + /** + * 事件内容 + */ + @NotBlank(message = "事件内容不能为空") + private String content; + + /** + * AI疗愈回复 + */ + private String aiReply; + + /** + * 情绪类型 + */ + private String emotionType; + + /** + * 情绪评分 + */ + private Double emotionScore; + + /** + * 标签列表 + */ + private List tags; +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/LifeEventPageRequest.java b/backend-single/src/main/java/com/emotion/dto/request/LifeEventPageRequest.java new file mode 100644 index 0000000..47fbaff --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/LifeEventPageRequest.java @@ -0,0 +1,36 @@ +package com.emotion.dto.request; + +import com.emotion.common.BasePageRequest; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 生命事件分页查询请求 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class LifeEventPageRequest extends BasePageRequest { + + /** + * 事件类型筛选 + */ + private String eventType; + + /** + * 开始日期 + */ + private String startDate; + + /** + * 结束日期 + */ + private String endDate; + + /** + * 情绪类型筛选 + */ + private String emotionType; +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/LifeEventUpdateRequest.java b/backend-single/src/main/java/com/emotion/dto/request/LifeEventUpdateRequest.java new file mode 100644 index 0000000..5b7f9ec --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/LifeEventUpdateRequest.java @@ -0,0 +1,66 @@ +package com.emotion.dto.request; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; +import java.util.List; + +/** + * 生命事件更新请求 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class LifeEventUpdateRequest extends BaseRequest { + + /** + * 事件ID + */ + @NotBlank(message = "事件ID不能为空") + private String id; + + /** + * 事件类型: daily_log-日常记录, milestone-里程碑 + */ + private String eventType; + + /** + * 事件日期 (ISO格式字符串) + */ + private String eventDate; + + /** + * 事件标题 + */ + @Size(max = 200, message = "事件标题长度不能超过200个字符") + private String title; + + /** + * 事件内容 + */ + private String content; + + /** + * AI疗愈回复 + */ + private String aiReply; + + /** + * 情绪类型 + */ + private String emotionType; + + /** + * 情绪评分 + */ + private Double emotionScore; + + /** + * 标签列表 + */ + private List tags; +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/LifePathCreateRequest.java b/backend-single/src/main/java/com/emotion/dto/request/LifePathCreateRequest.java new file mode 100644 index 0000000..d66ce7d --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/LifePathCreateRequest.java @@ -0,0 +1,53 @@ +package com.emotion.dto.request; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; +import java.util.List; +import java.util.Map; + +/** + * 实现路径创建请求 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class LifePathCreateRequest extends BaseRequest { + + /** + * 关联剧本ID + */ + @NotBlank(message = "关联剧本ID不能为空") + private String scriptId; + + /** + * 路径标题 + */ + @Size(max = 200, message = "路径标题长度不能超过200个字符") + private String title; + + /** + * 路径描述 + */ + private String description; + + /** + * 路径步骤列表 + * 每个步骤包含: phase, time, content, action, resources, habit + */ + private List> steps; + + /** + * 状态: active-进行中, completed-已完成, archived-已归档 + */ + private String status; + + /** + * 完成进度百分比 + */ + private Double progress; +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/LifePathPageRequest.java b/backend-single/src/main/java/com/emotion/dto/request/LifePathPageRequest.java new file mode 100644 index 0000000..6c1c6c2 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/LifePathPageRequest.java @@ -0,0 +1,26 @@ +package com.emotion.dto.request; + +import com.emotion.common.BasePageRequest; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 实现路径分页查询请求 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class LifePathPageRequest extends BasePageRequest { + + /** + * 关联剧本ID筛选 + */ + private String scriptId; + + /** + * 状态筛选 + */ + private String status; +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/LifePathUpdateRequest.java b/backend-single/src/main/java/com/emotion/dto/request/LifePathUpdateRequest.java new file mode 100644 index 0000000..9ebc5d1 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/LifePathUpdateRequest.java @@ -0,0 +1,53 @@ +package com.emotion.dto.request; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; +import java.util.List; +import java.util.Map; + +/** + * 实现路径更新请求 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class LifePathUpdateRequest extends BaseRequest { + + /** + * 路径ID + */ + @NotBlank(message = "路径ID不能为空") + private String id; + + /** + * 路径标题 + */ + @Size(max = 200, message = "路径标题长度不能超过200个字符") + private String title; + + /** + * 路径描述 + */ + private String description; + + /** + * 路径步骤列表 + * 每个步骤包含: phase, time, content, action, resources, habit + */ + private List> steps; + + /** + * 状态: active-进行中, completed-已完成, archived-已归档 + */ + private String status; + + /** + * 完成进度百分比 + */ + private Double progress; +} diff --git a/backend-single/src/main/java/com/emotion/dto/response/EpicScriptResponse.java b/backend-single/src/main/java/com/emotion/dto/response/EpicScriptResponse.java new file mode 100644 index 0000000..a4506df --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/response/EpicScriptResponse.java @@ -0,0 +1,72 @@ +package com.emotion.dto.response; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Map; + +/** + * 爽文剧本响应 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class EpicScriptResponse extends BaseResponse { + + /** + * 用户ID + */ + private String userId; + + /** + * 剧本标题 + */ + private String title; + + /** + * 剧本主题/渴望 + */ + private String theme; + + /** + * 剧本风格 + */ + private String style; + + /** + * 篇幅长度 + */ + private String length; + + /** + * 序幕:低谷回响 + */ + private String plotIntro; + + /** + * 转折:契机出现 + */ + private String plotTurning; + + /** + * 高潮:命运抉择 + */ + private String plotClimax; + + /** + * 结局:新的开始 + */ + private String plotEnding; + + /** + * 完整剧情JSON结构 + */ + private Map plotJson; + + /** + * 是否当前选中 + */ + private Boolean isSelected; +} diff --git a/backend-single/src/main/java/com/emotion/dto/response/LifeEventResponse.java b/backend-single/src/main/java/com/emotion/dto/response/LifeEventResponse.java new file mode 100644 index 0000000..8b8f6a1 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/response/LifeEventResponse.java @@ -0,0 +1,62 @@ +package com.emotion.dto.response; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +/** + * 生命事件响应 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class LifeEventResponse extends BaseResponse { + + /** + * 用户ID + */ + private String userId; + + /** + * 事件类型 + */ + private String eventType; + + /** + * 事件日期 + */ + private String eventDate; + + /** + * 事件标题 + */ + private String title; + + /** + * 事件内容 + */ + private String content; + + /** + * AI疗愈回复 + */ + private String aiReply; + + /** + * 情绪类型 + */ + private String emotionType; + + /** + * 情绪评分 + */ + private Double emotionScore; + + /** + * 标签列表 + */ + private List tags; +} diff --git a/backend-single/src/main/java/com/emotion/dto/response/LifePathResponse.java b/backend-single/src/main/java/com/emotion/dto/response/LifePathResponse.java new file mode 100644 index 0000000..5ab2af9 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/response/LifePathResponse.java @@ -0,0 +1,53 @@ +package com.emotion.dto.response; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; +import java.util.Map; + +/** + * 实现路径响应 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class LifePathResponse extends BaseResponse { + + /** + * 用户ID + */ + private String userId; + + /** + * 关联剧本ID + */ + private String scriptId; + + /** + * 路径标题 + */ + private String title; + + /** + * 路径描述 + */ + private String description; + + /** + * 路径步骤列表 + */ + private List> steps; + + /** + * 状态 + */ + private String status; + + /** + * 完成进度百分比 + */ + private Double progress; +} diff --git a/backend-single/src/main/java/com/emotion/entity/EpicScript.java b/backend-single/src/main/java/com/emotion/entity/EpicScript.java new file mode 100644 index 0000000..a56b6f0 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/entity/EpicScript.java @@ -0,0 +1,95 @@ +package com.emotion.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.emotion.common.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.Map; + +/** + * 爽文剧本实体类 + * 存储用户生成的爽文剧本,包括主题、风格、剧情章节等 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@TableName(value = "t_epic_script", autoResultMap = true) +public class EpicScript extends BaseEntity { + + /** + * 用户ID (关联t_user.id) + */ + @TableField("user_id") + private String userId; + + /** + * 剧本标题 + */ + @TableField("title") + private String title; + + /** + * 剧本主题/渴望 + */ + @TableField("theme") + private String theme; + + /** + * 剧本风格: career-职场逆袭, love-情感圆满, fantasy-玄幻觉醒 + */ + @TableField("style") + private String style; + + /** + * 篇幅长度: medium-标准篇, long-长篇 + */ + @TableField("length") + private String length; + + /** + * 序幕:低谷回响 + */ + @TableField("plot_intro") + private String plotIntro; + + /** + * 转折:契机出现 + */ + @TableField("plot_turning") + private String plotTurning; + + /** + * 高潮:命运抉择 + */ + @TableField("plot_climax") + private String plotClimax; + + /** + * 结局:新的开始 + */ + @TableField("plot_ending") + private String plotEnding; + + /** + * 完整剧情JSON结构 + */ + @TableField(value = "plot_json", typeHandler = JacksonTypeHandler.class) + private Map plotJson; + + /** + * 是否当前选中: 0-否, 1-是 + */ + @TableField("is_selected") + private Integer isSelected; +} diff --git a/backend-single/src/main/java/com/emotion/entity/LifeEvent.java b/backend-single/src/main/java/com/emotion/entity/LifeEvent.java new file mode 100644 index 0000000..67fdf85 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/entity/LifeEvent.java @@ -0,0 +1,85 @@ +package com.emotion.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.emotion.common.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 生命事件实体类 + * 存储用户的人生轨迹事件,包括日期、标题、内容、AI回复等 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@TableName(value = "t_life_event", autoResultMap = true) +public class LifeEvent extends BaseEntity { + + /** + * 用户ID (关联t_user.id) + */ + @TableField("user_id") + private String userId; + + /** + * 事件类型: daily_log-日常记录, milestone-里程碑 + */ + @TableField("event_type") + private String eventType; + + /** + * 事件日期 + */ + @TableField("event_date") + private LocalDateTime eventDate; + + /** + * 事件标题 + */ + @TableField("title") + private String title; + + /** + * 事件内容 + */ + @TableField("content") + private String content; + + /** + * AI疗愈回复 + */ + @TableField("ai_reply") + private String aiReply; + + /** + * 情绪类型 + */ + @TableField("emotion_type") + private String emotionType; + + /** + * 情绪评分 + */ + @TableField("emotion_score") + private BigDecimal emotionScore; + + /** + * 标签列表 + */ + @TableField(value = "tags", typeHandler = JacksonTypeHandler.class) + private List tags; +} diff --git a/backend-single/src/main/java/com/emotion/entity/LifePath.java b/backend-single/src/main/java/com/emotion/entity/LifePath.java new file mode 100644 index 0000000..f69ce4e --- /dev/null +++ b/backend-single/src/main/java/com/emotion/entity/LifePath.java @@ -0,0 +1,74 @@ +package com.emotion.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.emotion.common.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +/** + * 实现路径实体类 + * 存储基于剧本生成的实现路径规划 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@TableName(value = "t_life_path", autoResultMap = true) +public class LifePath extends BaseEntity { + + /** + * 用户ID (关联t_user.id) + */ + @TableField("user_id") + private String userId; + + /** + * 关联剧本ID (关联t_epic_script.id) + */ + @TableField("script_id") + private String scriptId; + + /** + * 路径标题 + */ + @TableField("title") + private String title; + + /** + * 路径描述 + */ + @TableField("description") + private String description; + + /** + * 路径步骤列表 (JSON数组) + * 每个步骤包含: phase, time, content, action, resources, habit + */ + @TableField(value = "steps", typeHandler = JacksonTypeHandler.class) + private List> steps; + + /** + * 状态: active-进行中, completed-已完成, archived-已归档 + */ + @TableField("status") + private String status; + + /** + * 完成进度百分比 + */ + @TableField("progress") + private BigDecimal progress; +} diff --git a/backend-single/src/main/java/com/emotion/mapper/EpicScriptMapper.java b/backend-single/src/main/java/com/emotion/mapper/EpicScriptMapper.java new file mode 100644 index 0000000..4da3fb9 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/mapper/EpicScriptMapper.java @@ -0,0 +1,15 @@ +package com.emotion.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.emotion.entity.EpicScript; +import org.apache.ibatis.annotations.Mapper; + +/** + * 爽文剧本Mapper接口 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Mapper +public interface EpicScriptMapper extends BaseMapper { +} diff --git a/backend-single/src/main/java/com/emotion/mapper/LifeEventMapper.java b/backend-single/src/main/java/com/emotion/mapper/LifeEventMapper.java new file mode 100644 index 0000000..a28dda5 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/mapper/LifeEventMapper.java @@ -0,0 +1,15 @@ +package com.emotion.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.emotion.entity.LifeEvent; +import org.apache.ibatis.annotations.Mapper; + +/** + * 生命事件Mapper接口 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Mapper +public interface LifeEventMapper extends BaseMapper { +} diff --git a/backend-single/src/main/java/com/emotion/mapper/LifePathMapper.java b/backend-single/src/main/java/com/emotion/mapper/LifePathMapper.java new file mode 100644 index 0000000..536cb19 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/mapper/LifePathMapper.java @@ -0,0 +1,15 @@ +package com.emotion.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.emotion.entity.LifePath; +import org.apache.ibatis.annotations.Mapper; + +/** + * 实现路径Mapper接口 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Mapper +public interface LifePathMapper extends BaseMapper { +} diff --git a/backend-single/src/main/java/com/emotion/service/EpicScriptService.java b/backend-single/src/main/java/com/emotion/service/EpicScriptService.java new file mode 100644 index 0000000..c808317 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/EpicScriptService.java @@ -0,0 +1,75 @@ +package com.emotion.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.emotion.common.PageResult; +import com.emotion.dto.request.EpicScriptCreateRequest; +import com.emotion.dto.request.EpicScriptPageRequest; +import com.emotion.dto.request.EpicScriptUpdateRequest; +import com.emotion.dto.response.EpicScriptResponse; +import com.emotion.entity.EpicScript; + +import java.util.List; + +/** + * 爽文剧本服务接口 + * + * @author huazhongmin + * @date 2025-12-22 + */ +public interface EpicScriptService extends IService { + + /** + * 分页查询当前用户的剧本 + * + * @param request 分页请求 + * @return 分页结果 + */ + PageResult getPageByCurrentUser(EpicScriptPageRequest request); + + /** + * 获取当前用户的所有剧本列表 + * + * @return 剧本列表 + */ + List getListByCurrentUser(); + + /** + * 根据ID获取剧本 + * + * @param id 剧本ID + * @return 剧本响应 + */ + EpicScriptResponse getScriptById(String id); + + /** + * 创建剧本 + * + * @param request 创建请求 + * @return 剧本响应 + */ + EpicScriptResponse createScript(EpicScriptCreateRequest request); + + /** + * 更新剧本 + * + * @param request 更新请求 + * @return 剧本响应 + */ + EpicScriptResponse updateScript(EpicScriptUpdateRequest request); + + /** + * 选中剧本 + * + * @param id 剧本ID + * @return 剧本响应 + */ + EpicScriptResponse selectScript(String id); + + /** + * 删除剧本 + * + * @param id 剧本ID + * @return 是否成功 + */ + boolean deleteScript(String id); +} diff --git a/backend-single/src/main/java/com/emotion/service/LifeEventService.java b/backend-single/src/main/java/com/emotion/service/LifeEventService.java new file mode 100644 index 0000000..3c24d21 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/LifeEventService.java @@ -0,0 +1,67 @@ +package com.emotion.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.emotion.common.PageResult; +import com.emotion.dto.request.LifeEventCreateRequest; +import com.emotion.dto.request.LifeEventPageRequest; +import com.emotion.dto.request.LifeEventUpdateRequest; +import com.emotion.dto.response.LifeEventResponse; +import com.emotion.entity.LifeEvent; + +import java.util.List; + +/** + * 生命事件服务接口 + * + * @author huazhongmin + * @date 2025-12-22 + */ +public interface LifeEventService extends IService { + + /** + * 分页查询当前用户的生命事件 + * + * @param request 分页请求 + * @return 分页结果 + */ + PageResult getPageByCurrentUser(LifeEventPageRequest request); + + /** + * 获取当前用户的所有生命事件列表 + * + * @return 事件列表 + */ + List getListByCurrentUser(); + + /** + * 根据ID获取生命事件 + * + * @param id 事件ID + * @return 事件响应 + */ + LifeEventResponse getEventById(String id); + + /** + * 创建生命事件 + * + * @param request 创建请求 + * @return 事件响应 + */ + LifeEventResponse createEvent(LifeEventCreateRequest request); + + /** + * 更新生命事件 + * + * @param request 更新请求 + * @return 事件响应 + */ + LifeEventResponse updateEvent(LifeEventUpdateRequest request); + + /** + * 删除生命事件 + * + * @param id 事件ID + * @return 是否成功 + */ + boolean deleteEvent(String id); +} diff --git a/backend-single/src/main/java/com/emotion/service/LifePathService.java b/backend-single/src/main/java/com/emotion/service/LifePathService.java new file mode 100644 index 0000000..8c44e4c --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/LifePathService.java @@ -0,0 +1,83 @@ +package com.emotion.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.emotion.common.PageResult; +import com.emotion.dto.request.LifePathCreateRequest; +import com.emotion.dto.request.LifePathPageRequest; +import com.emotion.dto.request.LifePathUpdateRequest; +import com.emotion.dto.response.LifePathResponse; +import com.emotion.entity.LifePath; + +import java.util.List; + +/** + * 实现路径服务接口 + * + * @author huazhongmin + * @date 2025-12-22 + */ +public interface LifePathService extends IService { + + /** + * 分页查询当前用户的路径 + * + * @param request 分页请求 + * @return 分页结果 + */ + PageResult getPageByCurrentUser(LifePathPageRequest request); + + /** + * 获取当前用户的所有路径列表 + * + * @return 路径列表 + */ + List getListByCurrentUser(); + + /** + * 根据剧本ID获取路径 + * + * @param scriptId 剧本ID + * @return 路径响应 + */ + LifePathResponse getByScriptId(String scriptId); + + /** + * 根据ID获取路径 + * + * @param id 路径ID + * @return 路径响应 + */ + LifePathResponse getPathById(String id); + + /** + * 创建路径 + * + * @param request 创建请求 + * @return 路径响应 + */ + LifePathResponse createPath(LifePathCreateRequest request); + + /** + * 更新路径 + * + * @param request 更新请求 + * @return 路径响应 + */ + LifePathResponse updatePath(LifePathUpdateRequest request); + + /** + * 删除路径 + * + * @param id 路径ID + * @return 是否成功 + */ + boolean deletePath(String id); + + /** + * 根据剧本ID删除路径 + * + * @param scriptId 剧本ID + * @return 是否成功 + */ + boolean deleteByScriptId(String scriptId); +} diff --git a/backend-single/src/main/java/com/emotion/service/impl/AiConfigServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/AiConfigServiceImpl.java index 7352c9b..510754e 100644 --- a/backend-single/src/main/java/com/emotion/service/impl/AiConfigServiceImpl.java +++ b/backend-single/src/main/java/com/emotion/service/impl/AiConfigServiceImpl.java @@ -121,6 +121,14 @@ public class AiConfigServiceImpl extends ServiceImpl i BeanUtils.copyProperties(request, aiConfig); aiConfig.setId(String.valueOf(snowflakeIdGenerator.nextId())); + // 处理JSON字段,防止空字符串导致数据库报错 + if (!StringUtils.hasText(aiConfig.getCustomHeaders())) { + aiConfig.setCustomHeaders("{}"); + } + if (!StringUtils.hasText(aiConfig.getCustomParams())) { + aiConfig.setCustomParams("{}"); + } + boolean saved = this.save(aiConfig); return saved ? convertToResponse(aiConfig) : null; } @@ -133,6 +141,15 @@ public class AiConfigServiceImpl extends ServiceImpl i } BeanUtils.copyProperties(request, aiConfig); + + // 处理JSON字段,防止空字符串导致数据库报错 + if (request.getCustomHeaders() != null && !StringUtils.hasText(request.getCustomHeaders())) { + aiConfig.setCustomHeaders("{}"); + } + if (request.getCustomParams() != null && !StringUtils.hasText(request.getCustomParams())) { + aiConfig.setCustomParams("{}"); + } + boolean updated = this.updateById(aiConfig); return updated ? convertToResponse(aiConfig) : null; } diff --git a/backend-single/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java new file mode 100644 index 0000000..b7e8b20 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java @@ -0,0 +1,262 @@ +package com.emotion.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.emotion.common.PageResult; +import com.emotion.dto.request.EpicScriptCreateRequest; +import com.emotion.dto.request.EpicScriptPageRequest; +import com.emotion.dto.request.EpicScriptUpdateRequest; +import com.emotion.dto.response.EpicScriptResponse; +import com.emotion.entity.EpicScript; +import com.emotion.mapper.EpicScriptMapper; +import com.emotion.service.EpicScriptService; +import com.emotion.service.LifePathService; +import com.emotion.util.UserContextHolder; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 爽文剧本服务实现类 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Service +public class EpicScriptServiceImpl extends ServiceImpl + implements EpicScriptService { + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Autowired + @Lazy + private LifePathService lifePathService; + + @Override + public PageResult getPageByCurrentUser(EpicScriptPageRequest request) { + String currentUserId = UserContextHolder.getCurrentUserId(); + if (currentUserId == null) { + return new PageResult<>(); + } + + Page page = new Page<>(request.getCurrent(), request.getSize()); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(EpicScript::getUserId, currentUserId) + .eq(EpicScript::getIsDeleted, 0); + + // 风格筛选 + if (StringUtils.hasText(request.getStyle())) { + wrapper.eq(EpicScript::getStyle, request.getStyle()); + } + + // 篇幅筛选 + if (StringUtils.hasText(request.getLength())) { + wrapper.eq(EpicScript::getLength, request.getLength()); + } + + // 关键词搜索 + if (StringUtils.hasText(request.getKeyword())) { + wrapper.and(w -> w.like(EpicScript::getTitle, request.getKeyword()) + .or().like(EpicScript::getTheme, request.getKeyword())); + } + + // 按创建时间倒序排列 + wrapper.orderByDesc(EpicScript::getCreateTime); + + Page resultPage = this.page(page, wrapper); + List responses = resultPage.getRecords().stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + + PageResult pageResult = new PageResult<>(); + pageResult.setCurrent(resultPage.getCurrent()); + pageResult.setSize(resultPage.getSize()); + pageResult.setTotal(resultPage.getTotal()); + pageResult.setPages(resultPage.getPages()); + pageResult.setRecords(responses); + + return pageResult; + } + + @Override + public List getListByCurrentUser() { + String currentUserId = UserContextHolder.getCurrentUserId(); + if (currentUserId == null) { + return List.of(); + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(EpicScript::getUserId, currentUserId) + .eq(EpicScript::getIsDeleted, 0) + .orderByDesc(EpicScript::getCreateTime); + + return this.list(wrapper).stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + } + + @Override + public EpicScriptResponse getScriptById(String id) { + EpicScript script = this.getById(id); + if (script == null || script.getIsDeleted() == 1) { + return null; + } + + // 验证权限 + String currentUserId = UserContextHolder.getCurrentUserId(); + if (!script.getUserId().equals(currentUserId)) { + return null; + } + + return convertToResponse(script); + } + + @Override + public EpicScriptResponse createScript(EpicScriptCreateRequest request) { + String currentUserId = UserContextHolder.getCurrentUserId(); + if (currentUserId == null) { + return null; + } + + EpicScript script = new EpicScript(); + script.setUserId(currentUserId); + script.setTitle(request.getTitle()); + script.setTheme(request.getTheme()); + script.setStyle(StringUtils.hasText(request.getStyle()) ? request.getStyle() : "career"); + script.setLength(StringUtils.hasText(request.getLength()) ? request.getLength() : "medium"); + script.setPlotIntro(request.getPlotIntro()); + script.setPlotTurning(request.getPlotTurning()); + script.setPlotClimax(request.getPlotClimax()); + script.setPlotEnding(request.getPlotEnding()); + script.setPlotJson(request.getPlotJson()); + script.setIsSelected(request.getIsSelected() != null && request.getIsSelected() ? 1 : 0); + + this.save(script); + return convertToResponse(script); + } + + @Override + public EpicScriptResponse updateScript(EpicScriptUpdateRequest request) { + EpicScript script = this.getById(request.getId()); + if (script == null || script.getIsDeleted() == 1) { + return null; + } + + // 验证权限 + String currentUserId = UserContextHolder.getCurrentUserId(); + if (!script.getUserId().equals(currentUserId)) { + return null; + } + + // 更新字段 + if (StringUtils.hasText(request.getTitle())) { + script.setTitle(request.getTitle()); + } + if (request.getTheme() != null) { + script.setTheme(request.getTheme()); + } + if (StringUtils.hasText(request.getStyle())) { + script.setStyle(request.getStyle()); + } + if (StringUtils.hasText(request.getLength())) { + script.setLength(request.getLength()); + } + if (request.getPlotIntro() != null) { + script.setPlotIntro(request.getPlotIntro()); + } + if (request.getPlotTurning() != null) { + script.setPlotTurning(request.getPlotTurning()); + } + if (request.getPlotClimax() != null) { + script.setPlotClimax(request.getPlotClimax()); + } + if (request.getPlotEnding() != null) { + script.setPlotEnding(request.getPlotEnding()); + } + if (request.getPlotJson() != null) { + script.setPlotJson(request.getPlotJson()); + } + if (request.getIsSelected() != null) { + script.setIsSelected(request.getIsSelected() ? 1 : 0); + } + + this.updateById(script); + return convertToResponse(script); + } + + @Override + public EpicScriptResponse selectScript(String id) { + String currentUserId = UserContextHolder.getCurrentUserId(); + if (currentUserId == null) { + return null; + } + + // 先取消所有选中 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(EpicScript::getUserId, currentUserId) + .eq(EpicScript::getIsSelected, 1) + .eq(EpicScript::getIsDeleted, 0); + List selectedScripts = this.list(wrapper); + for (EpicScript s : selectedScripts) { + s.setIsSelected(0); + this.updateById(s); + } + + // 选中指定剧本 + EpicScript script = this.getById(id); + if (script == null || script.getIsDeleted() == 1) { + return null; + } + if (!script.getUserId().equals(currentUserId)) { + return null; + } + + script.setIsSelected(1); + this.updateById(script); + return convertToResponse(script); + } + + @Override + public boolean deleteScript(String id) { + EpicScript script = this.getById(id); + if (script == null || script.getIsDeleted() == 1) { + return false; + } + + // 验证权限 + String currentUserId = UserContextHolder.getCurrentUserId(); + if (!script.getUserId().equals(currentUserId)) { + return false; + } + + // 删除关联的路径 + lifePathService.deleteByScriptId(id); + + script.setIsDeleted(1); + return this.updateById(script); + } + + /** + * 转换为响应对象 + */ + private EpicScriptResponse convertToResponse(EpicScript script) { + EpicScriptResponse response = new EpicScriptResponse(); + BeanUtils.copyProperties(script, response); + response.setId(script.getId()); + response.setIsSelected(script.getIsSelected() != null && script.getIsSelected() == 1); + if (script.getCreateTime() != null) { + response.setCreateTime(script.getCreateTime().format(DATE_TIME_FORMATTER)); + } + if (script.getUpdateTime() != null) { + response.setUpdateTime(script.getUpdateTime().format(DATE_TIME_FORMATTER)); + } + return response; + } +} diff --git a/backend-single/src/main/java/com/emotion/service/impl/LifeEventServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/LifeEventServiceImpl.java new file mode 100644 index 0000000..685633b --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/impl/LifeEventServiceImpl.java @@ -0,0 +1,237 @@ +package com.emotion.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.emotion.common.PageResult; +import com.emotion.dto.request.LifeEventCreateRequest; +import com.emotion.dto.request.LifeEventPageRequest; +import com.emotion.dto.request.LifeEventUpdateRequest; +import com.emotion.dto.response.LifeEventResponse; +import com.emotion.entity.LifeEvent; +import com.emotion.mapper.LifeEventMapper; +import com.emotion.service.LifeEventService; +import com.emotion.util.UserContextHolder; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 生命事件服务实现类 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Service +public class LifeEventServiceImpl extends ServiceImpl + implements LifeEventService { + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_DATE_TIME; + + @Override + public PageResult getPageByCurrentUser(LifeEventPageRequest request) { + String currentUserId = UserContextHolder.getCurrentUserId(); + if (currentUserId == null) { + return new PageResult<>(); + } + + Page page = new Page<>(request.getCurrent(), request.getSize()); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(LifeEvent::getUserId, currentUserId) + .eq(LifeEvent::getIsDeleted, 0); + + // 事件类型筛选 + if (StringUtils.hasText(request.getEventType())) { + wrapper.eq(LifeEvent::getEventType, request.getEventType()); + } + + // 情绪类型筛选 + if (StringUtils.hasText(request.getEmotionType())) { + wrapper.eq(LifeEvent::getEmotionType, request.getEmotionType()); + } + + // 关键词搜索 + if (StringUtils.hasText(request.getKeyword())) { + wrapper.and(w -> w.like(LifeEvent::getTitle, request.getKeyword()) + .or().like(LifeEvent::getContent, request.getKeyword())); + } + + // 按事件日期倒序排列 + wrapper.orderByDesc(LifeEvent::getEventDate); + + Page resultPage = this.page(page, wrapper); + List responses = resultPage.getRecords().stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + + PageResult pageResult = new PageResult<>(); + pageResult.setCurrent(resultPage.getCurrent()); + pageResult.setSize(resultPage.getSize()); + pageResult.setTotal(resultPage.getTotal()); + pageResult.setPages(resultPage.getPages()); + pageResult.setRecords(responses); + + return pageResult; + } + + @Override + public List getListByCurrentUser() { + String currentUserId = UserContextHolder.getCurrentUserId(); + if (currentUserId == null) { + return List.of(); + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(LifeEvent::getUserId, currentUserId) + .eq(LifeEvent::getIsDeleted, 0) + .orderByDesc(LifeEvent::getEventDate); + + return this.list(wrapper).stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + } + + @Override + public LifeEventResponse getEventById(String id) { + LifeEvent event = this.getById(id); + if (event == null || event.getIsDeleted() == 1) { + return null; + } + + // 验证权限 + String currentUserId = UserContextHolder.getCurrentUserId(); + if (!event.getUserId().equals(currentUserId)) { + return null; + } + + return convertToResponse(event); + } + + @Override + public LifeEventResponse createEvent(LifeEventCreateRequest request) { + String currentUserId = UserContextHolder.getCurrentUserId(); + if (currentUserId == null) { + return null; + } + + LifeEvent event = new LifeEvent(); + event.setUserId(currentUserId); + event.setEventType(StringUtils.hasText(request.getEventType()) ? request.getEventType() : "daily_log"); + event.setTitle(request.getTitle()); + event.setContent(request.getContent()); + event.setAiReply(request.getAiReply()); + event.setEmotionType(request.getEmotionType()); + event.setTags(request.getTags()); + + // 解析事件日期 + if (StringUtils.hasText(request.getEventDate())) { + try { + event.setEventDate(LocalDateTime.parse(request.getEventDate(), ISO_FORMATTER)); + } catch (Exception e) { + event.setEventDate(LocalDateTime.now()); + } + } else { + event.setEventDate(LocalDateTime.now()); + } + + // 情绪评分 + if (request.getEmotionScore() != null) { + event.setEmotionScore(BigDecimal.valueOf(request.getEmotionScore())); + } + + this.save(event); + return convertToResponse(event); + } + + @Override + public LifeEventResponse updateEvent(LifeEventUpdateRequest request) { + LifeEvent event = this.getById(request.getId()); + if (event == null || event.getIsDeleted() == 1) { + return null; + } + + // 验证权限 + String currentUserId = UserContextHolder.getCurrentUserId(); + if (!event.getUserId().equals(currentUserId)) { + return null; + } + + // 更新字段 + if (StringUtils.hasText(request.getEventType())) { + event.setEventType(request.getEventType()); + } + if (StringUtils.hasText(request.getTitle())) { + event.setTitle(request.getTitle()); + } + if (StringUtils.hasText(request.getContent())) { + event.setContent(request.getContent()); + } + if (request.getAiReply() != null) { + event.setAiReply(request.getAiReply()); + } + if (request.getEmotionType() != null) { + event.setEmotionType(request.getEmotionType()); + } + if (request.getTags() != null) { + event.setTags(request.getTags()); + } + if (StringUtils.hasText(request.getEventDate())) { + try { + event.setEventDate(LocalDateTime.parse(request.getEventDate(), ISO_FORMATTER)); + } catch (Exception ignored) { + } + } + if (request.getEmotionScore() != null) { + event.setEmotionScore(BigDecimal.valueOf(request.getEmotionScore())); + } + + this.updateById(event); + return convertToResponse(event); + } + + @Override + public boolean deleteEvent(String id) { + LifeEvent event = this.getById(id); + if (event == null || event.getIsDeleted() == 1) { + return false; + } + + // 验证权限 + String currentUserId = UserContextHolder.getCurrentUserId(); + if (!event.getUserId().equals(currentUserId)) { + return false; + } + + event.setIsDeleted(1); + return this.updateById(event); + } + + /** + * 转换为响应对象 + */ + private LifeEventResponse convertToResponse(LifeEvent event) { + LifeEventResponse response = new LifeEventResponse(); + BeanUtils.copyProperties(event, response); + response.setId(event.getId()); + if (event.getEventDate() != null) { + response.setEventDate(event.getEventDate().format(ISO_FORMATTER)); + } + if (event.getEmotionScore() != null) { + response.setEmotionScore(event.getEmotionScore().doubleValue()); + } + if (event.getCreateTime() != null) { + response.setCreateTime(event.getCreateTime().format(DATE_TIME_FORMATTER)); + } + if (event.getUpdateTime() != null) { + response.setUpdateTime(event.getUpdateTime().format(DATE_TIME_FORMATTER)); + } + return response; + } +} diff --git a/backend-single/src/main/java/com/emotion/service/impl/LifePathServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/LifePathServiceImpl.java new file mode 100644 index 0000000..bfaef67 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/impl/LifePathServiceImpl.java @@ -0,0 +1,235 @@ +package com.emotion.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.emotion.common.PageResult; +import com.emotion.dto.request.LifePathCreateRequest; +import com.emotion.dto.request.LifePathPageRequest; +import com.emotion.dto.request.LifePathUpdateRequest; +import com.emotion.dto.response.LifePathResponse; +import com.emotion.entity.LifePath; +import com.emotion.mapper.LifePathMapper; +import com.emotion.service.LifePathService; +import com.emotion.util.UserContextHolder; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 实现路径服务实现类 + * + * @author huazhongmin + * @date 2025-12-22 + */ +@Service +public class LifePathServiceImpl extends ServiceImpl + implements LifePathService { + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Override + public PageResult getPageByCurrentUser(LifePathPageRequest request) { + String currentUserId = UserContextHolder.getCurrentUserId(); + if (currentUserId == null) { + return new PageResult<>(); + } + + Page page = new Page<>(request.getCurrent(), request.getSize()); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(LifePath::getUserId, currentUserId) + .eq(LifePath::getIsDeleted, 0); + + // 剧本ID筛选 + if (StringUtils.hasText(request.getScriptId())) { + wrapper.eq(LifePath::getScriptId, request.getScriptId()); + } + + // 状态筛选 + if (StringUtils.hasText(request.getStatus())) { + wrapper.eq(LifePath::getStatus, request.getStatus()); + } + + // 按创建时间倒序排列 + wrapper.orderByDesc(LifePath::getCreateTime); + + Page resultPage = this.page(page, wrapper); + List responses = resultPage.getRecords().stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + + PageResult pageResult = new PageResult<>(); + pageResult.setCurrent(resultPage.getCurrent()); + pageResult.setSize(resultPage.getSize()); + pageResult.setTotal(resultPage.getTotal()); + pageResult.setPages(resultPage.getPages()); + pageResult.setRecords(responses); + + return pageResult; + } + + @Override + public List getListByCurrentUser() { + String currentUserId = UserContextHolder.getCurrentUserId(); + if (currentUserId == null) { + return List.of(); + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(LifePath::getUserId, currentUserId) + .eq(LifePath::getIsDeleted, 0) + .orderByDesc(LifePath::getCreateTime); + + return this.list(wrapper).stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + } + + @Override + public LifePathResponse getByScriptId(String scriptId) { + String currentUserId = UserContextHolder.getCurrentUserId(); + if (currentUserId == null) { + return null; + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(LifePath::getUserId, currentUserId) + .eq(LifePath::getScriptId, scriptId) + .eq(LifePath::getIsDeleted, 0); + + LifePath path = this.getOne(wrapper); + return path != null ? convertToResponse(path) : null; + } + + @Override + public LifePathResponse getPathById(String id) { + LifePath path = this.getById(id); + if (path == null || path.getIsDeleted() == 1) { + return null; + } + + // 验证权限 + String currentUserId = UserContextHolder.getCurrentUserId(); + if (!path.getUserId().equals(currentUserId)) { + return null; + } + + return convertToResponse(path); + } + + @Override + public LifePathResponse createPath(LifePathCreateRequest request) { + String currentUserId = UserContextHolder.getCurrentUserId(); + if (currentUserId == null) { + return null; + } + + LifePath path = new LifePath(); + path.setUserId(currentUserId); + path.setScriptId(request.getScriptId()); + path.setTitle(request.getTitle()); + path.setDescription(request.getDescription()); + path.setSteps(request.getSteps()); + path.setStatus(StringUtils.hasText(request.getStatus()) ? request.getStatus() : "active"); + path.setProgress(request.getProgress() != null ? BigDecimal.valueOf(request.getProgress()) : BigDecimal.ZERO); + + this.save(path); + return convertToResponse(path); + } + + + @Override + public LifePathResponse updatePath(LifePathUpdateRequest request) { + LifePath path = this.getById(request.getId()); + if (path == null || path.getIsDeleted() == 1) { + return null; + } + + // 验证权限 + String currentUserId = UserContextHolder.getCurrentUserId(); + if (!path.getUserId().equals(currentUserId)) { + return null; + } + + // 更新字段 + if (StringUtils.hasText(request.getTitle())) { + path.setTitle(request.getTitle()); + } + if (request.getDescription() != null) { + path.setDescription(request.getDescription()); + } + if (request.getSteps() != null) { + path.setSteps(request.getSteps()); + } + if (StringUtils.hasText(request.getStatus())) { + path.setStatus(request.getStatus()); + } + if (request.getProgress() != null) { + path.setProgress(BigDecimal.valueOf(request.getProgress())); + } + + this.updateById(path); + return convertToResponse(path); + } + + @Override + public boolean deletePath(String id) { + LifePath path = this.getById(id); + if (path == null || path.getIsDeleted() == 1) { + return false; + } + + // 验证权限 + String currentUserId = UserContextHolder.getCurrentUserId(); + if (!path.getUserId().equals(currentUserId)) { + return false; + } + + path.setIsDeleted(1); + return this.updateById(path); + } + + @Override + public boolean deleteByScriptId(String scriptId) { + String currentUserId = UserContextHolder.getCurrentUserId(); + if (currentUserId == null) { + return false; + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(LifePath::getUserId, currentUserId) + .eq(LifePath::getScriptId, scriptId) + .eq(LifePath::getIsDeleted, 0); + + List paths = this.list(wrapper); + for (LifePath path : paths) { + path.setIsDeleted(1); + this.updateById(path); + } + return true; + } + + /** + * 转换为响应对象 + */ + private LifePathResponse convertToResponse(LifePath path) { + LifePathResponse response = new LifePathResponse(); + BeanUtils.copyProperties(path, response); + response.setId(path.getId()); + if (path.getProgress() != null) { + response.setProgress(path.getProgress().doubleValue()); + } + if (path.getCreateTime() != null) { + response.setCreateTime(path.getCreateTime().format(DATE_TIME_FORMATTER)); + } + if (path.getUpdateTime() != null) { + response.setUpdateTime(path.getUpdateTime().format(DATE_TIME_FORMATTER)); + } + return response; + } +} diff --git a/course-web/package-lock.json b/course-web/package-lock.json index a2f8003..dd27fa8 100644 --- a/course-web/package-lock.json +++ b/course-web/package-lock.json @@ -20,6 +20,8 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", @@ -27,12 +29,84 @@ "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", + "fast-check": "^4.5.2", "globals": "^16.5.0", + "jsdom": "^27.3.0", "postcss": "^8.5.6", "tailwindcss": "^4.1.18", - "vite": "npm:rolldown-vite@7.2.5" + "vite": "npm:rolldown-vite@7.2.5", + "vitest": "^4.0.16" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.29", + "resolved": "https://registry.npmmirror.com/@acemir/cssom/-/cssom-0.9.29.tgz", + "integrity": "sha512-G90x0VW+9nW4dFajtjCoT+NM0scAfH9Mb08IcjgFHYbfiL/lU04dTF9JuVOi3/OH+DJCQdcIseSXkdCB9Ky6JA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmmirror.com/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -64,7 +138,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -268,6 +341,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.27.2.tgz", @@ -316,6 +399,141 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.22", + "resolved": "https://registry.npmmirror.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.22.tgz", + "integrity": "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.7.1.tgz", @@ -1032,6 +1250,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.17.tgz", @@ -1325,6 +1550,82 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmmirror.com/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.1", + "resolved": "https://registry.npmmirror.com/@testing-library/react/-/react-16.3.1.tgz", + "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1335,6 +1636,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1380,6 +1689,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", @@ -1400,7 +1727,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1436,13 +1762,123 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.16", + "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-4.0.16.tgz", + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.16", + "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.16", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.16", + "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.16", + "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-4.0.16.tgz", + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.16", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.16", + "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-4.0.16.tgz", + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.16", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-4.0.16.tgz", + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.16", + "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-4.0.16.tgz", + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1460,6 +1896,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", @@ -1477,6 +1923,17 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1500,6 +1957,26 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", @@ -1571,6 +2048,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1602,7 +2089,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1661,6 +2147,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.1", + "resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.1.tgz", + "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", @@ -1748,6 +2244,42 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "5.3.5", + "resolved": "https://registry.npmmirror.com/cssstyle/-/cssstyle-5.3.5.tgz", + "integrity": "sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", @@ -1755,6 +2287,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz", @@ -1783,6 +2329,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", @@ -1799,6 +2352,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1808,6 +2371,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1842,6 +2413,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "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.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1860,6 +2444,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1916,7 +2507,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2085,6 +2675,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", @@ -2095,6 +2695,39 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-check": { + "version": "4.5.2", + "resolved": "https://registry.npmmirror.com/fast-check/-/fast-check-4.5.2.tgz", + "integrity": "sha512-tOzL01LMrDIWPLfvMiGUMH0AjqnOelHQPmgvYkW/aRO4Yaw+pBQqWmyebNzAEbKOigoCN8HkRWUZXFkjmiaXMQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^7.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2414,6 +3047,60 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", @@ -2451,6 +3138,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2474,6 +3171,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", @@ -2510,6 +3214,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.3.0", + "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-27.3.0.tgz", + "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", @@ -2872,6 +3616,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", @@ -2890,6 +3645,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", @@ -2911,6 +3673,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", @@ -2963,6 +3735,17 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", @@ -3026,6 +3809,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", @@ -3046,6 +3842,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -3057,7 +3860,6 @@ "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3084,7 +3886,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3111,6 +3912,36 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3127,12 +3958,28 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3142,7 +3989,6 @@ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3150,6 +3996,14 @@ "react": "^19.2.3" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.18.0.tgz", @@ -3160,6 +4014,30 @@ "node": ">=0.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3208,6 +4086,26 @@ "integrity": "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==", "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", @@ -3247,6 +4145,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3256,6 +4161,33 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3282,6 +4214,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.3.0", "resolved": "https://registry.npmmirror.com/tabbable/-/tabbable-6.3.0.tgz", @@ -3317,6 +4256,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3333,6 +4289,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmmirror.com/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", @@ -3408,7 +4420,6 @@ "resolved": "https://registry.npmmirror.com/rolldown-vite/-/rolldown-vite-7.2.5.tgz", "integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==", "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/runtime": "0.97.0", "fdir": "^6.5.0", @@ -3479,6 +4490,144 @@ } } }, + "node_modules/vitest": { + "version": "4.0.16", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-4.0.16.tgz", + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", @@ -3495,6 +4644,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3505,6 +4671,45 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", @@ -3531,7 +4736,6 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/course-web/package.json b/course-web/package.json index 4c98f27..ce0798f 100644 --- a/course-web/package.json +++ b/course-web/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@headlessui/react": "^2.2.9", @@ -22,6 +24,8 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", @@ -29,10 +33,13 @@ "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", + "fast-check": "^4.5.2", "globals": "^16.5.0", + "jsdom": "^27.3.0", "postcss": "^8.5.6", "tailwindcss": "^4.1.18", - "vite": "npm:rolldown-vite@7.2.5" + "vite": "npm:rolldown-vite@7.2.5", + "vitest": "^4.0.16" }, "overrides": { "vite": "npm:rolldown-vite@7.2.5" diff --git a/course-web/src/App.jsx b/course-web/src/App.jsx index 64ad5ea..d3a2048 100644 --- a/course-web/src/App.jsx +++ b/course-web/src/App.jsx @@ -56,7 +56,9 @@ function CurrentPage() { return ( { - Store.completeOnboarding(); + // 登录成功后,检查用户是否已完成引导 + // 如果已完成,直接进入仪表盘;否则进入引导流程 + setView('onboarding'); }} onBack={() => setView('landing')} onSignUp={() => setView('onboarding')} diff --git a/course-web/src/api/auth.js b/course-web/src/api/auth.js new file mode 100644 index 0000000..9dba059 --- /dev/null +++ b/course-web/src/api/auth.js @@ -0,0 +1,86 @@ +import request from '../utils/request'; + +/** + * 认证相关 API + */ +export const authApi = { + /** + * 发送短信验证码 + * @param {string} phone - 手机号 + */ + sendSmsCode(phone) { + return request({ + url: '/auth/sms-code', + method: 'get', + params: { phone } + }); + }, + + /** + * 手机号验证码登录 + * @param {Object} data - 登录参数 + * @param {string} data.phone - 手机号 + * @param {string} data.smsCode - 短信验证码 + */ + login(data) { + return request({ + url: '/auth/login', + method: 'post', + data + }); + }, + + /** + * 获取当前用户信息 + */ + getUserInfo() { + return request({ + url: '/auth/userInfo', + method: 'get' + }); + }, + + /** + * 用户登出 + */ + logout() { + return request({ + url: '/auth/logout', + method: 'post' + }); + }, + + /** + * 刷新令牌 + * @param {string} refreshToken - 刷新令牌 + */ + refreshToken(refreshToken) { + return request({ + url: '/auth/refreshToken', + method: 'post', + data: { refreshToken } + }); + }, + + /** + * 验证令牌 + */ + validateToken() { + return request({ + url: '/auth/validateToken', + method: 'get' + }); + }, + + /** + * 检查手机号是否存在 + * @param {string} phone - 手机号 + */ + checkPhone(phone) { + return request({ + url: '/auth/checkPhone', + method: 'get', + params: { phone } + }); + } +}; diff --git a/course-web/src/api/lifeProfile.js b/course-web/src/api/lifeProfile.js new file mode 100644 index 0000000..86484d4 --- /dev/null +++ b/course-web/src/api/lifeProfile.js @@ -0,0 +1,233 @@ +import request from '../utils/request'; + +/** + * 生命事件 API + */ +export const lifeEventApi = { + /** + * 分页查询生命事件 + */ + getPage(params) { + return request({ + url: '/lifeEvent/page', + method: 'get', + params + }); + }, + + /** + * 获取所有生命事件列表 + */ + getList() { + return request({ + url: '/lifeEvent/list', + method: 'get' + }); + }, + + /** + * 获取生命事件详情 + */ + getById(id) { + return request({ + url: '/lifeEvent/detail', + method: 'get', + params: { id } + }); + }, + + /** + * 创建生命事件 + */ + create(data) { + return request({ + url: '/lifeEvent/create', + method: 'post', + data + }); + }, + + /** + * 更新生命事件 + */ + update(data) { + return request({ + url: '/lifeEvent/update', + method: 'put', + data + }); + }, + + /** + * 删除生命事件 + */ + delete(id) { + return request({ + url: '/lifeEvent/delete', + method: 'delete', + params: { id } + }); + } +}; + +/** + * 爽文剧本 API + */ +export const epicScriptApi = { + /** + * 分页查询爽文剧本 + */ + getPage(params) { + return request({ + url: '/epicScript/page', + method: 'get', + params + }); + }, + + /** + * 获取所有爽文剧本列表 + */ + getList() { + return request({ + url: '/epicScript/listAll', + method: 'get' + }); + }, + + /** + * 获取爽文剧本详情 + */ + getById(id) { + return request({ + url: '/epicScript/detail', + method: 'get', + params: { id } + }); + }, + + /** + * 创建爽文剧本 + */ + create(data) { + return request({ + url: '/epicScript/create', + method: 'post', + data + }); + }, + + /** + * 更新爽文剧本 + */ + update(data) { + return request({ + url: '/epicScript/update', + method: 'put', + data + }); + }, + + /** + * 选中剧本 + */ + select(id) { + return request({ + url: '/epicScript/select', + method: 'put', + params: { id } + }); + }, + + /** + * 删除爽文剧本 + */ + delete(id) { + return request({ + url: '/epicScript/delete', + method: 'delete', + params: { id } + }); + } +}; + +/** + * 实现路径 API + */ +export const lifePathApi = { + /** + * 分页查询实现路径 + */ + getPage(params) { + return request({ + url: '/lifePath/page', + method: 'get', + params + }); + }, + + /** + * 获取所有实现路径列表 + */ + getList() { + return request({ + url: '/lifePath/listAll', + method: 'get' + }); + }, + + /** + * 根据剧本ID获取实现路径 + */ + getByScriptId(scriptId) { + return request({ + url: '/lifePath/byScript', + method: 'get', + params: { scriptId } + }); + }, + + /** + * 获取实现路径详情 + */ + getById(id) { + return request({ + url: '/lifePath/detail', + method: 'get', + params: { id } + }); + }, + + /** + * 创建实现路径 + */ + create(data) { + return request({ + url: '/lifePath/create', + method: 'post', + data + }); + }, + + /** + * 更新实现路径 + */ + update(data) { + return request({ + url: '/lifePath/update', + method: 'put', + data + }); + }, + + /** + * 删除实现路径 + */ + delete(id) { + return request({ + url: '/lifePath/delete', + method: 'delete', + params: { id } + }); + } +}; diff --git a/course-web/src/components/UserMenu.jsx b/course-web/src/components/UserMenu.jsx index cb63a19..2695d90 100644 --- a/course-web/src/components/UserMenu.jsx +++ b/course-web/src/components/UserMenu.jsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useEffect } from 'react'; import { useStoreData } from '../hooks/useStoreData'; import { Store } from '../utils/store'; +import { userApi } from '../api/user'; import { User, Settings, LogOut, X, Edit2 } from 'lucide-react'; import { GlassCard } from './ui/GlassCard'; import { Button } from './ui/Button'; @@ -155,23 +156,44 @@ function EditProfileModal({ onClose, userProfile }) { gender: userProfile.gender || 'secret' }); const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(''); - const handleSave = () => { + const handleSave = async () => { setIsSaving(true); + setError(''); - // 更新用户资料 - Store.updateProfile({ + const updatedProfile = { nickname: formData.nickname, mbti: formData.mbti, zodiac: formData.zodiac, hobbies: formData.hobbies.split(',').map(s => s.trim()).filter(s => s), gender: formData.gender - }); + }; + + try { + // 1. 更新本地 Store + Store.updateProfile(updatedProfile); + + // 2. 同步到后端 + const currentProfile = await userApi.getCurrentUser(); + if (currentProfile.data && currentProfile.data.id) { + await userApi.updateUserProfile({ + id: currentProfile.data.id, + nickname: updatedProfile.nickname, + mbti: updatedProfile.mbti, + zodiac: updatedProfile.zodiac, + hobbies: JSON.stringify(updatedProfile.hobbies), + gender: updatedProfile.gender + }); + } - setTimeout(() => { - setIsSaving(false); onClose(); - }, 300); + } catch (e) { + console.error('保存资料失败:', e); + setError(e.response?.data?.message || '保存失败,请重试'); + } finally { + setIsSaving(false); + } }; return ( @@ -255,6 +277,13 @@ function EditProfileModal({ onClose, userProfile }) { + + {/* 错误提示 */} + {error && ( +
+ {error} +
+ )} {/* 操作按钮 */} diff --git a/course-web/src/components/views/PathView.jsx b/course-web/src/components/views/PathView.jsx index ea88117..1b08446 100644 --- a/course-web/src/components/views/PathView.jsx +++ b/course-web/src/components/views/PathView.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useStoreData } from '../../hooks/useStoreData'; import { Store } from '../../utils/store'; import { AI } from '../../utils/aiLogic'; +import { userApi } from '../../api/user'; import { Map, Ghost, diff --git a/course-web/src/components/views/TimelineView.jsx b/course-web/src/components/views/TimelineView.jsx index aafd830..27eabb4 100644 --- a/course-web/src/components/views/TimelineView.jsx +++ b/course-web/src/components/views/TimelineView.jsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { Store } from '../../utils/store'; import { AI } from '../../utils/aiLogic'; import { useStoreData } from '../../hooks/useStoreData'; -import { BookHeart, Bot, Send, Loader2, PenTool, HeartHandshake, Microscope, Sprout, Quote } from 'lucide-react'; +import { BookHeart, Bot, Send, Loader2, PenTool, HeartHandshake, Microscope, Sprout, Quote, Sparkles } from 'lucide-react'; import { Button } from '../ui/Button'; import { Input, Textarea } from '../ui/Input'; import { GlassCard as Card } from '../ui/GlassCard'; diff --git a/course-web/src/pages/LoginPage.jsx b/course-web/src/pages/LoginPage.jsx index 950207d..4f4eb9d 100644 --- a/course-web/src/pages/LoginPage.jsx +++ b/course-web/src/pages/LoginPage.jsx @@ -1,154 +1,261 @@ -import React, { useState } from 'react'; -import { User, Lock, Eye, EyeOff, ArrowRight, ArrowLeft } from 'lucide-react'; +/** + * 登录页面组件 + * 实现手机号验证码登录功能 + * Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8 + */ +import { useState, useEffect, useCallback } from 'react'; +import { ArrowRight, Phone, KeyRound } from 'lucide-react'; import { GlassCard } from '../components/ui/GlassCard'; import { Button } from '../components/ui/Button'; import { Input } from '../components/ui/Input'; -import { Checkbox } from '../components/ui/Checkbox'; import { Store } from '../utils/store'; +import { authApi } from '../api/auth'; +import { userApi } from '../api/user'; -export function LoginPage({ onLoginSuccess, onBack, onSignUp }) { - const [formData, setFormData] = useState({ - username: '', - password: '', - rememberMe: false - }); - const [showPassword, setShowPassword] = useState(false); +/** + * 验证手机号格式 + * Property 1: 手机号格式验证 + * @param {string} phone - 手机号 + * @returns {boolean} 是否为有效的11位手机号 + */ +export function validatePhone(phone) { + return /^1[3-9]\d{9}$/.test(phone); +} + +/** + * 登录页面 + * @param {Object} props + * @param {Function} props.onLoginSuccess - 登录成功回调 + */ +export function LoginPage({ onLoginSuccess }) { + const [phone, setPhone] = useState(''); + const [code, setCode] = useState(''); + const [countdown, setCountdown] = useState(0); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); + // 验证码倒计时 + useEffect(() => { + if (countdown > 0) { + const timer = setTimeout(() => setCountdown(countdown - 1), 1000); + return () => clearTimeout(timer); + } + }, [countdown]); + + /** + * 发送验证码 + */ + const handleSendCode = useCallback(async () => { + setError(''); + + if (!validatePhone(phone)) { + setError('请输入正确的11位手机号'); + return; + } + + try { + await authApi.sendSmsCode(phone); + setCountdown(60); + } catch (e) { + const errorMsg = e.response?.data?.message || '验证码发送失败,请稍后重试'; + setError(errorMsg); + } + }, [phone]); + + /** + * 提交登录 + */ const handleSubmit = async (e) => { e.preventDefault(); setError(''); + + if (!validatePhone(phone)) { + setError('请输入正确的11位手机号'); + return; + } + + if (code.length !== 6) { + setError('请输入6位验证码'); + return; + } + setIsLoading(true); - // Mock login delay - setTimeout(() => { - if (!formData.username || !formData.password) { - setError('请输入用户名和密码'); - setIsLoading(false); - return; - } - - // Simple mock validation - if (formData.password.length < 6) { - setError('密码长度不能少于6位'); - setIsLoading(false); - return; - } - - // Success - Store.updateProfile({ - nickname: formData.username, - // In a real app, we'd store a token - }); + try { + const response = await authApi.login({ phone, smsCode: code }); - setIsLoading(false); + // 保存token到localStorage + if (response.data?.token) { + localStorage.setItem('token', response.data.token); + } + if (response.data?.refreshToken) { + localStorage.setItem('refreshToken', response.data.refreshToken); + } + + // 从后端加载用户档案数据 + try { + const profileRes = await userApi.getCurrentUser(); + if (profileRes.data && profileRes.data.id) { + // 用户已有档案,同步到本地Store + const backendData = profileRes.data; + const mappedProfile = { + nickname: backendData.nickname || '', + gender: backendData.gender || 'secret', + zodiac: backendData.zodiac || '', + mbti: backendData.mbti || '', + hobbies: backendData.hobbies ? JSON.parse(backendData.hobbies) : [], + history: { + childhood: { + date: backendData.childhoodDate || '', + content: backendData.childhoodContent || '' + }, + peak: { + date: backendData.peakDate || '', + content: backendData.peakContent || '' + }, + valley: { + date: backendData.valleyDate || '', + content: backendData.valleyContent || '' + } + }, + futureVision: backendData.futureVision || '' + }; + + Store.updateProfile(mappedProfile); + + // 同步剧本和路径数据 + if (backendData.scripts) { + try { + const scripts = JSON.parse(backendData.scripts); + if (Array.isArray(scripts) && scripts.length > 0) { + const data = Store.get(); + data.generatedScripts = scripts; + Store.save(data); + } + } catch (e) { + console.warn('解析剧本数据失败', e); + } + } + + if (backendData.paths) { + try { + const paths = JSON.parse(backendData.paths); + if (Array.isArray(paths) && paths.length > 0) { + const data = Store.get(); + data.paths = paths; + Store.save(data); + } + } catch (e) { + console.warn('解析路径数据失败', e); + } + } + + // 如果用户已有完整档案(有昵称和MBTI),标记为已完成引导 + if (backendData.nickname && backendData.mbti) { + Store.completeOnboarding(); + } + } + } catch (e) { + console.warn('加载用户档案失败,将进入引导流程', e); + } + + // 更新本地用户信息 + Store.updateProfile({ phone }); onLoginSuccess(); - }, 1000); + } catch (e) { + const errorMsg = e.response?.data?.message || '登录失败,请稍后重试'; + setError(errorMsg); + } finally { + setIsLoading(false); + } }; return ( -
- -
- -
+
+ + {/* 标题区域 */} +
+

欢迎回来

+

开启你的数字生命档案

+
- - {/* Decorative elements */} -
-
- -
-
-

欢迎回来

-

登录您的 Emotion Museum 账号

+
+ {/* 手机号输入 */} +
+ +
+ + setPhone(e.target.value.replace(/\D/g, ''))} + className="pl-11 text-center tracking-[0.1em] bg-black/20 border-white/5 rounded-2xl focus:border-orange-200/50 focus:shadow-[0_0_20px_rgba(255,171,145,0.1)]" + /> +
- -
- + {/* 验证码输入 */} +
+
+
- - setFormData({...formData, username: e.target.value})} + + setCode(e.target.value.replace(/\D/g, ''))} + className="pl-11 text-center bg-black/20 border-white/5 rounded-2xl focus:border-orange-200/50 focus:shadow-[0_0_20px_rgba(255,171,145,0.1)]" />
- -
- -
- - setFormData({...formData, password: e.target.value})} - /> - -
-
- -
- setFormData({...formData, rememberMe: checked})} - /> -
- - {error && ( -
- - {error} -
- )} - - - - -
- 还没有账号? -
-
+ + {/* 错误提示 */} + {error && ( +
+ + {error} +
+ )} + + {/* 登录按钮 */} + + + + {/* 协议提示 */} +

+ 登录即代表同意《用户协议》与《隐私政策》,我们将妥善保管您的生命数据。 +

); diff --git a/course-web/src/pages/OnboardingPage.jsx b/course-web/src/pages/OnboardingPage.jsx index 43e4b0e..2ca23a8 100644 --- a/course-web/src/pages/OnboardingPage.jsx +++ b/course-web/src/pages/OnboardingPage.jsx @@ -1,4 +1,9 @@ -import React, { useState, useEffect } from 'react'; +/** + * 引导流程页面组件 + * 实现5步引导流程:基本信息、童年记忆、开心经历、低谷时光、未来愿景 + * Requirements: 2.1-2.11 + */ +import { useState, useEffect } from 'react'; import { Store } from '../utils/store'; import { userApi } from '../api/user'; import { ArrowLeft, ArrowRight, Check, X, Sparkles, Star, AlertCircle, CheckCircle, Loader } from 'lucide-react'; @@ -6,14 +11,50 @@ import { Button } from '../components/ui/Button'; import { Input, Select, Textarea } from '../components/ui/Input'; import clsx from 'clsx'; +/** 星座选项 */ const ZODIAC_SIGNS = [ "白羊座", "金牛座", "双子座", "巨蟹座", "狮子座", "处女座", "天秤座", "天蝎座", "射手座", "摩羯座", "水瓶座", "双鱼座" ]; +/** MBTI 类型选项 */ const MBTI_TYPES = ['INTJ','INTP','ENTJ','ENTP','INFJ','INFP','ENFJ','ENFP','ISTJ','ISFJ','ESTJ','ESFJ','ISTP','ISFP','ESTP','ESFP']; +/** + * 灵感标签词库 + * 用于帮助用户快速填写记忆描述 + */ +const INSPIRATION_CLUSTERS = { + childhood: ['秋千', '晚霞', '糖果', '奔跑', '蝉鸣', '雨后泥土', '旧书包', '风筝'], + peak: ['海浪', '拥抱', '掌声', '晨曦', '破土而出', '默契', '星空', '释放'], + valley: ['落叶', '雨伞', '长廊', '深呼吸', '自愈', '沉潜', '坚韧', '等待', '破茧'] +}; + +/** + * 灵感标签组件 + * 点击标签将文字插入到文本框中 + * @param {Object} props + * @param {string} props.type - 标签类型 (childhood/peak/valley) + * @param {Function} props.onInsert - 插入文字回调 + */ +function InspirationTags({ type, onInsert }) { + const words = INSPIRATION_CLUSTERS[type] || []; + return ( +
+ {words.map(word => ( + onInsert(word)} + className="px-3 py-1 bg-white/5 border border-white/5 rounded-full text-[10px] text-white/40 cursor-pointer hover:bg-orange-200/10 hover:text-orange-200 transition-all select-none" + > + {word} + + ))} +
+ ); +} + export function OnboardingPage({ onFinish }) { const [step, setStep] = useState(0); const [formData, setFormData] = useState(Store.get().userProfile); @@ -110,7 +151,7 @@ export function OnboardingPage({ onFinish }) { gender: formData.gender, zodiac: formData.zodiac, mbti: formData.mbti, - hobbies: JSON.stringify(formData.hobbies), + hobbies: JSON.stringify(formData.hobbies || []), childhoodDate: formData.history?.childhood?.date || null, childhoodContent: formData.history?.childhood?.content || '', peakDate: formData.history?.peak?.date || null, @@ -118,26 +159,24 @@ export function OnboardingPage({ onFinish }) { valleyDate: formData.history?.valley?.date || null, valleyContent: formData.history?.valley?.content || '', futureVision: formData.futureVision, - // Also sync scripts/paths if they exist in store (re-registration case) scripts: JSON.stringify(Store.get().generatedScripts || []), paths: JSON.stringify(Store.get().paths || []) }; - // 3. Call backend - // Check if profile exists first + // 3. Call backend - check if profile exists first const currentProfile = await userApi.getCurrentUser(); - if (currentProfile.data) { - // Update + if (currentProfile.data && currentProfile.data.id) { + // Update existing profile await userApi.updateUserProfile({ ...requestData, id: currentProfile.data.id }); } else { - // Create + // Create new profile await userApi.createUserProfile(requestData); } onFinish(); } catch (e) { - console.error(e); - showToast('保存失败,请重试'); + console.error('保存档案失败:', e); + showToast(e.response?.data?.message || '保存失败,请重试'); } finally { setSubmitting(false); } @@ -253,6 +292,11 @@ export function OnboardingPage({ onFinish }) {