人生轨迹功能模块补充

This commit is contained in:
2025-12-22 14:50:14 +08:00
parent fa57938a9d
commit cd6d995d5a
48 changed files with 5359 additions and 316 deletions
+405
View File
@@ -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<LifeEvent, 'id' | 'aiFeedback'>) => Promise<void>;
}
```
### 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<void>;
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<void>;
onSwitchToScript: () => void;
}
```
### 7. 用户菜单组件 (UserMenu.jsx)
```jsx
/**
* 用户菜单弹窗组件
* 查看和编辑用户资料
*/
interface UserMenuProps {
isOpen: boolean;
onClose: () => void;
onLogout: () => void;
}
interface UserMenuState {
isEditing: boolean;
editData: Partial<UserProfile>;
}
```
## 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
```
@@ -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 显示存储空间不足的提示
+173
View File
@@ -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
- 后端接口调用逻辑保持不变,仅重构前端组件
+3
View File
@@ -14,3 +14,6 @@
- 动画:GSAP
- AIOpenRouter (DeepSeek)
- 存储:LocalStorage
## 本地启动
本地启动访问命令: python3 -m http.server 8081
@@ -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<PageResult<EpicScriptResponse>> getPage(@Validated EpicScriptPageRequest request) {
PageResult<EpicScriptResponse> pageResult = epicScriptService.getPageByCurrentUser(request);
return Result.success(pageResult);
}
/**
* 获取当前用户的所有爽文剧本列表
*/
@GetMapping(value = "/listAll")
public Result<List<EpicScriptResponse>> getList() {
List<EpicScriptResponse> scripts = epicScriptService.getListByCurrentUser();
return Result.success(scripts);
}
/**
* 根据ID获取爽文剧本详情
*/
@GetMapping(value = "/detail")
public Result<EpicScriptResponse> getById(@RequestParam String id) {
EpicScriptResponse script = epicScriptService.getScriptById(id);
if (script == null) {
return Result.notFound("爽文剧本不存在");
}
return Result.success(script);
}
/**
* 创建爽文剧本
*/
@PostMapping(value = "/create")
public Result<EpicScriptResponse> 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<EpicScriptResponse> 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<EpicScriptResponse> select(@RequestParam String id) {
EpicScriptResponse script = epicScriptService.selectScript(id);
if (script == null) {
return Result.error("选中失败");
}
return Result.success(script);
}
/**
* 删除爽文剧本
*/
@DeleteMapping(value = "/delete")
public Result<Void> delete(@RequestParam String id) {
boolean deleted = epicScriptService.deleteScript(id);
if (!deleted) {
return Result.error("删除失败");
}
return Result.success();
}
}
@@ -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<PageResult<LifeEventResponse>> getPage(@Validated LifeEventPageRequest request) {
PageResult<LifeEventResponse> pageResult = lifeEventService.getPageByCurrentUser(request);
return Result.success(pageResult);
}
/**
* 获取当前用户的所有生命事件列表
*/
@GetMapping(value = "/list")
public Result<List<LifeEventResponse>> getList() {
List<LifeEventResponse> events = lifeEventService.getListByCurrentUser();
return Result.success(events);
}
/**
* 根据ID获取生命事件详情
*/
@GetMapping(value = "/detail")
public Result<LifeEventResponse> getById(@RequestParam String id) {
LifeEventResponse event = lifeEventService.getEventById(id);
if (event == null) {
return Result.notFound("生命事件不存在");
}
return Result.success(event);
}
/**
* 创建生命事件
*/
@PostMapping(value = "/create")
public Result<LifeEventResponse> 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<LifeEventResponse> 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<Void> delete(@RequestParam String id) {
boolean deleted = lifeEventService.deleteEvent(id);
if (!deleted) {
return Result.error("删除失败");
}
return Result.success();
}
}
@@ -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<PageResult<LifePathResponse>> getPage(@Validated LifePathPageRequest request) {
PageResult<LifePathResponse> pageResult = lifePathService.getPageByCurrentUser(request);
return Result.success(pageResult);
}
/**
* 获取当前用户的所有实现路径列表
*/
@GetMapping(value = "/listAll")
public Result<List<LifePathResponse>> getList() {
List<LifePathResponse> paths = lifePathService.getListByCurrentUser();
return Result.success(paths);
}
/**
* 根据剧本ID获取实现路径
*/
@GetMapping(value = "/byScript")
public Result<LifePathResponse> 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<LifePathResponse> getById(@RequestParam String id) {
LifePathResponse path = lifePathService.getPathById(id);
if (path == null) {
return Result.notFound("实现路径不存在");
}
return Result.success(path);
}
/**
* 创建实现路径
*/
@PostMapping(value = "/create")
public Result<LifePathResponse> 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<LifePathResponse> 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<Void> delete(@RequestParam String id) {
boolean deleted = lifePathService.deletePath(id);
if (!deleted) {
return Result.error("删除失败");
}
return Result.success();
}
}
@@ -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<String, Object> plotJson;
/**
* 是否当前选中
*/
private Boolean isSelected;
}
@@ -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;
}
@@ -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<String, Object> plotJson;
/**
* 是否当前选中
*/
private Boolean isSelected;
}
@@ -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<String> tags;
}
@@ -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;
}
@@ -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<String> tags;
}
@@ -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<Map<String, Object>> steps;
/**
* 状态: active-进行中, completed-已完成, archived-已归档
*/
private String status;
/**
* 完成进度百分比
*/
private Double progress;
}
@@ -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;
}
@@ -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<Map<String, Object>> steps;
/**
* 状态: active-进行中, completed-已完成, archived-已归档
*/
private String status;
/**
* 完成进度百分比
*/
private Double progress;
}
@@ -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<String, Object> plotJson;
/**
* 是否当前选中
*/
private Boolean isSelected;
}
@@ -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<String> tags;
}
@@ -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<Map<String, Object>> steps;
/**
* 状态
*/
private String status;
/**
* 完成进度百分比
*/
private Double progress;
}
@@ -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<String, Object> plotJson;
/**
* 是否当前选中: 0-否, 1-是
*/
@TableField("is_selected")
private Integer isSelected;
}
@@ -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<String> tags;
}
@@ -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<Map<String, Object>> steps;
/**
* 状态: active-进行中, completed-已完成, archived-已归档
*/
@TableField("status")
private String status;
/**
* 完成进度百分比
*/
@TableField("progress")
private BigDecimal progress;
}
@@ -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<EpicScript> {
}
@@ -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<LifeEvent> {
}
@@ -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<LifePath> {
}
@@ -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<EpicScript> {
/**
* 分页查询当前用户的剧本
*
* @param request 分页请求
* @return 分页结果
*/
PageResult<EpicScriptResponse> getPageByCurrentUser(EpicScriptPageRequest request);
/**
* 获取当前用户的所有剧本列表
*
* @return 剧本列表
*/
List<EpicScriptResponse> 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);
}
@@ -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<LifeEvent> {
/**
* 分页查询当前用户的生命事件
*
* @param request 分页请求
* @return 分页结果
*/
PageResult<LifeEventResponse> getPageByCurrentUser(LifeEventPageRequest request);
/**
* 获取当前用户的所有生命事件列表
*
* @return 事件列表
*/
List<LifeEventResponse> 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);
}
@@ -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<LifePath> {
/**
* 分页查询当前用户的路径
*
* @param request 分页请求
* @return 分页结果
*/
PageResult<LifePathResponse> getPageByCurrentUser(LifePathPageRequest request);
/**
* 获取当前用户的所有路径列表
*
* @return 路径列表
*/
List<LifePathResponse> 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);
}
@@ -121,6 +121,14 @@ public class AiConfigServiceImpl extends ServiceImpl<AiConfigMapper, AiConfig> 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<AiConfigMapper, AiConfig> 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;
}
@@ -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<EpicScriptMapper, EpicScript>
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<EpicScriptResponse> getPageByCurrentUser(EpicScriptPageRequest request) {
String currentUserId = UserContextHolder.getCurrentUserId();
if (currentUserId == null) {
return new PageResult<>();
}
Page<EpicScript> page = new Page<>(request.getCurrent(), request.getSize());
LambdaQueryWrapper<EpicScript> 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<EpicScript> resultPage = this.page(page, wrapper);
List<EpicScriptResponse> responses = resultPage.getRecords().stream()
.map(this::convertToResponse)
.collect(Collectors.toList());
PageResult<EpicScriptResponse> 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<EpicScriptResponse> getListByCurrentUser() {
String currentUserId = UserContextHolder.getCurrentUserId();
if (currentUserId == null) {
return List.of();
}
LambdaQueryWrapper<EpicScript> 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<EpicScript> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(EpicScript::getUserId, currentUserId)
.eq(EpicScript::getIsSelected, 1)
.eq(EpicScript::getIsDeleted, 0);
List<EpicScript> 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;
}
}
@@ -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<LifeEventMapper, LifeEvent>
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<LifeEventResponse> getPageByCurrentUser(LifeEventPageRequest request) {
String currentUserId = UserContextHolder.getCurrentUserId();
if (currentUserId == null) {
return new PageResult<>();
}
Page<LifeEvent> page = new Page<>(request.getCurrent(), request.getSize());
LambdaQueryWrapper<LifeEvent> 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<LifeEvent> resultPage = this.page(page, wrapper);
List<LifeEventResponse> responses = resultPage.getRecords().stream()
.map(this::convertToResponse)
.collect(Collectors.toList());
PageResult<LifeEventResponse> 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<LifeEventResponse> getListByCurrentUser() {
String currentUserId = UserContextHolder.getCurrentUserId();
if (currentUserId == null) {
return List.of();
}
LambdaQueryWrapper<LifeEvent> 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;
}
}
@@ -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<LifePathMapper, LifePath>
implements LifePathService {
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Override
public PageResult<LifePathResponse> getPageByCurrentUser(LifePathPageRequest request) {
String currentUserId = UserContextHolder.getCurrentUserId();
if (currentUserId == null) {
return new PageResult<>();
}
Page<LifePath> page = new Page<>(request.getCurrent(), request.getSize());
LambdaQueryWrapper<LifePath> 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<LifePath> resultPage = this.page(page, wrapper);
List<LifePathResponse> responses = resultPage.getRecords().stream()
.map(this::convertToResponse)
.collect(Collectors.toList());
PageResult<LifePathResponse> 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<LifePathResponse> getListByCurrentUser() {
String currentUserId = UserContextHolder.getCurrentUserId();
if (currentUserId == null) {
return List.of();
}
LambdaQueryWrapper<LifePath> 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<LifePath> 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<LifePath> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(LifePath::getUserId, currentUserId)
.eq(LifePath::getScriptId, scriptId)
.eq(LifePath::getIsDeleted, 0);
List<LifePath> 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;
}
}
+1216 -12
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -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"
+3 -1
View File
@@ -56,7 +56,9 @@ function CurrentPage() {
return (
<LoginPage
onLoginSuccess={() => {
Store.completeOnboarding();
// 登录成功后,检查用户是否已完成引导
// 如果已完成,直接进入仪表盘;否则进入引导流程
setView('onboarding');
}}
onBack={() => setView('landing')}
onSignUp={() => setView('onboarding')}
+86
View File
@@ -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 }
});
}
};
+233
View File
@@ -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 }
});
}
};
+36 -7
View File
@@ -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 }) {
<option value="female"></option>
</select>
</div>
{/* 错误提示 */}
{error && (
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-200 text-sm">
{error}
</div>
)}
</div>
{/* 操作按钮 */}
@@ -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,
@@ -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';
+225 -118
View File
@@ -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;
try {
const response = await authApi.login({ phone, smsCode: code });
// 保存token到localStorage
if (response.data?.token) {
localStorage.setItem('token', response.data.token);
}
if (response.data?.refreshToken) {
localStorage.setItem('refreshToken', response.data.refreshToken);
}
// Simple mock validation
if (formData.password.length < 6) {
setError('密码长度不能少于6位');
setIsLoading(false);
return;
// 从后端加载用户档案数据
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);
}
// Success
Store.updateProfile({
nickname: formData.username,
// In a real app, we'd store a token
});
setIsLoading(false);
// 更新本地用户信息
Store.updateProfile({ phone });
onLoginSuccess();
}, 1000);
} catch (e) {
const errorMsg = e.response?.data?.message || '登录失败,请稍后重试';
setError(errorMsg);
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center p-4 relative z-10 animate-fade-in">
<div className="min-h-screen flex items-center justify-center p-6 animate-fade-in">
<GlassCard className="max-w-md w-full p-10 space-y-8 border-white/5 shadow-2xl rounded-[32px]">
{/* 标题区域 */}
<div className="text-center space-y-2">
<h2 className="text-3xl font-serif tracking-wider text-white/90">欢迎回来</h2>
<p className="text-sm text-white/40 italic">开启你的数字生命档案</p>
</div>
<div className="absolute top-8 left-8">
<button
onClick={onBack}
className="flex items-center gap-2 text-white/60 hover:text-white transition-colors group"
>
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
<span>返回首页</span>
</button>
</div>
<GlassCard className="w-full max-w-md p-8 md:p-10 relative overflow-hidden">
{/* Decorative elements */}
<div className="absolute -top-10 -right-10 w-40 h-40 bg-primary/20 rounded-full blur-3xl pointer-events-none"></div>
<div className="absolute -bottom-10 -left-10 w-40 h-40 bg-aurora-green/20 rounded-full blur-3xl pointer-events-none"></div>
<div className="relative z-10">
<div className="text-center mb-10">
<h2 className="text-3xl font-bold text-white mb-2 tracking-tight">欢迎回来</h2>
<p className="text-gray-400">登录您的 Emotion Museum 账号</p>
<form onSubmit={handleSubmit} className="space-y-6">
{/* 手机号输入 */}
<div className="space-y-2">
<label className="text-[10px] text-white/30 uppercase tracking-[0.2em] font-bold">
手机号码
</label>
<div className="relative group">
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30 group-focus-within:text-orange-200 transition-colors" />
<Input
type="tel"
placeholder="输入手机号"
maxLength={11}
value={phone}
onChange={(e) => 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)]"
/>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-300 ml-1">用户名 / 邮箱</label>
{/* 验证码输入 */}
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2 space-y-2">
<label className="text-[10px] text-white/30 uppercase tracking-[0.2em] font-bold">
验证码
</label>
<div className="relative group">
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-primary transition-colors" />
<KeyRound className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30 group-focus-within:text-orange-200 transition-colors" />
<Input
type="text"
placeholder="请输入您的账号"
className="pl-12 w-full"
value={formData.username}
onChange={(e) => setFormData({...formData, username: e.target.value})}
placeholder="六位验证码"
maxLength={6}
value={code}
onChange={(e) => 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)]"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-300 ml-1">密码</label>
<div className="relative group">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-primary transition-colors" />
<Input
type={showPassword ? "text" : "password"}
placeholder="请输入您的密码"
className="pl-12 pr-12 w-full"
value={formData.password}
onChange={(e) => setFormData({...formData, password: e.target.value})}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors focus:outline-none"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div className="flex items-center justify-between pt-2">
<Checkbox
label="记住我"
checked={formData.rememberMe}
onChange={(checked) => setFormData({...formData, rememberMe: checked})}
/>
<button type="button" className="text-sm text-primary hover:text-aurora-green transition-colors">
忘记密码
<div className="flex items-end">
<button
type="button"
onClick={handleSendCode}
disabled={countdown > 0 || !phone}
className="w-full h-[46px] rounded-2xl border border-white/5 bg-white/5 text-[10px] uppercase tracking-tighter hover:bg-white/10 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{countdown > 0 ? `${countdown}S` : '获取'}
</button>
</div>
{error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-200 text-sm flex items-center gap-2 animate-shake">
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
{error}
</div>
)}
<Button
type="submit"
className="w-full"
size="lg"
isLoading={isLoading}
>
{!isLoading && (
<>
立即登录 <ArrowRight className="w-4 h-4 ml-2" />
</>
)}
</Button>
</form>
<div className="mt-8 text-center text-sm text-gray-400">
还没有账号
<button
onClick={onSignUp}
className="text-primary hover:text-aurora-green font-bold ml-1 hover:underline transition-all"
>
立即注册
</button>
</div>
</div>
{/* 错误提示 */}
{error && (
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-200 text-sm flex items-center gap-2 animate-shake">
<span className="w-1.5 h-1.5 rounded-full bg-red-500 flex-shrink-0"></span>
{error}
</div>
)}
{/* 登录按钮 */}
<Button
type="submit"
isLoading={isLoading}
disabled={!phone || !code}
className="w-full py-4 rounded-2xl bg-orange-200/5 text-orange-200 font-bold tracking-[0.3em] hover:bg-orange-200/10 border-orange-200/20 shadow-lg shadow-orange-900/10"
variant="secondary"
>
{!isLoading && (
<>
开启旅程 <ArrowRight className="w-4 h-4 ml-2" />
</>
)}
</Button>
</form>
{/* 协议提示 */}
<p className="text-[10px] text-center text-white/20 px-4 leading-relaxed">
登录即代表同意用户协议隐私政策我们将妥善保管您的生命数据
</p>
</GlassCard>
</div>
);
+64 -10
View File
@@ -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 (
<div className="flex flex-wrap gap-2 pt-2">
{words.map(word => (
<span
key={word}
onClick={() => 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}
</span>
))}
</div>
);
}
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 }) {
<label className="block text-xs font-bold text-amber-300/80 mb-2">记忆中的画面</label>
<Textarea rows={5} placeholder="比如:外婆的蒲扇,炎热的下午,或者第一次离家..." value={formData.history?.childhood?.content || ''} onChange={e => updateHistory('childhood', 'content', e.target.value)} />
<InspirationTags
type="childhood"
onInsert={(word) => updateHistory('childhood', 'content', (formData.history?.childhood?.content || '') + word)}
/>
</div>
</div>
)}
@@ -271,6 +315,11 @@ export function OnboardingPage({ onFinish }) {
<label className="block text-xs font-bold text-primary/80 mb-2">那发生了什么</label>
<Textarea rows={5} placeholder="比如:收到心仪的offer,一次完美的旅行,或者被理解的瞬间..." value={formData.history?.peak?.content || ''} onChange={e => updateHistory('peak', 'content', e.target.value)} />
<InspirationTags
type="peak"
onInsert={(word) => updateHistory('peak', 'content', (formData.history?.peak?.content || '') + word)}
/>
</div>
</div>
)}
@@ -289,6 +338,11 @@ export function OnboardingPage({ onFinish }) {
<label className="block text-xs font-bold text-blue-400/80 mb-2">当时的感受与成长</label>
<Textarea rows={5} placeholder="那个挑战是什么?现在回看,它带给了你什么?" value={formData.history?.valley?.content || ''} onChange={e => updateHistory('valley', 'content', e.target.value)} />
<InspirationTags
type="valley"
onInsert={(word) => updateHistory('valley', 'content', (formData.history?.valley?.content || '') + word)}
/>
</div>
</div>
)}
+40
View File
@@ -0,0 +1,40 @@
/**
* Vitest 测试环境设置
* 配置 jsdom 环境和 localStorage mock
*/
import '@testing-library/jest-dom';
// Mock localStorage
const localStorageMock = {
store: {},
getItem: function(key) {
return this.store[key] || null;
},
setItem: function(key, value) {
this.store[key] = value.toString();
},
removeItem: function(key) {
delete this.store[key];
},
clear: function() {
this.store = {};
}
};
Object.defineProperty(window, 'localStorage', {
value: localStorageMock
});
// Mock window.location.reload
Object.defineProperty(window, 'location', {
value: {
reload: vi.fn(),
href: ''
},
writable: true
});
// Reset localStorage before each test
beforeEach(() => {
localStorageMock.clear();
});
+224 -156
View File
@@ -1,162 +1,230 @@
/**
* AI Logic Module
* Simulates intelligent healing responses and content generation.
* AI 服务模块
* 提供生命事件分析、剧本生成、路径规划等 AI 功能
* 保持与原 PncyssD/api.js 相同的 API 调用逻辑
*/
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const API_KEY = "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";
const BASE_URL = "https://openrouter.ai/api/v1/chat/completions";
const ADJECTIVES = {
INTJ: ['深刻', '逻辑严密', '富有远见'],
INFP: ['温柔', '富有同理心', '充满想象力'],
ENFP: ['热情', '鼓舞人心', '充满可能性'],
ISTJ: ['稳重', '务实', '值得信赖'],
INFJ: ['深邃', '直觉敏锐', '利他'],
DEFAULT: ['特别', '真诚', '有力量']
};
export const AI = {
/**
* Generate a healing reply for a timeline log.
* Analyzes sentiment and provides Analysis, Growth, and Healing.
*/
async generateReply(content, profile) {
await sleep(1500 + Math.random() * 1000); // Simulate 1.5-2.5s thinking
const mbti = profile.mbti || 'DEFAULT';
const traits = ADJECTIVES[mbti] || ADJECTIVES.DEFAULT;
const trait = traits[Math.floor(Math.random() * traits.length)];
const zodiac = profile.zodiac || '星辰';
let mood = 'neutral';
if (/(开心|成功|棒|好|爱|幸福|顺利)/.test(content)) mood = 'positive';
if (/(难过|累|失败|痛|丧|焦虑|迷茫)/.test(content)) mood = 'negative';
let analysis = "";
let growth = "";
let healing = "";
if (mood === 'positive') {
analysis = `这不仅仅是一个事件,而是你内心能量充盈的体现。作为${mbti},你敏锐地捕捉到了生活中的光亮。`;
growth = `请记住这种${trait}的感觉,它构成了你人格中坚韧的底色。你的每一次喜悦,都在为未来的挑战积蓄心理资本。`;
healing = `愿这一刻的温暖,像${zodiac}的光芒一样,长久地照耀你的内心。`;
} else if (mood === 'negative') {
analysis = `感到低落并非示弱,而是灵魂在提醒你需要休息。${mbti}的你往往思考得很深,容易承担过多情绪。`;
growth = `每一次的阵痛都是成长的伏笔。你正在经历破茧成蝶前的静默,这本身就是一种力量。`;
healing = `允许自己暂停,就像月亮也有阴晴圆缺。给那个受伤的自己一个拥抱,风雨之后,必见彩虹。`;
} else {
analysis = `记录本身就是一种深刻的觉察。你在平淡的流年中,依然保持着${trait}的观察力。`;
growth = `生活的大部分时间是平静的,能在其中找到节奏,是你最宝贵的能力。`;
healing = `在这漫长的岁月里,你就是自己最忠实的见证者。`;
}
return {
analysis,
growth,
healing
};
},
/**
* Generate a "Cool Story" (爽文) script.
* Uses profile history (Valley/Peak) to create a narrative arc.
*/
async generateScript(profile, timeline, requirements) {
await sleep(2000 + Math.random() * 1000);
const id = Date.now().toString();
const hobby = (profile.hobbies && profile.hobbies.length > 0) ? profile.hobbies[0] : "隐藏的天赋";
const theme = requirements.theme || "自我超越";
const valleyEvent = profile.history?.valley?.content
? `那段"${profile.history.valley.content.substring(0, 15)}..."的经历`
: "曾经那段默默无闻的时光";
const peakEvent = profile.history?.peak?.content
? `就像"${profile.history.peak.content.substring(0, 15)}..."那次一样`
: "如同星辰觉醒";
const templates = {
career: {
intro: `故事始于${valleyEvent}${profile.nickname}虽然身处低谷,但作为${profile.mbti}${profile.gender === 'male' ? '他' : '她'}内心深处对"${theme}"的渴望从未熄灭。${profile.zodiac}骨子里的韧性,支撑着${profile.gender === 'male' ? '他' : '她'}熬过了最黑的夜。`,
turning: `转折发生在一个不起眼的午后。公司面临前所未有的技术难题,所有人束手无策。${profile.nickname}利用业余时间钻研的${hobby},意外发现了破局的关键。`,
explosion: `在项目汇报会上,${profile.nickname}条理清晰地展示了方案,那种自信${peakEvent}。原本轻视的人都闭上了嘴。不仅完美解决了危机,更直接为公司带来了巨大的收益,一战成名!`,
ending: `最终,${profile.nickname}站在了行业的顶峰,实现了"${theme}"的宏愿。回首往事,轻舟已过万重山,${profile.gender === 'male' ? '他' : '她'}终于成为了自己想成为的人。`
},
love: {
intro: `${valleyEvent}的日子里,${profile.nickname}习惯了独自一人。${profile.mbti}的特质让${profile.gender === 'male' ? '他' : '她'}虽然渴望爱,却更害怕受伤。`,
turning: `直到那次${hobby}社团的聚会,命运的齿轮开始转动。${profile.nickname}不经意间流露出的${ADJECTIVES[profile.mbti]?.[0] || '独特'}气质,深深吸引了那个命中注定的人。`,
explosion: `面对现实的阻碍和误解,${profile.nickname}没有退缩。${profile.zodiac}赋予的勇气觉醒,${profile.gender === 'male' ? '他' : '她'}坚定地跨越了山海,只为奔赴那份真挚的感情。那一刻,全世界都在为爱让路。`,
ending: `正如${profile.futureVision || '童话故事'}里的结局,两人在夕阳下相拥。${profile.nickname}发现,原来最好的爱,是让你成为更好的自己。`
},
fantasy: {
intro: `在这个看似平凡的世界,${profile.nickname}总感觉自己格格不入。${valleyEvent},其实是灵力觉醒前的阵痛。${profile.zodiac}星盘早已预示了不凡的命运。`,
turning: `${hobby}这一媒介触碰到古老的法阵,封印解除!${profile.nickname}发现自己竟然是百年难遇的${profile.mbti}系元素掌控者。`,
explosion: `暗黑势力降临城市,绝望蔓延。关键时刻,${profile.nickname}挺身而出,爆发出了${peakEvent}般耀眼的光芒,一击必杀,守护了心中的"${theme}"。`,
ending: `成为了传说中的守护神,${profile.nickname}站在云端俯瞰大地。${profile.futureVision || '和平'}的景象映入眼帘,传奇才刚刚开始。`
}
};
const t = templates[requirements.style] || templates.career;
return {
id,
createdAt: new Date().toISOString(),
title: requirements.theme.substring(0, 10) + (requirements.style === 'fantasy' ? '·觉醒篇' : requirements.style === 'love' ? '·情缘篇' : '·逆袭篇'),
theme: requirements.theme,
style: requirements.style,
plot: t
};
},
/**
* Generate implementation path based on script.
*/
async generatePath(script, profile) {
await sleep(1500);
const hobby = (profile.hobbies && profile.hobbies.length > 0) ? profile.hobbies[0] : "关键技能";
return {
id: Date.now().toString(),
scriptId: script.id,
createdAt: new Date().toISOString(),
steps: [
{
phase: "第一阶段:沉淀与积累",
time: "第1-2个月",
content: `针对剧本中提到的"${hobby}",开始系统性学习。不要急于求成,利用${profile.mbti}擅长的深度思考,打好基础。`,
action: `购买两本高评分书籍或订阅一个专业专栏,每天坚持阅读学习45分钟。`,
resources: `专业书籍、在线MOOC平台`,
habit: `建立"日落复盘"机制,记录当天的收获。`
},
{
phase: "第二阶段:破局尝试",
time: "第3-6个月",
content: `正如剧本中"${script.plot.turning.substring(0, 10)}..."所描述的,寻找一个小型的实践机会。将知识转化为行动。`,
action: `完成一个最小可行性项目(Side Project)并在社交媒体分享。`,
resources: `GitHub/Behance/小红书等展示平台`,
habit: `每周连接一位同频的伙伴。`
},
{
phase: "第三阶段:爆发冲刺",
time: "第6-12个月",
content: `制造你的高光时刻。${profile.futureVision ? '向着"' + profile.futureVision + '"靠近' : '向着行业头部进发'}。主动承担高难度任务。`,
action: `参加一次行业比赛,或在团队中主导一个关键项目。`,
resources: `导师资源、行业峰会`,
habit: `冥想与可视化练习,强化自信心。`
},
{
phase: "第四阶段:愿景显化",
time: "1年后",
content: `将能力转化为影响力,实现"${script.theme}"。保持谦逊,同时不吝啬展示自己的光芒。`,
action: `整理你的方法论,开设分享会或撰写系列文章。`,
resources: `个人品牌渠道`,
habit: `终身学习,保持空杯心态。`
}
]
};
/**
* AI 服务对象
* 封装所有 AI 相关的 API 调用
*/
export const AIService = {
/**
* 基础 AI 请求方法
* @param {string} prompt - 用户提示词
* @param {string} systemMsg - 系统消息
* @returns {Promise<string>} AI 响应内容
*/
async fetchAI(prompt, systemMsg) {
try {
const response = await fetch(BASE_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: "deepseek/deepseek-chat-v3-0324:free",
messages: [
{ role: "system", content: systemMsg },
{ role: "user", content: prompt }
]
})
});
const data = await response.json();
return data.choices[0].message.content;
} catch (e) {
console.error(e);
return "(AI 暂时陷入了沉思,请稍后再试)";
}
},
/**
* 分析生命事件
* 从用户记录的事件中发掘成长的力量,提供情感价值、成长总结和疗愈鼓励
* @param {Object} event - 生命事件对象
* @param {string} event.title - 事件标题
* @param {string} event.time - 事件时间
* @param {string} event.content - 事件内容
* @returns {Promise<string>} AI 分析反馈
*/
async analyzeLifeEvent(event) {
const system = "你是一位温柔的生命引路人,擅长从平凡事件中发掘成长的力量。请分析用户记录的事件,提供情感价值、成长总结和疗愈鼓励。保持字数在150字左右。";
const prompt = `事件标题:${event.title}\n时间:${event.time}\n内容:${event.content}`;
return this.fetchAI(prompt, system);
},
/**
* 生成爽文剧本
* 根据用户的角色设定和过往经历,生成充满爽感的未来人生剧本
* @param {Object} params - 剧本参数
* @param {Object} params.character - 角色信息
* @param {string} params.character.nickname - 昵称
* @param {string} params.character.mbti - MBTI 类型
* @param {Array<string>} params.character.hobbies - 兴趣爱好
* @param {string} params.character.zodiac - 星座
* @param {string} params.theme - 剧本主题
* @param {string} params.style - 叙事风格
* @param {string} params.length - 篇幅要求
* @param {Array<Object>} events - 生命事件列表
* @returns {Promise<string>} 生成的剧本内容
*/
async generateEpicScript(params, events) {
const system = `你是一位金牌爽文编剧。根据用户的角色设定和过往经历,生成一段符合用户设定、充满爽感的未来人生剧本。剧本必须包含起承转合,使用【标题】标记段落。`;
const charInfo = `姓名:${params.character.nickname}, 性格:${params.character.mbti}, 兴趣:${params.character.hobbies.join(',')}, 星座:${params.character.zodiac}`;
const eventSummary = events.map(e => e.title).join(', ');
const prompt = `角色信息:${charInfo}\n过往经历关键词:${eventSummary}\n用户指定主题:${params.theme}\n指定风格:${params.style}\n篇幅要求:${params.length}\n\n请以此创作一段热血、精彩的人生剧本。`;
return this.fetchAI(prompt, system);
},
/**
* 生成实现路径
* 将剧本拆解为现实中可操作的路径
* @param {string} script - 剧本内容
* @returns {Promise<string>} 生成的路径规划
*/
async generatePath(script) {
const system = "你是一位人生规划导师。请将用户生成的剧本拆解为现实中可操作的路径。使用【阶段名称】加上具体建议。务必客观、可执行。";
return this.fetchAI(script, system);
}
};
export default AIService;
/**
* AI 别名导出
* 兼容现有视图组件的导入方式
*/
export const AI = {
/**
* 生成 AI 回复(用于生命轨迹)
*/
async generateReply(content, userProfile) {
const system = "你是一位温柔的生命引路人,擅长从平凡事件中发掘成长的力量。请分析用户记录的事件,提供情感价值、成长总结和疗愈鼓励。保持字数在150字左右。";
const prompt = `用户信息:${userProfile.nickname || '旅人'}${userProfile.mbti || '未知'}类型\n\n用户分享:${content}`;
return AIService.fetchAI(prompt, system);
},
/**
* 生成剧本
*/
async generateScript(userProfile, lifeTimeline, requirements) {
const system = `你是一位金牌爽文编剧。根据用户的角色设定和过往经历,生成一段符合用户设定、充满爽感的未来人生剧本。
请按以下JSON格式返回(不要包含markdown代码块标记):
{
"title": "剧本标题",
"plot": {
"intro": "序幕内容",
"turning": "转折内容",
"climax": "高潮内容",
"ending": "结局内容"
}
}`;
const charInfo = `姓名:${userProfile.nickname}, 性格:${userProfile.mbti}, 兴趣:${(userProfile.hobbies || []).join(',')}, 星座:${userProfile.zodiac}`;
const eventSummary = (lifeTimeline || []).map(e => e.title).join(', ');
const styleMap = { career: '职场逆袭', love: '情感圆满', fantasy: '玄幻觉醒' };
const prompt = `角色信息:${charInfo}
过往经历关键词:${eventSummary || '暂无'}
用户指定主题:${requirements.theme}
指定风格:${styleMap[requirements.style] || requirements.style}
篇幅要求:${requirements.length === 'long' ? '长篇' : '标准篇'}
请以此创作一段热血、精彩的人生剧本。`;
const response = await AIService.fetchAI(prompt, system);
// 尝试解析 JSON 响应
try {
const parsed = JSON.parse(response.replace(/```json\n?|\n?```/g, '').trim());
return {
id: Date.now(),
title: parsed.title || requirements.theme,
style: requirements.style,
length: requirements.length,
plot: parsed.plot || { intro: response, turning: '', climax: '', ending: '' },
createdAt: new Date().toISOString()
};
} catch (e) {
// 如果解析失败,返回原始文本
return {
id: Date.now(),
title: requirements.theme,
style: requirements.style,
length: requirements.length,
plot: { intro: response, turning: '', climax: '', ending: '' },
createdAt: new Date().toISOString()
};
}
},
/**
* 生成实现路径
*/
async generatePath(script, userProfile) {
const system = `你是一位人生规划导师。请将用户生成的剧本拆解为现实中可操作的路径。
请按以下JSON格式返回(不要包含markdown代码块标记):
{
"steps": [
{
"phase": "阶段名称",
"time": "时间范围",
"content": "核心策略",
"action": "关键行动",
"resources": "所需资源",
"habit": "养成习惯"
}
]
}
请生成3-5个阶段。`;
const scriptContent = script.plot
? `序幕:${script.plot.intro}\n转折:${script.plot.turning}\n高潮:${script.plot.climax}\n结局:${script.plot.ending}`
: script.content || '';
const prompt = `剧本标题:${script.title}
剧本内容:${scriptContent}
请基于此剧本,为用户规划可执行的实现路径。`;
const response = await AIService.fetchAI(prompt, system);
// 尝试解析 JSON 响应
try {
const parsed = JSON.parse(response.replace(/```json\n?|\n?```/g, '').trim());
return {
id: Date.now(),
scriptId: script.id,
steps: parsed.steps || [],
createdAt: new Date().toISOString()
};
} catch (e) {
// 如果解析失败,尝试从文本中提取阶段
const steps = response.split(/【/).filter(s => s.trim()).map((s, i) => {
const parts = s.split(/】/);
return {
phase: parts[0] || `阶段 ${i + 1}`,
time: '待定',
content: parts[1] || s,
action: '',
resources: '',
habit: ''
};
});
return {
id: Date.now(),
scriptId: script.id,
steps: steps.length > 0 ? steps : [{ phase: '规划中', time: '待定', content: response, action: '', resources: '', habit: '' }],
createdAt: new Date().toISOString()
};
}
}
};
+61 -3
View File
@@ -21,9 +21,11 @@ const DEFAULT_STATE = {
},
futureVision: ""
},
lifeTimeline: [],
generatedScripts: [],
paths: []
lifeTimeline: [], // 生命事件列表
generatedScripts: [], // 生成的剧本列表
paths: [], // 实现路径列表
selectedScriptId: null, // 当前选中的剧本ID
selectedPath: null // 当前选中的路径内容
};
export const Store = {
@@ -109,5 +111,61 @@ export const Store = {
const data = this.get();
data.paths = data.paths.filter(p => p.id !== id);
this.save(data);
},
/**
* 选择剧本
* @param {number} id - 剧本ID
*/
selectScript(id) {
const data = this.get();
data.selectedScriptId = id;
this.save(data);
},
/**
* 获取当前选中的剧本
* @returns {Object|null} 选中的剧本对象
*/
getSelectedScript() {
const data = this.get();
if (!data.selectedScriptId) return null;
return data.generatedScripts.find(s => s.id === data.selectedScriptId) || null;
},
/**
* 设置当前路径
* @param {string} path - 路径内容
*/
setSelectedPath(path) {
const data = this.get();
data.selectedPath = path;
this.save(data);
},
/**
* 添加剧本并自动选中
* @param {Object} script - 剧本对象
*/
addScriptAndSelect(script) {
const data = this.get();
const newScript = {
...script,
id: Date.now(),
date: new Date().toLocaleDateString()
};
data.generatedScripts.unshift(newScript);
data.selectedScriptId = newScript.id;
this.save(data);
return newScript;
},
/**
* 清除所有数据并退出
*/
clear() {
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem('token');
window.location.reload();
}
};
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
/**
* Vitest 配置文件
* 用于运行单元测试和属性测试
*/
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.js'],
include: ['src/**/*.{test,spec}.{js,jsx}'],
},
});
+168
View File
@@ -1216,5 +1216,173 @@ CREATE INDEX idx_user_profile_user_id ON t_user_profile (user_id);
CREATE INDEX idx_user_profile_create_time ON t_user_profile (create_time);
-- ============================================================================
-- 20. 生命事件表 (t_life_event)
-- 存储用户的人生轨迹事件,包括日期、标题、内容、AI回复等
-- 关联说明: user_id 关联 t_user.id
-- ============================================================================
DROP TABLE IF EXISTS t_life_event;
CREATE TABLE t_life_event (
id VARCHAR(64) PRIMARY KEY COMMENT 'UUID主键',
user_id VARCHAR(64) NOT NULL COMMENT '用户ID (关联t_user.id)',
-- 事件信息
event_type VARCHAR(50) DEFAULT 'daily_log' COMMENT '事件类型: daily_log-日常记录, milestone-里程碑',
event_date DATETIME COMMENT '事件日期',
title VARCHAR(200) COMMENT '事件标题',
content TEXT COMMENT '事件内容',
ai_reply TEXT COMMENT 'AI疗愈回复',
-- 情绪分析
emotion_type VARCHAR(50) COMMENT '情绪类型',
emotion_score DECIMAL(3, 2) COMMENT '情绪评分',
-- 标签
tags JSON COMMENT '标签列表',
-- 公共字段
create_by VARCHAR(64) COMMENT '创建人ID',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by VARCHAR(64) COMMENT '更新人ID',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
is_deleted TINYINT DEFAULT 0 COMMENT '是否删除: 0-未删除, 1-已删除',
remarks VARCHAR(500) COMMENT '备注'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '生命事件表 (t_life_event)';
-- t_life_event表索引
CREATE INDEX idx_life_event_user_id ON t_life_event (user_id);
CREATE INDEX idx_life_event_event_date ON t_life_event (event_date);
CREATE INDEX idx_life_event_user_date ON t_life_event (user_id, event_date);
CREATE INDEX idx_life_event_event_type ON t_life_event (event_type);
CREATE INDEX idx_life_event_create_time ON t_life_event (create_time);
CREATE INDEX idx_life_event_is_deleted ON t_life_event (is_deleted);
-- ============================================================================
-- 21. 爽文剧本表 (t_epic_script)
-- 存储用户生成的爽文剧本,包括主题、风格、剧情章节等
-- 关联说明: user_id 关联 t_user.id
-- ============================================================================
DROP TABLE IF EXISTS t_epic_script;
CREATE TABLE t_epic_script (
id VARCHAR(64) PRIMARY KEY COMMENT 'UUID主键',
user_id VARCHAR(64) NOT NULL COMMENT '用户ID (关联t_user.id)',
-- 剧本基本信息
title VARCHAR(200) COMMENT '剧本标题',
theme TEXT COMMENT '剧本主题/渴望',
style VARCHAR(50) COMMENT '剧本风格: career-职场逆袭, love-情感圆满, fantasy-玄幻觉醒',
length VARCHAR(20) DEFAULT 'medium' COMMENT '篇幅长度: medium-标准篇, long-长篇',
-- 剧情内容 (四章结构)
plot_intro TEXT COMMENT '序幕:低谷回响',
plot_turning TEXT COMMENT '转折:契机出现',
plot_climax TEXT COMMENT '高潮:命运抉择',
plot_ending TEXT COMMENT '结局:新的开始',
-- 完整剧情JSON (备用)
plot_json JSON COMMENT '完整剧情JSON结构',
-- 状态
is_selected TINYINT DEFAULT 0 COMMENT '是否当前选中: 0-否, 1-是',
-- 公共字段
create_by VARCHAR(64) COMMENT '创建人ID',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by VARCHAR(64) COMMENT '更新人ID',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
is_deleted TINYINT DEFAULT 0 COMMENT '是否删除: 0-未删除, 1-已删除',
remarks VARCHAR(500) COMMENT '备注'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '爽文剧本表 (t_epic_script)';
-- t_epic_script表索引
CREATE INDEX idx_epic_script_user_id ON t_epic_script (user_id);
CREATE INDEX idx_epic_script_style ON t_epic_script (style);
CREATE INDEX idx_epic_script_user_selected ON t_epic_script (user_id, is_selected);
CREATE INDEX idx_epic_script_create_time ON t_epic_script (create_time);
CREATE INDEX idx_epic_script_is_deleted ON t_epic_script (is_deleted);
-- ============================================================================
-- 22. 实现路径表 (t_life_path)
-- 存储基于剧本生成的实现路径规划
-- 关联说明: user_id 关联 t_user.id, script_id 关联 t_epic_script.id
-- ============================================================================
DROP TABLE IF EXISTS t_life_path;
CREATE TABLE t_life_path (
id VARCHAR(64) PRIMARY KEY COMMENT 'UUID主键',
user_id VARCHAR(64) NOT NULL COMMENT '用户ID (关联t_user.id)',
script_id VARCHAR(64) NOT NULL COMMENT '关联剧本ID (关联t_epic_script.id)',
-- 路径基本信息
title VARCHAR(200) COMMENT '路径标题',
description TEXT COMMENT '路径描述',
-- 路径步骤 (JSON数组)
steps JSON COMMENT '路径步骤列表',
-- 状态
status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active-进行中, completed-已完成, archived-已归档',
progress DECIMAL(5, 2) DEFAULT 0.00 COMMENT '完成进度百分比',
-- 公共字段
create_by VARCHAR(64) COMMENT '创建人ID',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by VARCHAR(64) COMMENT '更新人ID',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
is_deleted TINYINT DEFAULT 0 COMMENT '是否删除: 0-未删除, 1-已删除',
remarks VARCHAR(500) COMMENT '备注'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '实现路径表 (t_life_path)';
-- t_life_path表索引
CREATE INDEX idx_life_path_user_id ON t_life_path (user_id);
CREATE INDEX idx_life_path_script_id ON t_life_path (script_id);
CREATE INDEX idx_life_path_user_script ON t_life_path (user_id, script_id);
CREATE INDEX idx_life_path_status ON t_life_path (status);
CREATE INDEX idx_life_path_create_time ON t_life_path (create_time);
CREATE INDEX idx_life_path_is_deleted ON t_life_path (is_deleted);
-- ============================================================================
-- 23. 路径步骤表 (t_life_path_step) - 可选,用于更细粒度的步骤管理
-- 存储路径的每个步骤详情
-- 关联说明: path_id 关联 t_life_path.id
-- ============================================================================
DROP TABLE IF EXISTS t_life_path_step;
CREATE TABLE t_life_path_step (
id VARCHAR(64) PRIMARY KEY COMMENT 'UUID主键',
path_id VARCHAR(64) NOT NULL COMMENT '路径ID (关联t_life_path.id)',
user_id VARCHAR(64) NOT NULL COMMENT '用户ID (关联t_user.id)',
-- 步骤信息
step_order INT DEFAULT 0 COMMENT '步骤顺序',
phase VARCHAR(100) COMMENT '阶段名称',
time_frame VARCHAR(50) COMMENT '时间范围',
content TEXT COMMENT '核心策略内容',
action TEXT COMMENT '关键行动',
resources TEXT COMMENT '所需资源',
habit TEXT COMMENT '养成习惯',
-- 状态
status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending-待开始, in_progress-进行中, completed-已完成',
completed_time DATETIME COMMENT '完成时间',
-- 公共字段
create_by VARCHAR(64) COMMENT '创建人ID',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by VARCHAR(64) COMMENT '更新人ID',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
is_deleted TINYINT DEFAULT 0 COMMENT '是否删除: 0-未删除, 1-已删除',
remarks VARCHAR(500) COMMENT '备注'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '路径步骤表 (t_life_path_step)';
-- t_life_path_step表索引
CREATE INDEX idx_life_path_step_path_id ON t_life_path_step (path_id);
CREATE INDEX idx_life_path_step_user_id ON t_life_path_step (user_id);
CREATE INDEX idx_life_path_step_order ON t_life_path_step (path_id, step_order);
CREATE INDEX idx_life_path_step_status ON t_life_path_step (status);
CREATE INDEX idx_life_path_step_is_deleted ON t_life_path_step (is_deleted);
-- 提交事务
COMMIT;
@@ -546,7 +546,7 @@
<el-form label-width="100px">
<el-form-item label="请求URL">
<el-input v-model="testRequest.url" readonly />
<el-input v-model="testRequest.url" placeholder="请输入请求URL" />
</el-form-item>
<el-form-item label="测试选项">