前端重构实现
This commit is contained in:
@@ -0,0 +1,611 @@
|
||||
# Design Document: Life Script Frontend
|
||||
|
||||
## Overview
|
||||
|
||||
本设计文档描述了基于 React + Tailwind CSS + Headless UI/Radix UI 技术栈,完整还原 PncyssD 原型设计的前端应用架构。应用采用组件化架构,使用 Zustand 进行状态管理,Framer Motion 实现动画效果,并通过 React Router 管理路由。
|
||||
|
||||
## Architecture
|
||||
|
||||
### 技术栈选型
|
||||
|
||||
| 类别 | 技术 | 说明 |
|
||||
|------|------|------|
|
||||
| 框架 | React 18 + Vite | 现代化构建工具,快速开发体验 |
|
||||
| 样式 | Tailwind CSS 3.x | 原子化CSS,完美还原毛玻璃设计 |
|
||||
| UI组件 | Radix UI | 无样式可访问组件库 |
|
||||
| 状态管理 | Zustand | 轻量级状态管理,支持持久化 |
|
||||
| 路由 | React Router v6 | 声明式路由管理 |
|
||||
| 动画 | Framer Motion | 声明式动画库,替代GSAP |
|
||||
| 图标 | Lucide React | 与原型一致的图标库 |
|
||||
| HTTP | Axios | API请求封装 |
|
||||
|
||||
### 应用架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Application Layer"
|
||||
App[App.jsx]
|
||||
Router[React Router]
|
||||
end
|
||||
|
||||
subgraph "Pages"
|
||||
Login[LoginPage]
|
||||
Onboarding[OnboardingPage]
|
||||
Dashboard[DashboardPage]
|
||||
end
|
||||
|
||||
subgraph "Dashboard Views"
|
||||
Timeline[TimelineView]
|
||||
Script[ScriptView]
|
||||
Path[PathView]
|
||||
end
|
||||
|
||||
subgraph "Shared Components"
|
||||
GlassCard[GlassCard]
|
||||
GlassButton[GlassButton]
|
||||
GlassInput[GlassInput]
|
||||
Modal[Modal]
|
||||
Header[Header]
|
||||
Sidebar[Sidebar]
|
||||
end
|
||||
|
||||
subgraph "State Management"
|
||||
Store[Zustand Store]
|
||||
Persist[localStorage Persist]
|
||||
end
|
||||
|
||||
subgraph "Services"
|
||||
AIService[AI Service]
|
||||
AuthService[Auth Service]
|
||||
end
|
||||
|
||||
App --> Router
|
||||
Router --> Login
|
||||
Router --> Onboarding
|
||||
Router --> Dashboard
|
||||
|
||||
Dashboard --> Timeline
|
||||
Dashboard --> Script
|
||||
Dashboard --> Path
|
||||
|
||||
Login --> GlassCard
|
||||
Login --> GlassInput
|
||||
Onboarding --> GlassCard
|
||||
Dashboard --> Sidebar
|
||||
Dashboard --> Header
|
||||
|
||||
Timeline --> Modal
|
||||
Script --> GlassCard
|
||||
Path --> GlassCard
|
||||
|
||||
Store --> Persist
|
||||
Timeline --> AIService
|
||||
Script --> AIService
|
||||
Path --> AIService
|
||||
```
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
life-script/
|
||||
├── public/
|
||||
│ └── assets/
|
||||
│ └── images/ # 背景图片、logo等
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # 基础UI组件
|
||||
│ │ │ ├── GlassCard.jsx
|
||||
│ │ │ ├── GlassButton.jsx
|
||||
│ │ │ ├── GlassInput.jsx
|
||||
│ │ │ ├── GlassTextarea.jsx
|
||||
│ │ │ ├── GlassSelect.jsx
|
||||
│ │ │ └── index.js
|
||||
│ │ ├── layout/ # 布局组件
|
||||
│ │ │ ├── Header.jsx
|
||||
│ │ │ ├── Sidebar.jsx
|
||||
│ │ │ ├── Background.jsx
|
||||
│ │ │ └── index.js
|
||||
│ │ ├── Modal.jsx # 模态弹窗
|
||||
│ │ ├── Loader.jsx # 加载动画
|
||||
│ │ └── PromptTag.jsx # 灵感标签
|
||||
│ ├── pages/
|
||||
│ │ ├── LoginPage.jsx
|
||||
│ │ ├── OnboardingPage.jsx
|
||||
│ │ └── DashboardPage.jsx
|
||||
│ ├── views/ # Dashboard子视图
|
||||
│ │ ├── TimelineView.jsx
|
||||
│ │ ├── ScriptView.jsx
|
||||
│ │ ├── PathView.jsx
|
||||
│ │ └── ProfileModal.jsx
|
||||
│ ├── store/
|
||||
│ │ └── useStore.js # Zustand store
|
||||
│ ├── services/
|
||||
│ │ ├── ai.js # AI服务
|
||||
│ │ └── api.js # API封装
|
||||
│ ├── hooks/
|
||||
│ │ ├── useTransition.js # 页面过渡hook
|
||||
│ │ └── useCountdown.js # 倒计时hook
|
||||
│ ├── styles/
|
||||
│ │ └── index.css # 全局样式
|
||||
│ ├── utils/
|
||||
│ │ └── constants.js # 常量定义
|
||||
│ ├── App.jsx
|
||||
│ └── main.jsx
|
||||
├── index.html
|
||||
├── tailwind.config.js
|
||||
├── vite.config.js
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### 1. 基础UI组件
|
||||
|
||||
#### GlassCard
|
||||
|
||||
```typescript
|
||||
interface GlassCardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
variant?: 'default' | 'highlight' | 'ai';
|
||||
padding?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
```
|
||||
|
||||
样式规范:
|
||||
- 背景: `rgba(15, 17, 26, 0.4)`
|
||||
- 模糊: `backdrop-filter: blur(25px) saturate(180%)`
|
||||
- 边框: `1px solid rgba(255, 255, 255, 0.08)`
|
||||
- 圆角: `32px` (移动端 `20px`)
|
||||
- 阴影: `0 20px 50px -12px rgba(0, 0, 0, 0.5)`
|
||||
|
||||
#### GlassButton
|
||||
|
||||
```typescript
|
||||
interface GlassButtonProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
variant?: 'default' | 'primary' | 'icon';
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
```
|
||||
|
||||
样式规范:
|
||||
- 背景: `rgba(255, 255, 255, 0.03)`
|
||||
- Hover: `rgba(255, 255, 255, 0.08)`
|
||||
- Primary变体: `bg-orange-200/5 text-orange-200 border-orange-200/20`
|
||||
- 过渡: `all 0.5s cubic-bezier(0.23, 1, 0.32, 1)`
|
||||
|
||||
#### GlassInput
|
||||
|
||||
```typescript
|
||||
interface GlassInputProps {
|
||||
label?: string;
|
||||
type?: 'text' | 'tel' | 'date';
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
maxLength?: number;
|
||||
className?: string;
|
||||
}
|
||||
```
|
||||
|
||||
样式规范:
|
||||
- 背景: `rgba(0, 0, 0, 0.2)`
|
||||
- 边框: `1px solid rgba(255, 255, 255, 0.05)`
|
||||
- Focus: `border-color: #FFAB91; box-shadow: 0 0 20px rgba(255, 171, 145, 0.1)`
|
||||
- 圆角: `16px`
|
||||
- 内边距: `14px 20px`
|
||||
|
||||
#### GlassTextarea
|
||||
|
||||
```typescript
|
||||
interface GlassTextareaProps {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
rows?: number;
|
||||
className?: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### GlassSelect
|
||||
|
||||
```typescript
|
||||
interface GlassSelectProps {
|
||||
label?: string;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 布局组件
|
||||
|
||||
#### Background
|
||||
|
||||
动态流体背景组件,包含:
|
||||
- 渐变底层: `from-[#1a1c2c] via-[#0a0c10] to-[#2d1b10]`
|
||||
- 浮动模糊圆: 蓝色 (`bg-blue-900/20`) 和橙色 (`bg-orange-900/10`)
|
||||
- 纹理叠加层: `mix-blend-overlay opacity-30`
|
||||
|
||||
#### Header
|
||||
|
||||
```typescript
|
||||
interface HeaderProps {
|
||||
showNav?: boolean;
|
||||
onProfileClick?: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
固定定位,包含logo和用户按钮。
|
||||
|
||||
#### Sidebar
|
||||
|
||||
```typescript
|
||||
interface SidebarProps {
|
||||
activeView: 'timeline' | 'script' | 'path';
|
||||
onViewChange: (view: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
导航分组:
|
||||
- 回溯过去: 生命长河
|
||||
- 创造未来: 爽文剧本, 实现路径
|
||||
|
||||
### 3. 模态弹窗
|
||||
|
||||
#### Modal
|
||||
|
||||
```typescript
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
maxWidth?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
```
|
||||
|
||||
使用 Radix UI Dialog 实现,样式:
|
||||
- 遮罩: `bg-black/60 backdrop-blur-xl`
|
||||
- 内容: GlassCard样式
|
||||
- 关闭按钮: 右上角X图标
|
||||
|
||||
### 4. 页面组件
|
||||
|
||||
#### LoginPage
|
||||
|
||||
状态:
|
||||
- phone: string
|
||||
- code: string
|
||||
- countdown: number
|
||||
- isLoading: boolean
|
||||
|
||||
流程:
|
||||
1. 输入手机号 → 点击获取验证码 → 60秒倒计时
|
||||
2. 输入验证码 → 点击登录 → 验证成功跳转Onboarding
|
||||
|
||||
#### OnboardingPage
|
||||
|
||||
状态:
|
||||
- currentStep: 1-5
|
||||
- formData: RegistrationData
|
||||
|
||||
步骤内容:
|
||||
1. 基础信息 (nickname, gender, mbti, zodiac, hobbies)
|
||||
2. 童年记忆 (date, text) + 灵感标签
|
||||
3. 开心经历 (date, text) + 灵感标签
|
||||
4. 低谷时刻 (date, text) + 灵感标签
|
||||
5. 未来愿景 (vision, ideal)
|
||||
|
||||
#### DashboardPage
|
||||
|
||||
状态:
|
||||
- activeView: 'timeline' | 'script' | 'path'
|
||||
- isProfileOpen: boolean
|
||||
|
||||
布局:
|
||||
- 左侧: Sidebar (3/12 列)
|
||||
- 右侧: 内容区 (9/12 列)
|
||||
|
||||
### 5. 视图组件
|
||||
|
||||
#### TimelineView
|
||||
|
||||
```typescript
|
||||
interface LifeEvent {
|
||||
id: number;
|
||||
title: string;
|
||||
time: string;
|
||||
content: string;
|
||||
aiFeedback: string;
|
||||
}
|
||||
```
|
||||
|
||||
功能:
|
||||
- 显示事件列表(时间线样式)
|
||||
- 添加新事件模态框
|
||||
- AI分析反馈
|
||||
|
||||
#### ScriptView
|
||||
|
||||
```typescript
|
||||
interface Script {
|
||||
id: number;
|
||||
theme: string;
|
||||
style: string;
|
||||
length: string;
|
||||
content: string;
|
||||
date: string;
|
||||
}
|
||||
```
|
||||
|
||||
布局:
|
||||
- 左侧面板: 角色设定卡片 + 创作需求表单 + 历史卷轴列表
|
||||
- 右侧面板: 剧本内容展示
|
||||
|
||||
#### PathView
|
||||
|
||||
功能:
|
||||
- 检查是否有选中的剧本
|
||||
- 生成路径步骤
|
||||
- 展示路径卡片列表
|
||||
|
||||
## Data Models
|
||||
|
||||
### State Schema
|
||||
|
||||
```typescript
|
||||
interface AppState {
|
||||
// 认证状态
|
||||
isLoggedIn: boolean;
|
||||
phone: string;
|
||||
|
||||
// 视图状态
|
||||
view: 'login' | 'onboarding' | 'dashboard';
|
||||
currentStep: number;
|
||||
|
||||
// 用户注册数据
|
||||
registrationData: {
|
||||
nickname: string;
|
||||
gender: string;
|
||||
zodiac: string;
|
||||
mbti: string;
|
||||
profession: string;
|
||||
hobbies: string[];
|
||||
childhood: { date: string; text: string };
|
||||
joy: { date: string; text: string };
|
||||
low: { date: string; text: string };
|
||||
future: { vision: string; ideal: string };
|
||||
};
|
||||
|
||||
// 生命事件
|
||||
lifeEvents: LifeEvent[];
|
||||
|
||||
// 剧本
|
||||
scripts: Script[];
|
||||
selectedScriptId: number | null;
|
||||
|
||||
// 路径
|
||||
selectedPath: string | null;
|
||||
|
||||
// Actions
|
||||
save: () => void;
|
||||
load: () => void;
|
||||
updateRegistration: (data: Partial<RegistrationData>) => void;
|
||||
addLifeEvent: (event: Omit<LifeEvent, 'id'>) => void;
|
||||
addScript: (script: Omit<Script, 'id' | 'date'>) => void;
|
||||
setPath: (path: string) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### 灵感标签数据
|
||||
|
||||
```typescript
|
||||
const inspirationClusters = {
|
||||
childhood: ['秋千', '晚霞', '糖果', '奔跑', '蝉鸣', '雨后泥土', '旧书包', '风筝'],
|
||||
joy: ['海浪', '拥抱', '掌声', '晨曦', '破土而出', '默契', '星空', '释放'],
|
||||
low: ['落叶', '雨伞', '长廊', '深呼吸', '自愈', '沉潜', '坚韧', '等待', '破茧']
|
||||
};
|
||||
```
|
||||
|
||||
### 下拉选项数据
|
||||
|
||||
```typescript
|
||||
const scriptStyles = [
|
||||
{ value: '都市', label: '都市沉浮' },
|
||||
{ value: '古风', label: '快意恩仇' },
|
||||
{ value: '爱情', label: '唯美浪漫' },
|
||||
{ value: '科幻', label: '星际远征' },
|
||||
{ value: '喜剧', label: '荒诞不经' },
|
||||
{ value: '悬疑', label: '迷雾重重' },
|
||||
{ value: '恐怖', label: '午夜回响' }
|
||||
];
|
||||
|
||||
const scriptLengths = [
|
||||
{ value: '短', label: '极简' },
|
||||
{ value: '中', label: '连载' },
|
||||
{ value: '长', label: '史诗' }
|
||||
];
|
||||
```
|
||||
|
||||
|
||||
## 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.*
|
||||
|
||||
|
||||
Based on the prework analysis, the following correctness properties have been identified:
|
||||
|
||||
### Property 1: State Persistence Round-Trip
|
||||
|
||||
*For any* valid application state object, serializing to localStorage and then deserializing on page reload SHALL produce an equivalent state object with all user data intact.
|
||||
|
||||
**Validates: Requirements 9.1, 9.2, 9.4**
|
||||
|
||||
### Property 2: Login Validation and Navigation
|
||||
|
||||
*For any* phone number input, the system SHALL:
|
||||
- Accept only 11-digit numbers as valid
|
||||
- Start countdown only for valid phone numbers
|
||||
- Navigate to onboarding only when credentials match (phone + code "888888")
|
||||
- Display error messages for all invalid inputs
|
||||
|
||||
**Validates: Requirements 2.3, 2.4, 2.5, 2.6**
|
||||
|
||||
### Property 3: Onboarding Step Progression
|
||||
|
||||
*For any* step number N (1-5), the onboarding flow SHALL:
|
||||
- Display the correct content for step N
|
||||
- Show "返回" button if and only if N > 1
|
||||
- Preserve all form data when navigating between steps
|
||||
- Update progress indicator to highlight step N
|
||||
|
||||
**Validates: Requirements 3.1, 3.8, 3.10, 3.11**
|
||||
|
||||
### Property 4: Inspiration Tag Appending
|
||||
|
||||
*For any* inspiration tag click in the onboarding flow, the corresponding textarea value SHALL be appended with the tag text, preserving any existing content.
|
||||
|
||||
**Validates: Requirements 3.7**
|
||||
|
||||
### Property 5: Timeline Event Ordering
|
||||
|
||||
*For any* collection of life events with different timestamps, the Timeline view SHALL display them in reverse chronological order (newest first), and each event card SHALL contain all required fields (title, date, content, aiFeedback).
|
||||
|
||||
**Validates: Requirements 5.3, 5.4**
|
||||
|
||||
### Property 6: Script Generation and Selection
|
||||
|
||||
*For any* script generation request with valid parameters, the system SHALL:
|
||||
- Add the generated script to the scripts list
|
||||
- Set it as the selected script
|
||||
- Display it in the script view
|
||||
- Allow selection of any historical script from the list
|
||||
|
||||
**Validates: Requirements 6.6, 6.7, 6.8, 6.9, 6.10**
|
||||
|
||||
### Property 7: Path Generation Conditional Display
|
||||
|
||||
*For any* dashboard state:
|
||||
- If no script is selected, Path view SHALL display the "generate script first" prompt
|
||||
- If a script is selected, Path view SHALL display the script theme and generation button
|
||||
- After path generation, all path steps SHALL be displayed with sequential numbering
|
||||
|
||||
**Validates: Requirements 7.1, 7.2, 7.3, 7.4, 7.5**
|
||||
|
||||
### Property 8: Modal Open/Close Behavior
|
||||
|
||||
*For any* modal trigger action, the modal SHALL open with the correct content, and clicking the close button SHALL hide the modal and return to the previous state.
|
||||
|
||||
**Validates: Requirements 8.1, 8.3, 8.5, 11.4**
|
||||
|
||||
### Property 9: Corrupted State Recovery
|
||||
|
||||
*For any* corrupted or invalid JSON in localStorage, the State_Manager SHALL gracefully handle the error and initialize with default state values.
|
||||
|
||||
**Validates: Requirements 9.5**
|
||||
|
||||
## Error Handling
|
||||
|
||||
### 用户输入错误
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| 手机号格式错误 | 显示 alert 提示 "请输入正确的手机号" |
|
||||
| 验证码错误 | 显示 alert 提示 "验证失败,请检查手机号或验证码" |
|
||||
| 事件表单不完整 | 显示 alert 提示 "请完整填写记录" |
|
||||
| 剧本主题为空 | 显示 alert 提示 "请输入主题" |
|
||||
|
||||
### AI 服务错误
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| API 请求失败 | 返回默认文本 "(AI 暂时陷入了沉思,请稍后再试)" |
|
||||
| 网络超时 | 同上,使用 try-catch 捕获 |
|
||||
|
||||
### 状态持久化错误
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| localStorage 解析失败 | 使用 console.error 记录,使用默认状态 |
|
||||
| localStorage 不可用 | 应用正常运行,数据不持久化 |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### 单元测试 (Unit Tests)
|
||||
|
||||
使用 Vitest + React Testing Library:
|
||||
|
||||
1. **组件渲染测试**
|
||||
- GlassCard 渲染正确的样式类
|
||||
- GlassButton 各变体渲染正确
|
||||
- GlassInput 显示 label 和 placeholder
|
||||
- Modal 打开/关闭状态
|
||||
|
||||
2. **页面测试**
|
||||
- LoginPage 初始渲染
|
||||
- OnboardingPage 各步骤内容
|
||||
- DashboardPage 布局结构
|
||||
|
||||
3. **边缘情况**
|
||||
- 空数据状态显示
|
||||
- 长文本截断
|
||||
- 特殊字符处理
|
||||
|
||||
### 属性测试 (Property-Based Tests)
|
||||
|
||||
使用 fast-check 库,最少 100 次迭代:
|
||||
|
||||
1. **Property 1: State Round-Trip**
|
||||
- 生成随机状态对象
|
||||
- 序列化到 localStorage
|
||||
- 反序列化并比较
|
||||
|
||||
2. **Property 2: Login Validation**
|
||||
- 生成随机手机号字符串
|
||||
- 验证 11 位数字通过,其他拒绝
|
||||
|
||||
3. **Property 3: Step Progression**
|
||||
- 生成随机步骤序列
|
||||
- 验证数据保持和 UI 状态
|
||||
|
||||
4. **Property 5: Event Ordering**
|
||||
- 生成随机事件列表
|
||||
- 验证排序结果
|
||||
|
||||
### 测试配置
|
||||
|
||||
```javascript
|
||||
// vitest.config.js
|
||||
export default {
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.js'],
|
||||
coverage: {
|
||||
reporter: ['text', 'html'],
|
||||
exclude: ['node_modules/', 'src/test/']
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 测试标注格式
|
||||
|
||||
每个属性测试必须包含注释:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Feature: life-script-frontend
|
||||
* Property 1: State Persistence Round-Trip
|
||||
* Validates: Requirements 9.1, 9.2, 9.4
|
||||
*/
|
||||
test.prop([fc.record({...})])('state round-trip', (state) => {
|
||||
// test implementation
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,186 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
本项目旨在将 PncyssD 原型设计完整还原为一个基于 React + Tailwind CSS + Headless UI/Radix UI 的现代化前端应用。该应用是一款结合数字疗愈美学与人工智能的人生管理工具,包含登录、深度入站(Onboarding)、仪表盘(Dashboard)三大核心模块,以及生命长河(Timeline)、爽文剧本(Script)、实现路径(Path)三个功能视图。
|
||||
|
||||
## Glossary
|
||||
|
||||
- **System**: 人生轨迹前端应用系统
|
||||
- **User**: 使用该应用的终端用户
|
||||
- **Login_Page**: 登录页面组件
|
||||
- **Onboarding_Flow**: 深度入站流程组件
|
||||
- **Dashboard**: 仪表盘主界面组件
|
||||
- **Timeline_View**: 生命长河视图组件
|
||||
- **Script_View**: 爽文剧本视图组件
|
||||
- **Path_View**: 实现路径视图组件
|
||||
- **Glass_Card**: 毛玻璃卡片UI组件
|
||||
- **Glass_Button**: 毛玻璃按钮UI组件
|
||||
- **Glass_Input**: 毛玻璃输入框UI组件
|
||||
- **Modal**: 模态弹窗组件
|
||||
- **State_Manager**: 状态管理模块
|
||||
- **AI_Service**: AI服务调用模块
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: 全局视觉主题与布局
|
||||
|
||||
**User Story:** As a user, I want to experience a consistent dark-themed glassmorphism UI, so that I can enjoy a visually cohesive and calming digital healing aesthetic.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE System SHALL render a dynamic fluid background with gradient colors (#1a1c2c, #0a0c10, #2d1b10) and floating blur elements
|
||||
2. THE System SHALL apply glassmorphism styling (backdrop-filter blur, semi-transparent backgrounds, subtle borders) to all card components
|
||||
3. THE System SHALL use Noto Serif SC for headings and Noto Sans SC for body text
|
||||
4. THE System SHALL maintain a fixed header with logo and navigation elements
|
||||
5. THE System SHALL support responsive layouts for mobile (< 768px) and desktop viewports
|
||||
6. WHEN the viewport width is less than 768px, THE System SHALL adjust card border-radius and hide navigation text labels
|
||||
|
||||
### Requirement 2: 登录页面
|
||||
|
||||
**User Story:** As a user, I want to log in using my phone number and verification code, so that I can access my personal life trajectory data.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the user is not logged in, THE Login_Page SHALL display a centered glass card with phone input, verification code input, and login button
|
||||
2. THE Login_Page SHALL display the title "欢迎回来" with subtitle "开启你的数字生命档案"
|
||||
3. WHEN the user clicks the "获取" button with a valid 11-digit phone number, THE System SHALL start a 60-second countdown and display simulated verification code sent message
|
||||
4. IF the user clicks "获取" with an invalid phone number, THEN THE System SHALL display an error alert
|
||||
5. WHEN the user submits correct phone number and verification code (888888), THE System SHALL transition to the onboarding flow
|
||||
6. IF the user submits incorrect credentials, THEN THE System SHALL display a validation error message
|
||||
7. THE Login_Page SHALL display terms agreement text at the bottom
|
||||
|
||||
### Requirement 3: 深度入站流程 (Onboarding)
|
||||
|
||||
**User Story:** As a new user, I want to complete a 5-step onboarding process, so that I can set up my personal profile and life memories.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Onboarding_Flow SHALL consist of exactly 5 sequential steps with progress indicator dots
|
||||
2. WHEN on step 1, THE System SHALL display form fields for: 称呼, 性别, MBTI, 星座, 兴趣爱好
|
||||
3. WHEN on step 2, THE System SHALL display "童年记忆" section with date picker, text area, and inspiration tags (秋千, 晚霞, 糖果, etc.)
|
||||
4. WHEN on step 3, THE System SHALL display "开心的经历" section with date picker, text area, and inspiration tags (海浪, 拥抱, 掌声, etc.)
|
||||
5. WHEN on step 4, THE System SHALL display "沮丧与低谷" section with date picker, text area, and inspiration tags (落叶, 雨伞, 长廊, etc.)
|
||||
6. WHEN on step 5, THE System SHALL display "未来想成为谁" section with vision and ideal life text areas
|
||||
7. WHEN the user clicks an inspiration tag, THE System SHALL append the tag text to the corresponding text area
|
||||
8. THE System SHALL save form data to state when navigating between steps
|
||||
9. WHEN the user completes step 5 and clicks "开启人生", THE System SHALL transition to the dashboard
|
||||
10. THE System SHALL display "返回" button on steps 2-5 and hide it on step 1
|
||||
11. THE System SHALL update progress indicator to highlight current step with orange color and expanded width
|
||||
|
||||
### Requirement 4: 仪表盘布局
|
||||
|
||||
**User Story:** As a logged-in user, I want to access a dashboard with sidebar navigation, so that I can switch between different life management views.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Dashboard SHALL display a sidebar with navigation items: 生命长河, 爽文剧本, 实现路径
|
||||
2. THE Dashboard SHALL group navigation items under "回溯过去" and "创造未来" sections
|
||||
3. WHEN the user clicks a navigation item, THE System SHALL highlight it with active state styling and load the corresponding view
|
||||
4. THE Dashboard SHALL display a user profile button in the header
|
||||
5. THE Dashboard SHALL display an inspirational quote at the bottom of the sidebar
|
||||
6. THE System SHALL apply smooth fade transitions when switching between views
|
||||
|
||||
### Requirement 5: 生命长河视图 (Timeline)
|
||||
|
||||
**User Story:** As a user, I want to record and view my life events on a timeline, so that I can reflect on my past experiences with AI-powered insights.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Timeline_View SHALL display a header with title "生命长河" and "记录足迹" button
|
||||
2. WHEN no events exist, THE System SHALL display an empty state with wind icon and placeholder text
|
||||
3. WHEN events exist, THE System SHALL display them in reverse chronological order with timeline dots and connecting line
|
||||
4. FOR EACH event card, THE System SHALL display: title, date, content, and AI feedback section
|
||||
5. WHEN the user clicks "记录足迹", THE System SHALL open a modal with event form (title, date, content)
|
||||
6. WHEN the user submits a new event, THE System SHALL call AI service for analysis and save the event with AI feedback
|
||||
7. THE System SHALL display loading state "正在共鸣生命轨迹..." while AI processes the event
|
||||
|
||||
### Requirement 6: 爽文剧本视图 (Script Generator)
|
||||
|
||||
**User Story:** As a user, I want to generate epic life scripts based on my profile and experiences, so that I can envision an inspiring future narrative.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Script_View SHALL display a two-column layout: settings panel (left) and script display (right)
|
||||
2. THE System SHALL display user's character settings (nickname, zodiac, MBTI, hobbies) in a read-only card
|
||||
3. THE System SHALL provide form inputs for: 剧本主题, 叙事风格 (dropdown), 剧本篇幅 (dropdown)
|
||||
4. THE System SHALL offer style options: 都市沉浮, 快意恩仇, 唯美浪漫, 星际远征, 荒诞不经, 迷雾重重, 午夜回响
|
||||
5. THE System SHALL offer length options: 极简, 连载, 史诗
|
||||
6. WHEN the user clicks "开启天命编撰", THE System SHALL generate a script via AI service and display it
|
||||
7. THE System SHALL display historical scripts list with theme, style, length, and date
|
||||
8. WHEN the user clicks a historical script, THE System SHALL load and display that script
|
||||
9. WHEN no script is selected, THE System SHALL display an empty state with sparkles icon
|
||||
10. THE System SHALL format script content with 【标题】 sections highlighted in orange
|
||||
|
||||
### Requirement 7: 实现路径视图 (Path Generator)
|
||||
|
||||
**User Story:** As a user, I want to generate actionable life paths based on my scripts, so that I can plan realistic steps toward my goals.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. IF no script is selected, THEN THE Path_View SHALL display a prompt to generate a script first
|
||||
2. WHEN a script is selected, THE System SHALL display the script theme and "开启人生导航" button
|
||||
3. WHEN the user clicks the path generation button, THE System SHALL call AI service to generate path steps
|
||||
4. THE System SHALL display path steps as numbered cards with blue accent styling
|
||||
5. EACH path step card SHALL display: step number, phase title, and detailed recommendations
|
||||
6. THE System SHALL apply staggered animation delays when rendering path cards
|
||||
|
||||
### Requirement 8: 用户资料模态框
|
||||
|
||||
**User Story:** As a user, I want to view and edit my profile information, so that I can keep my personal data up to date.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the user clicks the profile button, THE System SHALL open a modal displaying user avatar, nickname, MBTI, zodiac
|
||||
2. THE System SHALL display statistics: 生命足迹 count and 天命卷轴 count
|
||||
3. WHEN the user clicks "编辑资料", THE System SHALL switch to edit mode with editable fields
|
||||
4. THE System SHALL provide editable fields for: 昵称, 职业, MBTI, 星座, 兴趣爱好
|
||||
5. WHEN the user clicks "保存修改", THE System SHALL update the state and return to view mode
|
||||
6. WHEN the user clicks "清除数据并退出", THE System SHALL clear all local storage and reload the page
|
||||
|
||||
### Requirement 9: 状态管理与持久化
|
||||
|
||||
**User Story:** As a user, I want my data to persist across sessions, so that I don't lose my life records and scripts.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE State_Manager SHALL persist all user data to localStorage under key 'life_trajectory_v3'
|
||||
2. THE State_Manager SHALL load saved state on application initialization
|
||||
3. THE State_Manager SHALL provide methods for: save, load, updateRegistration, addLifeEvent, addScript, setPath, clear
|
||||
4. THE System SHALL automatically save state after any data modification
|
||||
5. IF localStorage data is corrupted, THEN THE System SHALL handle the error gracefully and use default state
|
||||
|
||||
### Requirement 10: 页面过渡动画
|
||||
|
||||
**User Story:** As a user, I want smooth animations between pages and views, so that the experience feels polished and fluid.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN transitioning between major views (login → onboarding → dashboard), THE System SHALL apply fade-out and fade-in animations
|
||||
2. THE System SHALL display a loading spinner with text "载入生命序列..." during transitions
|
||||
3. WHEN switching dashboard views, THE System SHALL apply subtle opacity and translate animations
|
||||
4. THE System SHALL use cubic-bezier easing for smooth motion curves
|
||||
5. THE System SHALL apply staggered animations for list items and cards
|
||||
|
||||
### Requirement 11: 模态弹窗系统
|
||||
|
||||
**User Story:** As a user, I want modal dialogs for focused interactions, so that I can complete tasks without leaving the current context.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Modal SHALL display with a dark backdrop (bg-black/60) and blur effect
|
||||
2. THE Modal SHALL be centered on screen with max-width constraint
|
||||
3. THE Modal SHALL include a close button in the top-right corner
|
||||
4. WHEN the user clicks the close button, THE System SHALL hide the modal
|
||||
5. THE Modal content SHALL be scrollable when exceeding viewport height
|
||||
|
||||
### Requirement 12: 响应式设计
|
||||
|
||||
**User Story:** As a user, I want the application to work well on both mobile and desktop devices, so that I can access it from any device.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN viewport width is less than 768px, THE System SHALL use full-height view container without border-radius
|
||||
2. WHEN viewport width is less than 768px, THE System SHALL hide navigation item text labels and show only icons
|
||||
3. THE System SHALL use CSS Grid with responsive column configurations (1 column on mobile, 12-column grid on desktop)
|
||||
4. THE System SHALL adjust padding and spacing for mobile viewports
|
||||
@@ -0,0 +1,292 @@
|
||||
# Implementation Plan: Life Script Frontend
|
||||
|
||||
## Overview
|
||||
|
||||
本实现计划将 PncyssD 原型设计完整还原为基于 React + Tailwind CSS + Radix UI 的现代化前端应用。采用增量开发方式,从项目初始化开始,逐步构建基础组件、页面和功能模块。
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. 项目初始化与基础配置
|
||||
- [x] 1.1 初始化 Vite + React 项目
|
||||
- 在 life-script 目录创建 Vite React 项目
|
||||
- 安装核心依赖:react-router-dom, zustand, framer-motion, @radix-ui/react-dialog, lucide-react, axios
|
||||
- 配置 Tailwind CSS 和自定义主题色
|
||||
- _Requirements: 1.1, 1.2, 1.3_
|
||||
- [x] 1.2 配置全局样式和字体
|
||||
- 添加 Noto Serif SC 和 Noto Sans SC 字体
|
||||
- 创建 CSS 变量定义(glass-bg, glass-border, accent-orange, accent-blue)
|
||||
- 配置 Tailwind 自定义动画(float, float-delayed)
|
||||
- _Requirements: 1.2, 1.3_
|
||||
|
||||
- [x] 2. 基础UI组件开发
|
||||
- [x] 2.1 创建 Background 组件
|
||||
- 实现动态流体背景(渐变 + 浮动模糊圆 + 纹理叠加)
|
||||
- 添加 animate-float 和 animate-float-delayed 动画
|
||||
- _Requirements: 1.1_
|
||||
- [x] 2.2 创建 GlassCard 组件
|
||||
- 实现毛玻璃卡片样式(backdrop-filter, 边框, 阴影)
|
||||
- 支持 variant 属性(default, highlight, ai)
|
||||
- _Requirements: 1.2_
|
||||
- [x] 2.3 创建 GlassButton 组件
|
||||
- 实现毛玻璃按钮样式
|
||||
- 支持 variant(default, primary, icon)和 loading 状态
|
||||
- _Requirements: 1.2_
|
||||
- [x] 2.4 创建 GlassInput 和 GlassTextarea 组件
|
||||
- 实现毛玻璃输入框样式
|
||||
- 支持 label、placeholder、focus 状态
|
||||
- _Requirements: 1.2_
|
||||
- [x] 2.5 创建 GlassSelect 组件
|
||||
- 实现毛玻璃下拉选择框
|
||||
- 支持 options 数组配置
|
||||
- _Requirements: 1.2_
|
||||
- [x] 2.6 创建 Modal 组件
|
||||
- 使用 Radix UI Dialog 实现
|
||||
- 添加暗色遮罩和模糊效果
|
||||
- _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5_
|
||||
- [x] 2.7 创建 Loader 组件
|
||||
- 实现加载动画(旋转圆环 + 文字)
|
||||
- _Requirements: 10.2_
|
||||
- [x] 2.8 创建 PromptTag 组件
|
||||
- 实现灵感标签样式和点击交互
|
||||
- _Requirements: 3.7_
|
||||
|
||||
- [x] 3. Checkpoint - 基础组件完成
|
||||
- 确保所有基础 UI 组件正确渲染
|
||||
- 验证样式与原型一致
|
||||
|
||||
- [-] 4. 状态管理实现
|
||||
- [x] 4.1 创建 Zustand Store
|
||||
- 定义完整的 AppState 接口
|
||||
- 实现 save、load、updateRegistration、addLifeEvent、addScript、setPath、clear 方法
|
||||
- 配置 localStorage 持久化中间件
|
||||
- _Requirements: 9.1, 9.2, 9.3, 9.4_
|
||||
- [ ] 4.2 编写状态持久化属性测试
|
||||
- **Property 1: State Persistence Round-Trip**
|
||||
- **Validates: Requirements 9.1, 9.2, 9.4**
|
||||
- [ ] 4.3 编写损坏状态恢复属性测试
|
||||
- **Property 9: Corrupted State Recovery**
|
||||
- **Validates: Requirements 9.5**
|
||||
|
||||
- [x] 5. 布局组件开发
|
||||
- [x] 5.1 创建 Header 组件
|
||||
- 实现固定定位头部
|
||||
- 添加 logo 和用户按钮
|
||||
- _Requirements: 1.4_
|
||||
- [x] 5.2 创建 Sidebar 组件
|
||||
- 实现导航分组(回溯过去、创造未来)
|
||||
- 添加导航项激活状态
|
||||
- 添加底部引用文字
|
||||
- _Requirements: 4.1, 4.2, 4.5_
|
||||
|
||||
- [-] 6. 登录页面实现
|
||||
- [x] 6.1 创建 LoginPage 组件
|
||||
- 实现登录表单布局(手机号、验证码、登录按钮)
|
||||
- 添加标题和协议文字
|
||||
- _Requirements: 2.1, 2.2, 2.7_
|
||||
- [x] 6.2 实现验证码倒计时逻辑
|
||||
- 创建 useCountdown hook
|
||||
- 实现 60 秒倒计时和按钮状态切换
|
||||
- _Requirements: 2.3_
|
||||
- [x] 6.3 实现登录验证逻辑
|
||||
- 验证手机号格式(11位数字)
|
||||
- 验证验证码(888888)
|
||||
- 成功后跳转到 Onboarding
|
||||
- _Requirements: 2.4, 2.5, 2.6_
|
||||
- [ ] 6.4 编写登录验证属性测试
|
||||
- **Property 2: Login Validation and Navigation**
|
||||
- **Validates: Requirements 2.3, 2.4, 2.5, 2.6**
|
||||
|
||||
- [ ] 7. Checkpoint - 登录功能完成
|
||||
- 确保登录流程正常工作
|
||||
- 验证状态持久化
|
||||
|
||||
- [-] 8. 入站流程实现
|
||||
- [x] 8.1 创建 OnboardingPage 组件框架
|
||||
- 实现 5 步骤容器布局
|
||||
- 添加进度指示器
|
||||
- 添加导航按钮(返回、继续)
|
||||
- _Requirements: 3.1, 3.10, 3.11_
|
||||
- [x] 8.2 实现步骤 1 - 基础信息
|
||||
- 创建表单字段(称呼、性别、MBTI、星座、兴趣爱好)
|
||||
- _Requirements: 3.2_
|
||||
- [x] 8.3 实现步骤 2-4 - 记忆采集
|
||||
- 创建通用记忆步骤组件
|
||||
- 添加日期选择器和文本区域
|
||||
- 集成灵感标签(童年、开心、低谷)
|
||||
- _Requirements: 3.3, 3.4, 3.5, 3.7_
|
||||
- [x] 8.4 实现步骤 5 - 未来愿景
|
||||
- 创建愿景和理想生活文本区域
|
||||
- _Requirements: 3.6_
|
||||
- [x] 8.5 实现步骤间数据保存
|
||||
- 在步骤切换时保存表单数据到 store
|
||||
- _Requirements: 3.8_
|
||||
- [x] 8.6 实现完成跳转
|
||||
- 完成步骤 5 后跳转到 Dashboard
|
||||
- _Requirements: 3.9_
|
||||
- [ ] 8.7 编写入站步骤进度属性测试
|
||||
- **Property 3: Onboarding Step Progression**
|
||||
- **Validates: Requirements 3.1, 3.8, 3.10, 3.11**
|
||||
- [ ] 8.8 编写灵感标签追加属性测试
|
||||
- **Property 4: Inspiration Tag Appending**
|
||||
- **Validates: Requirements 3.7**
|
||||
|
||||
- [ ] 9. Checkpoint - 入站流程完成
|
||||
- 确保 5 步骤流程正常工作
|
||||
- 验证数据保存和跳转
|
||||
|
||||
- [x] 10. 仪表盘框架实现
|
||||
- [x] 10.1 创建 DashboardPage 组件
|
||||
- 实现 Grid 布局(侧边栏 3/12 + 内容区 9/12)
|
||||
- 集成 Header 和 Sidebar
|
||||
- _Requirements: 4.1, 4.2, 4.4_
|
||||
- [x] 10.2 实现视图切换逻辑
|
||||
- 添加导航点击处理
|
||||
- 实现视图切换动画
|
||||
- _Requirements: 4.3, 4.6_
|
||||
|
||||
- [-] 11. 生命长河视图实现
|
||||
- [x] 11.1 创建 TimelineView 组件
|
||||
- 实现标题和添加按钮
|
||||
- 实现空状态显示
|
||||
- _Requirements: 5.1, 5.2_
|
||||
- [x] 11.2 实现事件卡片列表
|
||||
- 创建时间线样式(点 + 连接线)
|
||||
- 实现事件卡片(标题、日期、内容、AI反馈)
|
||||
- 按时间倒序排列
|
||||
- _Requirements: 5.3, 5.4_
|
||||
- [x] 11.3 实现添加事件模态框
|
||||
- 创建事件表单(标题、日期、内容)
|
||||
- 集成 AI 分析调用
|
||||
- 显示加载状态
|
||||
- _Requirements: 5.5, 5.6, 5.7_
|
||||
- [ ] 11.4 编写时间线事件排序属性测试
|
||||
- **Property 5: Timeline Event Ordering**
|
||||
- **Validates: Requirements 5.3, 5.4**
|
||||
|
||||
- [ ] 12. Checkpoint - 时间线功能完成
|
||||
- 确保事件添加和显示正常
|
||||
- 验证 AI 分析集成
|
||||
|
||||
- [-] 13. 爽文剧本视图实现
|
||||
- [x] 13.1 创建 ScriptView 组件框架
|
||||
- 实现两栏布局(设置面板 + 剧本展示)
|
||||
- _Requirements: 6.1_
|
||||
- [x] 13.2 实现角色设定卡片
|
||||
- 显示用户信息(昵称、星座、MBTI、爱好)
|
||||
- 添加修改人设按钮
|
||||
- _Requirements: 6.2_
|
||||
- [x] 13.3 实现创作需求表单
|
||||
- 添加主题输入框
|
||||
- 添加风格下拉选择(7种风格)
|
||||
- 添加篇幅下拉选择(3种篇幅)
|
||||
- _Requirements: 6.3, 6.4, 6.5_
|
||||
- [x] 13.4 实现剧本生成功能
|
||||
- 集成 AI 剧本生成调用
|
||||
- 保存生成的剧本到 store
|
||||
- _Requirements: 6.6_
|
||||
- [x] 13.5 实现历史卷轴列表
|
||||
- 显示历史剧本列表
|
||||
- 实现点击选择功能
|
||||
- _Requirements: 6.7, 6.8_
|
||||
- [x] 13.6 实现剧本内容展示
|
||||
- 显示选中剧本内容
|
||||
- 格式化【标题】高亮
|
||||
- 实现空状态显示
|
||||
- _Requirements: 6.9, 6.10_
|
||||
- [ ] 13.7 编写剧本生成和选择属性测试
|
||||
- **Property 6: Script Generation and Selection**
|
||||
- **Validates: Requirements 6.6, 6.7, 6.8, 6.9, 6.10**
|
||||
|
||||
- [ ] 14. Checkpoint - 剧本功能完成
|
||||
- 确保剧本生成和选择正常
|
||||
- 验证历史记录功能
|
||||
|
||||
- [-] 15. 实现路径视图实现
|
||||
- [x] 15.1 创建 PathView 组件
|
||||
- 实现无剧本时的提示状态
|
||||
- 实现有剧本时的生成界面
|
||||
- _Requirements: 7.1, 7.2_
|
||||
- [x] 15.2 实现路径生成功能
|
||||
- 集成 AI 路径生成调用
|
||||
- 保存路径到 store
|
||||
- _Requirements: 7.3_
|
||||
- [x] 15.3 实现路径步骤展示
|
||||
- 创建编号卡片样式(蓝色主题)
|
||||
- 显示阶段标题和建议
|
||||
- 添加交错动画
|
||||
- _Requirements: 7.4, 7.5, 7.6_
|
||||
- [ ] 15.4 编写路径生成条件显示属性测试
|
||||
- **Property 7: Path Generation Conditional Display**
|
||||
- **Validates: Requirements 7.1, 7.2, 7.3, 7.4, 7.5**
|
||||
|
||||
- [-] 16. 用户资料模态框实现
|
||||
- [x] 16.1 创建 ProfileModal 组件
|
||||
- 实现查看模式(头像、昵称、统计)
|
||||
- _Requirements: 8.1, 8.2_
|
||||
- [x] 16.2 实现编辑模式
|
||||
- 添加可编辑字段
|
||||
- 实现保存和取消功能
|
||||
- _Requirements: 8.3, 8.4, 8.5_
|
||||
- [x] 16.3 实现清除数据功能
|
||||
- 添加确认对话框
|
||||
- 清除 localStorage 并刷新
|
||||
- _Requirements: 8.6_
|
||||
- [ ] 16.4 编写模态框开关行为属性测试
|
||||
- **Property 8: Modal Open/Close Behavior**
|
||||
- **Validates: Requirements 8.1, 8.3, 8.5, 11.4**
|
||||
|
||||
- [ ] 17. Checkpoint - 核心功能完成
|
||||
- 确保所有视图正常工作
|
||||
- 验证用户资料功能
|
||||
|
||||
- [x] 18. AI 服务集成
|
||||
- [x] 18.1 创建 AI Service 模块
|
||||
- 封装 OpenRouter API 调用
|
||||
- 实现 analyzeLifeEvent 方法
|
||||
- 实现 generateEpicScript 方法
|
||||
- 实现 generatePath 方法
|
||||
- _Requirements: 5.6, 6.6, 7.3_
|
||||
- [x] 18.2 实现错误处理
|
||||
- 添加 try-catch 错误捕获
|
||||
- 返回默认错误消息
|
||||
- _Requirements: 5.7_
|
||||
|
||||
- [x] 19. 响应式设计优化
|
||||
- [x] 19.1 实现移动端适配
|
||||
- 调整视图容器高度和圆角
|
||||
- 隐藏导航文字标签
|
||||
- 调整 Grid 列配置
|
||||
- _Requirements: 12.1, 12.2, 12.3, 12.4_
|
||||
- [x] 19.2 实现断点样式
|
||||
- 添加 768px 断点媒体查询
|
||||
- 调整内边距和间距
|
||||
- _Requirements: 1.5, 1.6_
|
||||
|
||||
- [x] 20. 页面过渡动画
|
||||
- [x] 20.1 实现页面切换动画
|
||||
- 使用 Framer Motion 实现淡入淡出
|
||||
- 添加加载器显示
|
||||
- _Requirements: 10.1, 10.2_
|
||||
- [x] 20.2 实现视图切换动画
|
||||
- 添加透明度和位移动画
|
||||
- 配置 cubic-bezier 缓动
|
||||
- _Requirements: 10.3, 10.4, 10.5_
|
||||
|
||||
- [x] 21. 路由配置
|
||||
- [x] 21.1 配置 React Router
|
||||
- 设置路由:/, /onboarding, /dashboard
|
||||
- 添加路由守卫(登录检查)
|
||||
- _Requirements: 2.5, 3.9_
|
||||
|
||||
- [ ] 22. Final Checkpoint - 项目完成
|
||||
- 确保所有功能正常工作
|
||||
- 验证响应式设计
|
||||
- 确保所有测试通过
|
||||
|
||||
## Notes
|
||||
|
||||
- All tasks are required for comprehensive testing
|
||||
- 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
|
||||
+5
@@ -75,6 +75,11 @@ public class UserProfileCreateRequest {
|
||||
*/
|
||||
private String futureVision;
|
||||
|
||||
/**
|
||||
* 理想生活状态
|
||||
*/
|
||||
private String idealLife;
|
||||
|
||||
/**
|
||||
* 生成的剧本列表 (JSON字符串)
|
||||
*/
|
||||
|
||||
+5
@@ -79,6 +79,11 @@ public class UserProfileUpdateRequest {
|
||||
*/
|
||||
private String futureVision;
|
||||
|
||||
/**
|
||||
* 理想生活状态
|
||||
*/
|
||||
private String idealLife;
|
||||
|
||||
/**
|
||||
* 生成的剧本列表 (JSON字符串)
|
||||
*/
|
||||
|
||||
+5
@@ -94,6 +94,11 @@ public class UserProfileResponse {
|
||||
*/
|
||||
private String futureVision;
|
||||
|
||||
/**
|
||||
* 理想生活状态
|
||||
*/
|
||||
private String idealLife;
|
||||
|
||||
/**
|
||||
* 生成的剧本列表 (JSON字符串)
|
||||
*/
|
||||
|
||||
@@ -103,6 +103,12 @@ public class UserProfile extends BaseEntity {
|
||||
@TableField("future_vision")
|
||||
private String futureVision;
|
||||
|
||||
/**
|
||||
* 理想生活状态
|
||||
*/
|
||||
@TableField("ideal_life")
|
||||
private String idealLife;
|
||||
|
||||
/**
|
||||
* 生成的剧本列表 (JSON字符串)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# 开发环境配置
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
@@ -0,0 +1,2 @@
|
||||
# 生产环境配置
|
||||
VITE_API_BASE_URL=/api
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,16 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>life-script</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3901
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "life-script",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"axios": "^1.13.2",
|
||||
"framer-motion": "^12.23.26",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.11.0",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vite": "npm:rolldown-vite@7.2.5"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@7.2.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Background } from './components/layout';
|
||||
import Loader from './components/Loader';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import OnboardingPage from './pages/OnboardingPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import useStore from './store/useStore';
|
||||
|
||||
/**
|
||||
* 路由守卫组件
|
||||
* 根据登录状态和注册完成状态进行路由重定向
|
||||
*/
|
||||
const ProtectedRoute = ({ children, requireAuth = false, requireOnboarding = false }) => {
|
||||
const { isLoggedIn, registrationData } = useStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 检查是否完成入站流程
|
||||
const hasCompletedOnboarding = registrationData.nickname && registrationData.future?.vision;
|
||||
|
||||
useEffect(() => {
|
||||
if (requireAuth && !isLoggedIn) {
|
||||
navigate('/', { replace: true });
|
||||
} else if (requireOnboarding && !hasCompletedOnboarding) {
|
||||
navigate('/onboarding', { replace: true });
|
||||
}
|
||||
}, [isLoggedIn, hasCompletedOnboarding, requireAuth, requireOnboarding, navigate]);
|
||||
|
||||
if (requireAuth && !isLoggedIn) {
|
||||
return <Loader text="正在验证身份..." />;
|
||||
}
|
||||
|
||||
if (requireOnboarding && !hasCompletedOnboarding) {
|
||||
return <Loader text="正在加载..." />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
/**
|
||||
* 页面过渡动画包装器
|
||||
*/
|
||||
const PageTransition = ({ children }) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
ease: [0.25, 0.46, 0.45, 0.94]
|
||||
}}
|
||||
className="w-full h-full"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 动画路由组件
|
||||
*/
|
||||
const AnimatedRoutes = () => {
|
||||
const location = useLocation();
|
||||
const { isLoggedIn, registrationData } = useStore();
|
||||
|
||||
// 检查是否完成入站流程
|
||||
const hasCompletedOnboarding = registrationData.nickname && registrationData.future?.vision;
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<Routes location={location} key={location.pathname}>
|
||||
{/* 登录页 */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
isLoggedIn ? (
|
||||
hasCompletedOnboarding ? (
|
||||
<Navigate to="/dashboard" replace />
|
||||
) : (
|
||||
<Navigate to="/onboarding" replace />
|
||||
)
|
||||
) : (
|
||||
<PageTransition>
|
||||
<LoginPage />
|
||||
</PageTransition>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 入站流程页 */}
|
||||
<Route
|
||||
path="/onboarding"
|
||||
element={
|
||||
<ProtectedRoute requireAuth>
|
||||
<PageTransition>
|
||||
<OnboardingPage />
|
||||
</PageTransition>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 仪表盘页 */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute requireAuth requireOnboarding>
|
||||
<PageTransition>
|
||||
<DashboardPage />
|
||||
</PageTransition>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 404 重定向 */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* App 主组件
|
||||
*/
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
{/* 动态背景 */}
|
||||
<Background />
|
||||
|
||||
{/* 主容器 */}
|
||||
<main className="relative z-10 min-h-screen flex flex-col items-center justify-center p-4 md:p-8">
|
||||
<AnimatedRoutes />
|
||||
</main>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,29 @@
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
/**
|
||||
* Loader 组件
|
||||
* 全屏加载动画,包含旋转圆环和文字
|
||||
* @param {Object} props
|
||||
* @param {string} props.text - 加载文字
|
||||
*/
|
||||
const Loader = ({ text = '载入生命序列...' }) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="fixed inset-0 flex flex-col items-center justify-center z-[200] bg-[#0a0c10]"
|
||||
>
|
||||
{/* 旋转圆环 */}
|
||||
<div className="w-16 h-16 border-2 border-orange-200/20 border-t-orange-200 rounded-full animate-spin mb-4" />
|
||||
|
||||
{/* 加载文字 */}
|
||||
<p className="text-xs tracking-[0.3em] text-white/40 uppercase">
|
||||
{text}
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loader;
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
/**
|
||||
* Modal 组件
|
||||
* 使用 Radix UI Dialog 实现的模态弹窗
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.isOpen - 是否打开
|
||||
* @param {Function} props.onClose - 关闭回调
|
||||
* @param {React.ReactNode} props.children - 子元素
|
||||
* @param {'sm'|'md'|'lg'} props.maxWidth - 最大宽度
|
||||
* @param {string} props.title - 标题(可选)
|
||||
*/
|
||||
const Modal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
maxWidth = 'md',
|
||||
title
|
||||
}) => {
|
||||
// 最大宽度映射
|
||||
const maxWidthMap = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl'
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<Dialog.Portal forceMount>
|
||||
{/* 遮罩层 */}
|
||||
<Dialog.Overlay asChild>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-xl z-[100]"
|
||||
/>
|
||||
</Dialog.Overlay>
|
||||
|
||||
{/* 内容区 */}
|
||||
<Dialog.Content asChild>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
className={`
|
||||
fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
|
||||
glass-card ${maxWidthMap[maxWidth]} w-[calc(100%-2rem)] p-8
|
||||
border border-white/10 shadow-2xl z-[101]
|
||||
`}
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
className="absolute top-6 right-6 text-white/40 hover:text-white transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
|
||||
{/* 标题 */}
|
||||
{title && (
|
||||
<Dialog.Title className="text-2xl font-serif mb-6">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
)}
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="max-h-[70vh] overflow-y-auto pr-2 custom-scrollbar">
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* PromptTag 组件
|
||||
* 灵感标签,点击后追加文本到目标输入框
|
||||
* @param {Object} props
|
||||
* @param {string} props.text - 标签文本
|
||||
* @param {Function} props.onClick - 点击回调
|
||||
*/
|
||||
const PromptTag = ({ text, onClick }) => {
|
||||
return (
|
||||
<span
|
||||
onClick={() => onClick(text)}
|
||||
className="prompt-tag"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* PromptTagGroup 组件
|
||||
* 灵感标签组,用于批量渲染标签
|
||||
* @param {Object} props
|
||||
* @param {string[]} props.tags - 标签文本数组
|
||||
* @param {Function} props.onTagClick - 标签点击回调
|
||||
*/
|
||||
export const PromptTagGroup = ({ tags, onTagClick }) => {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{tags.map((tag, index) => (
|
||||
<PromptTag
|
||||
key={index}
|
||||
text={tag}
|
||||
onClick={onTagClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptTag;
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Background 组件
|
||||
* 动态流体背景,包含渐变、浮动模糊圆和纹理叠加
|
||||
*/
|
||||
const Background = () => {
|
||||
return (
|
||||
<div id="app-bg" className="fixed inset-0 z-[-1]">
|
||||
{/* 渐变底层 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#1a1c2c] via-[#0a0c10] to-[#2d1b10] opacity-80" />
|
||||
|
||||
{/* 浮动模糊圆 - 蓝色 */}
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[60%] h-[60%] bg-blue-900/20 blur-[120px] rounded-full animate-float" />
|
||||
|
||||
{/* 浮动模糊圆 - 橙色 */}
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-orange-900/10 blur-[120px] rounded-full animate-float-delayed" />
|
||||
|
||||
{/* 纹理叠加层 */}
|
||||
<img
|
||||
src="https://r2-bucket.flowith.net/f/845b300ff0a2b36e/digital_healing_background_design_index_1%401024x1024.jpeg"
|
||||
alt="texture"
|
||||
className="w-full h-full object-cover mix-blend-overlay opacity-30"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Background;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { User } from 'lucide-react';
|
||||
import GlassButton from '../ui/GlassButton';
|
||||
|
||||
/**
|
||||
* Header 组件
|
||||
* 固定定位头部,包含 logo 和用户按钮
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.showNav - 是否显示导航按钮
|
||||
* @param {Function} props.onProfileClick - 用户按钮点击回调
|
||||
*/
|
||||
const Header = ({ showNav = false, onProfileClick }) => {
|
||||
/**
|
||||
* 处理 logo 点击,刷新页面
|
||||
*/
|
||||
const handleLogoClick = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 left-0 p-6 z-50 w-full flex justify-between items-center pointer-events-none">
|
||||
{/* Logo 区域 */}
|
||||
<div
|
||||
className="flex items-center gap-3 pointer-events-auto cursor-pointer"
|
||||
onClick={handleLogoClick}
|
||||
>
|
||||
<img
|
||||
src="https://r2-bucket.flowith.net/f/cf8c6e7c020409c9/lifeline_app_logo_design_index_0%401024x1024.jpeg"
|
||||
alt="logo"
|
||||
className="w-10 h-10 rounded-full shadow-2xl border border-white/10"
|
||||
/>
|
||||
<h1 className="text-xl font-serif tracking-[0.2em] bg-clip-text text-transparent bg-gradient-to-r from-white to-white/50">
|
||||
人生轨迹
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* 导航区域 */}
|
||||
{showNav && (
|
||||
<nav className="pointer-events-auto">
|
||||
<GlassButton
|
||||
variant="icon"
|
||||
onClick={onProfileClick}
|
||||
className="hover:shadow-orange-200/10 shadow-lg"
|
||||
>
|
||||
<User className="w-5 h-5" />
|
||||
</GlassButton>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { History, Sparkles, Map } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* 导航项配置
|
||||
*/
|
||||
const navGroups = [
|
||||
{
|
||||
title: '回溯过去',
|
||||
items: [
|
||||
{ id: 'timeline', label: '生命长河', icon: History }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '创造未来',
|
||||
items: [
|
||||
{ id: 'script', label: '爽文剧本', icon: Sparkles },
|
||||
{ id: 'path', label: '实现路径', icon: Map }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Sidebar 组件
|
||||
* 仪表盘侧边栏导航
|
||||
* @param {Object} props
|
||||
* @param {'timeline'|'script'|'path'} props.activeView - 当前激活的视图
|
||||
* @param {Function} props.onViewChange - 视图切换回调
|
||||
*/
|
||||
const Sidebar = ({ activeView, onViewChange }) => {
|
||||
return (
|
||||
<aside className="md:col-span-3 border-r border-white/5 p-6 flex flex-col gap-6 bg-black/20">
|
||||
{/* 导航分组 */}
|
||||
{navGroups.map((group) => (
|
||||
<div key={group.title} className="space-y-2">
|
||||
{/* 分组标题 */}
|
||||
<div className="px-3 py-2 text-[10px] text-white/30 uppercase tracking-[0.2em] font-bold">
|
||||
{group.title}
|
||||
</div>
|
||||
|
||||
{/* 导航项 */}
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeView === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onViewChange(item.id)}
|
||||
className={`
|
||||
nav-item w-full flex items-center gap-3 p-4 rounded-2xl glass-btn text-white/50
|
||||
${isActive ? 'active' : ''}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 底部引用文字 */}
|
||||
<div className="mt-auto p-4 bg-white/[0.02] rounded-2xl border border-white/5">
|
||||
<p className="text-[10px] text-white/20 italic leading-relaxed">
|
||||
"回溯过去、记录当下、创造未来。"
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 布局组件统一导出
|
||||
*/
|
||||
export { default as Background } from './Background';
|
||||
export { default as Header } from './Header';
|
||||
export { default as Sidebar } from './Sidebar';
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* GlassButton 组件
|
||||
* 毛玻璃按钮样式,支持多种变体和加载状态
|
||||
* @param {Object} props
|
||||
* @param {React.ReactNode} props.children - 子元素
|
||||
* @param {Function} props.onClick - 点击事件处理函数
|
||||
* @param {'default'|'primary'|'icon'} props.variant - 按钮变体
|
||||
* @param {boolean} props.disabled - 是否禁用
|
||||
* @param {boolean} props.loading - 是否显示加载状态
|
||||
* @param {string} props.className - 额外的CSS类名
|
||||
* @param {string} props.type - 按钮类型
|
||||
*/
|
||||
const GlassButton = ({
|
||||
children,
|
||||
onClick,
|
||||
variant = 'default',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
className = '',
|
||||
type = 'button'
|
||||
}) => {
|
||||
// 变体样式映射
|
||||
const variantMap = {
|
||||
default: 'px-6 py-3 rounded-2xl text-white/70',
|
||||
primary: 'px-8 py-4 rounded-2xl bg-orange-200/5 text-orange-200 font-bold tracking-[0.2em] border-orange-200/20 shadow-lg shadow-orange-900/10',
|
||||
icon: 'p-2.5 rounded-full'
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
className={`
|
||||
glass-btn
|
||||
${variantMap[variant]}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<span className="animate-pulse">处理中...</span>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlassButton;
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* GlassCard 组件
|
||||
* 毛玻璃卡片样式,支持多种变体
|
||||
* @param {Object} props
|
||||
* @param {React.ReactNode} props.children - 子元素
|
||||
* @param {string} props.className - 额外的CSS类名
|
||||
* @param {'default'|'highlight'|'ai'} props.variant - 卡片变体
|
||||
* @param {'sm'|'md'|'lg'} props.padding - 内边距大小
|
||||
*/
|
||||
const GlassCard = ({
|
||||
children,
|
||||
className = '',
|
||||
variant = 'default',
|
||||
padding = 'md'
|
||||
}) => {
|
||||
// 内边距映射
|
||||
const paddingMap = {
|
||||
sm: 'p-4',
|
||||
md: 'p-6',
|
||||
lg: 'p-8'
|
||||
};
|
||||
|
||||
// 变体样式映射
|
||||
const variantMap = {
|
||||
default: 'border-white/5',
|
||||
highlight: 'border-orange-200/20 shadow-orange-900/10',
|
||||
ai: 'border-orange-200/5 bg-orange-200/[0.02]'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
glass-card
|
||||
${paddingMap[padding]}
|
||||
${variantMap[variant]}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlassCard;
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* GlassInput 组件
|
||||
* 毛玻璃输入框样式
|
||||
* @param {Object} props
|
||||
* @param {string} props.label - 标签文本
|
||||
* @param {string} props.type - 输入类型
|
||||
* @param {string} props.placeholder - 占位符文本
|
||||
* @param {string} props.value - 输入值
|
||||
* @param {Function} props.onChange - 值变化处理函数
|
||||
* @param {number} props.maxLength - 最大长度
|
||||
* @param {string} props.className - 额外的CSS类名
|
||||
* @param {string} props.id - 输入框ID
|
||||
*/
|
||||
const GlassInput = ({
|
||||
label,
|
||||
type = 'text',
|
||||
placeholder = '',
|
||||
value,
|
||||
onChange,
|
||||
maxLength,
|
||||
className = '',
|
||||
id
|
||||
}) => {
|
||||
return (
|
||||
<div className={`flex flex-col gap-2 ${className}`}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-[10px] text-white/30 uppercase tracking-widest font-bold"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
maxLength={maxLength}
|
||||
className="glass-input w-full focus:ring-2 focus:ring-orange-200/50"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlassInput;
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* GlassSelect 组件
|
||||
* 毛玻璃下拉选择框样式
|
||||
* @param {Object} props
|
||||
* @param {string} props.label - 标签文本
|
||||
* @param {Array<{value: string, label: string}>} props.options - 选项数组
|
||||
* @param {string} props.value - 当前选中值
|
||||
* @param {Function} props.onChange - 值变化处理函数
|
||||
* @param {string} props.className - 额外的CSS类名
|
||||
* @param {string} props.id - 选择框ID
|
||||
*/
|
||||
const GlassSelect = ({
|
||||
label,
|
||||
options = [],
|
||||
value,
|
||||
onChange,
|
||||
className = '',
|
||||
id
|
||||
}) => {
|
||||
return (
|
||||
<div className={`flex flex-col gap-2 ${className}`}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-[10px] text-white/30 uppercase tracking-widest font-bold"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="glass-input w-full appearance-none cursor-pointer"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="bg-[#0a0c10] text-white"
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlassSelect;
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* GlassTextarea 组件
|
||||
* 毛玻璃文本区域样式
|
||||
* @param {Object} props
|
||||
* @param {string} props.label - 标签文本
|
||||
* @param {string} props.placeholder - 占位符文本
|
||||
* @param {string} props.value - 输入值
|
||||
* @param {Function} props.onChange - 值变化处理函数
|
||||
* @param {number} props.rows - 行数
|
||||
* @param {string} props.className - 额外的CSS类名
|
||||
* @param {string} props.id - 文本区域ID
|
||||
*/
|
||||
const GlassTextarea = ({
|
||||
label,
|
||||
placeholder = '',
|
||||
value,
|
||||
onChange,
|
||||
rows = 4,
|
||||
className = '',
|
||||
id
|
||||
}) => {
|
||||
return (
|
||||
<div className={`flex flex-col gap-2 ${className}`}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-[10px] text-white/30 uppercase tracking-widest font-bold"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={rows}
|
||||
className="glass-input w-full resize-none focus:ring-2 focus:ring-orange-200/50 text-sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlassTextarea;
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* UI组件统一导出
|
||||
*/
|
||||
export { default as GlassCard } from './GlassCard';
|
||||
export { default as GlassButton } from './GlassButton';
|
||||
export { default as GlassInput } from './GlassInput';
|
||||
export { default as GlassTextarea } from './GlassTextarea';
|
||||
export { default as GlassSelect } from './GlassSelect';
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* useCountdown Hook
|
||||
* 倒计时钩子,用于验证码发送倒计时
|
||||
* @param {number} initialSeconds - 初始秒数
|
||||
* @returns {Object} { countdown, isActive, start, reset }
|
||||
*/
|
||||
const useCountdown = (initialSeconds = 60) => {
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timer;
|
||||
if (isActive && countdown > 0) {
|
||||
timer = setInterval(() => {
|
||||
setCountdown((prev) => prev - 1);
|
||||
}, 1000);
|
||||
} else if (countdown === 0 && isActive) {
|
||||
setIsActive(false);
|
||||
}
|
||||
return () => clearInterval(timer);
|
||||
}, [isActive, countdown]);
|
||||
|
||||
/**
|
||||
* 开始倒计时
|
||||
*/
|
||||
const start = useCallback(() => {
|
||||
setCountdown(initialSeconds);
|
||||
setIsActive(true);
|
||||
}, [initialSeconds]);
|
||||
|
||||
/**
|
||||
* 重置倒计时
|
||||
*/
|
||||
const reset = useCallback(() => {
|
||||
setCountdown(0);
|
||||
setIsActive(false);
|
||||
}, []);
|
||||
|
||||
return { countdown, isActive, start, reset };
|
||||
};
|
||||
|
||||
export default useCountdown;
|
||||
@@ -0,0 +1,272 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@300;600&family=Noto+Sans+SC:wght@300;400;500&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--glass-bg: rgba(15, 17, 26, 0.4);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
--accent-orange: #FFAB91;
|
||||
--accent-blue: #81D4FA;
|
||||
--card-shadow: 0 20px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: #e2e8f0;
|
||||
background-color: #0a0c10;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.font-serif {
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
}
|
||||
|
||||
/* Advanced Glassmorphism */
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(25px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(25px) saturate(180%);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 32px;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.glass-btn {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.glass-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.glass-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.glass-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
|
||||
.glass-input {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 14px 20px;
|
||||
color: white;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.glass-input:focus {
|
||||
border-color: var(--accent-orange);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
box-shadow: 0 0 20px rgba(255, 171, 145, 0.1);
|
||||
}
|
||||
|
||||
.glass-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translate(5%, 5%) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float-delayed {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-5%, -5%) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 15s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.animate-float-delayed {
|
||||
animation: float-delayed 20s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
|
||||
/* Timeline UI */
|
||||
.timeline-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--accent-orange);
|
||||
background: #0a0c10;
|
||||
box-shadow: 0 0 10px var(--accent-orange);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.timeline-line {
|
||||
position: absolute;
|
||||
left: 21px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.1) 15%, rgba(255, 255, 255, 0.1) 85%, transparent);
|
||||
}
|
||||
|
||||
/* Scrollbar Refinement */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Prompt Tag */
|
||||
.prompt-tag {
|
||||
display: inline-block;
|
||||
padding: 6px 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 99px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.prompt-tag:hover {
|
||||
background: var(--accent-orange);
|
||||
color: #000;
|
||||
border-color: var(--accent-orange);
|
||||
}
|
||||
|
||||
/* Sidebar Active State */
|
||||
.nav-item.active {
|
||||
background: rgba(255, 171, 145, 0.08);
|
||||
border-color: rgba(255, 171, 145, 0.2);
|
||||
color: var(--accent-orange) !important;
|
||||
}
|
||||
|
||||
/* AI Glow Effect */
|
||||
.ai-glow-card {
|
||||
background: linear-gradient(145deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 100%);
|
||||
border-left: 2px solid var(--accent-orange);
|
||||
box-shadow: inset 0 0 20px rgba(255, 171, 145, 0.05);
|
||||
}
|
||||
|
||||
/* Responsive Form Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.view-container {
|
||||
height: 95vh !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-item i {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
/* 移动端侧边栏调整 */
|
||||
.sidebar-nav {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* 移动端内容区调整 */
|
||||
.content-area {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* 移动端头部调整 */
|
||||
header h1 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 移动端表单调整 */
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏幕设备 */
|
||||
@media (max-width: 480px) {
|
||||
.glass-card {
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.glass-input {
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.glass-btn {
|
||||
padding: 12px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App.jsx';
|
||||
|
||||
/**
|
||||
* 应用入口
|
||||
* 渲染 React 应用到 DOM
|
||||
*/
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Header, Sidebar } from '../components/layout';
|
||||
import TimelineView from '../views/TimelineView';
|
||||
import ScriptView from '../views/ScriptView';
|
||||
import PathView from '../views/PathView';
|
||||
import ProfileModal from '../views/ProfileModal';
|
||||
|
||||
/**
|
||||
* DashboardPage 组件
|
||||
* 仪表盘主页面,包含侧边栏导航和内容区
|
||||
*/
|
||||
const DashboardPage = () => {
|
||||
// 当前激活的视图
|
||||
const [activeView, setActiveView] = useState('timeline');
|
||||
// 用户资料模态框状态
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||
|
||||
/**
|
||||
* 处理视图切换
|
||||
*/
|
||||
const handleViewChange = (view) => {
|
||||
setActiveView(view);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染当前视图内容
|
||||
*/
|
||||
const renderView = () => {
|
||||
switch (activeView) {
|
||||
case 'timeline':
|
||||
return <TimelineView />;
|
||||
case 'script':
|
||||
return <ScriptView onOpenProfile={() => setIsProfileOpen(true)} />;
|
||||
case 'path':
|
||||
return <PathView onGoToScript={() => setActiveView('script')} />;
|
||||
default:
|
||||
return <TimelineView />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 头部 */}
|
||||
<Header
|
||||
showNav
|
||||
onProfileClick={() => setIsProfileOpen(true)}
|
||||
/>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className="glass-card w-full h-full overflow-hidden">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 h-full">
|
||||
{/* 侧边栏 */}
|
||||
<Sidebar
|
||||
activeView={activeView}
|
||||
onViewChange={handleViewChange}
|
||||
/>
|
||||
|
||||
{/* 内容区 */}
|
||||
<section className="md:col-span-9 p-8 overflow-y-auto custom-scrollbar relative">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeView}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
className="h-full"
|
||||
>
|
||||
{renderView()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 用户资料模态框 */}
|
||||
<ProfileModal
|
||||
isOpen={isProfileOpen}
|
||||
onClose={() => setIsProfileOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { GlassCard, GlassInput, GlassButton } from '../components/ui';
|
||||
import useStore from '../store/useStore';
|
||||
import useCountdown from '../hooks/useCountdown';
|
||||
|
||||
/**
|
||||
* LoginPage 组件
|
||||
* 登录页面,包含手机号和验证码输入
|
||||
*/
|
||||
const LoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { login, getSmsCode, setLogin, loading } = useStore();
|
||||
|
||||
// 表单状态
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 倒计时
|
||||
const { countdown, isActive, start } = useCountdown(60);
|
||||
|
||||
/**
|
||||
* 处理获取验证码
|
||||
*/
|
||||
const handleGetCode = async () => {
|
||||
if (phone.length !== 11) {
|
||||
alert('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
start();
|
||||
// 调用后端获取验证码
|
||||
await getSmsCode(phone);
|
||||
alert('验证码已发送');
|
||||
} catch (error) {
|
||||
// 后端不可用时,使用模拟验证码
|
||||
alert('验证码已发送 (模拟验证码: 888888)');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理登录提交
|
||||
*/
|
||||
const handleSubmit = async () => {
|
||||
if (phone.length !== 11) {
|
||||
alert('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
if (code.length !== 6) {
|
||||
alert('请输入6位验证码');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// 尝试调用后端登录
|
||||
await login(phone, code);
|
||||
navigate('/onboarding');
|
||||
} catch (error) {
|
||||
// 后端不可用时,使用本地验证
|
||||
if (code === '888888') {
|
||||
setLogin(true, phone);
|
||||
navigate('/onboarding');
|
||||
} else {
|
||||
alert('验证失败,请检查手机号或验证码');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center p-6 animate-fade-in">
|
||||
<GlassCard className="max-w-md w-full space-y-8 border-white/5 shadow-2xl" padding="lg">
|
||||
{/* 标题区域 */}
|
||||
<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="space-y-4">
|
||||
{/* 手机号输入 */}
|
||||
<GlassInput
|
||||
label="手机号码"
|
||||
type="tel"
|
||||
placeholder="输入手机号"
|
||||
value={phone}
|
||||
onChange={setPhone}
|
||||
maxLength={11}
|
||||
className="text-center tracking-[0.1em]"
|
||||
/>
|
||||
|
||||
{/* 验证码输入 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2">
|
||||
<GlassInput
|
||||
label="验证码"
|
||||
type="text"
|
||||
placeholder="六位验证码"
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
maxLength={6}
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={handleGetCode}
|
||||
disabled={isActive || loading}
|
||||
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"
|
||||
>
|
||||
{isActive ? `${countdown}S` : '获取'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<GlassButton
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
loading={isSubmitting || loading}
|
||||
className="w-full"
|
||||
>
|
||||
开启旅程
|
||||
</GlassButton>
|
||||
|
||||
{/* 协议文字 */}
|
||||
<p className="text-[10px] text-center text-white/20 px-4 leading-relaxed">
|
||||
登录即代表同意《用户协议》与《隐私政策》,我们将妥善保管您的生命数据。
|
||||
</p>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowRight, Check } from 'lucide-react';
|
||||
import { GlassCard, GlassInput, GlassTextarea, GlassButton } from '../components/ui';
|
||||
import { PromptTagGroup } from '../components/PromptTag';
|
||||
import useStore from '../store/useStore';
|
||||
import { inspirationClusters } from '../utils/constants';
|
||||
|
||||
/**
|
||||
* OnboardingPage 组件
|
||||
* 5步入站流程页面
|
||||
*/
|
||||
const OnboardingPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
registrationData,
|
||||
updateRegistration,
|
||||
saveUserProfile,
|
||||
setView,
|
||||
loading
|
||||
} = useStore();
|
||||
|
||||
const [formData, setFormData] = useState(registrationData);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFormData(registrationData);
|
||||
}, [registrationData]);
|
||||
|
||||
const updateField = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const updateNestedField = (parent, field, value) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[parent]: { ...prev[parent], [field]: value }
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTagClick = (type, text) => {
|
||||
const currentText = formData[type]?.text || '';
|
||||
updateNestedField(type, 'text', currentText + text);
|
||||
};
|
||||
|
||||
const saveStepData = () => {
|
||||
updateRegistration(formData);
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
saveStepData();
|
||||
if (currentStep < 5) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
} else {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await saveUserProfile();
|
||||
} catch (error) {
|
||||
console.error('保存档案失败:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setView('dashboard');
|
||||
navigate('/dashboard');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
saveStepData();
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStep1 = () => (
|
||||
<div className="animate-fade-in space-y-8">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-4xl font-serif mb-3">你是谁?</h2>
|
||||
<p className="text-white/40 italic text-sm">定义你生命坐标的初始属性。</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2">
|
||||
<GlassInput label="称呼" placeholder="例如:林中鹿" value={formData.nickname} onChange={(v) => updateField('nickname', v)} />
|
||||
<GlassInput label="性别" placeholder="自由填写" value={formData.gender} onChange={(v) => updateField('gender', v)} />
|
||||
<GlassInput label="MBTI" placeholder="如:INFJ" value={formData.mbti} onChange={(v) => updateField('mbti', v)} />
|
||||
<GlassInput label="星座" placeholder="星辰指引" value={formData.zodiac} onChange={(v) => updateField('zodiac', v)} />
|
||||
</div>
|
||||
<GlassInput
|
||||
label="兴趣爱好"
|
||||
placeholder="用逗号分隔你的热爱"
|
||||
value={Array.isArray(formData.hobbies) ? formData.hobbies.join(',') : formData.hobbies}
|
||||
onChange={(v) => updateField('hobbies', v.split(',').map(s => s.trim()).filter(s => s))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMemoryStep = (type, title, label) => (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<div>
|
||||
<h2 className="text-4xl font-serif mb-3">{title}</h2>
|
||||
<p className="text-white/40 italic text-sm">回望足迹,这些瞬间如何塑造了此时的你。</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<GlassInput label={`${label}的日期`} type="date" value={formData[type]?.date || ''} onChange={(v) => updateNestedField(type, 'date', v)} className="max-w-xs" />
|
||||
<GlassTextarea label="详细描述" placeholder="描述那段时光发生的点滴..." value={formData[type]?.text || ''} onChange={(v) => updateNestedField(type, 'text', v)} rows={5} />
|
||||
<PromptTagGroup tags={inspirationClusters[type]} onTagClick={(text) => handleTagClick(type, text)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderStep5 = () => (
|
||||
<div className="animate-fade-in space-y-8">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-4xl font-serif mb-3">未来想成为谁?</h2>
|
||||
<p className="text-white/40 italic text-sm">勾勒你对理想生活的全部向往。</p>
|
||||
</div>
|
||||
<GlassTextarea label="对未来的憧憬" placeholder="你想成为一个什么样的人?" value={formData.future?.vision || ''} onChange={(v) => updateNestedField('future', 'vision', v)} rows={4} />
|
||||
<GlassTextarea label="理想生活状态" placeholder="你的理想清晨与傍晚是怎样的?" value={formData.future?.ideal || ''} onChange={(v) => updateNestedField('future', 'ideal', v)} rows={4} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1: return renderStep1();
|
||||
case 2: return renderMemoryStep('childhood', '那段纯真的时光', '童年记忆');
|
||||
case 3: return renderMemoryStep('joy', '光芒闪耀的时刻', '开心的经历');
|
||||
case 4: return renderMemoryStep('low', '在暗夜中潜行', '沮丧与低谷');
|
||||
case 5: return renderStep5();
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard className="w-full h-full flex flex-col justify-between overflow-hidden relative" padding="lg">
|
||||
<div className="flex-1 flex flex-col justify-center max-w-2xl mx-auto w-full overflow-y-auto custom-scrollbar">
|
||||
{renderStepContent()}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-10 max-w-2xl mx-auto w-full border-t border-white/5 pt-8">
|
||||
<div className="flex gap-2">
|
||||
{[1, 2, 3, 4, 5].map((step) => (
|
||||
<div key={step} className={`h-1 rounded-full transition-all duration-500 ${step === currentStep ? 'w-8 bg-orange-200' : 'w-3 bg-white/10'}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
{currentStep > 1 && (
|
||||
<button onClick={handlePrev} className="text-white/40 px-6 py-2 text-sm hover:text-white transition-colors">返回</button>
|
||||
)}
|
||||
<GlassButton onClick={handleNext} loading={isSaving || loading} className="px-8 py-3 rounded-full text-orange-200 font-bold tracking-widest text-sm shadow-xl shadow-orange-900/10">
|
||||
{currentStep === 5 ? (<>开启人生 <Check className="w-4 h-4 ml-2" /></>) : (<>继续 <ArrowRight className="w-4 h-4 ml-2" /></>)}
|
||||
</GlassButton>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingPage;
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* AI 服务模块
|
||||
* 封装 OpenRouter API 调用
|
||||
*/
|
||||
|
||||
const API_KEY = "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";
|
||||
const BASE_URL = "https://openrouter.ai/api/v1/chat/completions";
|
||||
|
||||
/**
|
||||
* 调用 AI API
|
||||
* @param {string} prompt - 用户提示
|
||||
* @param {string} systemMsg - 系统消息
|
||||
* @returns {Promise<string>} AI 响应内容
|
||||
*/
|
||||
const fetchAI = async (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 (error) {
|
||||
console.error('AI API Error:', error);
|
||||
return "(AI 暂时陷入了沉思,请稍后再试)";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 分析生命事件
|
||||
* @param {Object} event - 事件对象 { title, time, content }
|
||||
* @returns {Promise<string>} AI 分析反馈
|
||||
*/
|
||||
export const analyzeLifeEvent = async (event) => {
|
||||
const system = "你是一位温柔的生命引路人,擅长从平凡事件中发掘成长的力量。请分析用户记录的事件,提供情感价值、成长总结和疗愈鼓励。保持字数在150字左右。";
|
||||
const prompt = `事件标题:${event.title}\n时间:${event.time}\n内容:${event.content}`;
|
||||
return fetchAI(prompt, system);
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成爽文剧本
|
||||
* @param {Object} params - 参数对象 { theme, style, length, character }
|
||||
* @param {Array} events - 生命事件数组
|
||||
* @returns {Promise<string>} 生成的剧本内容
|
||||
*/
|
||||
export const generateEpicScript = async (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 fetchAI(prompt, system);
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成实现路径
|
||||
* @param {string} script - 剧本内容
|
||||
* @returns {Promise<string>} 生成的路径内容
|
||||
*/
|
||||
export const generatePath = async (script) => {
|
||||
const system = "你是一位人生规划导师。请将用户生成的剧本拆解为现实中可操作的路径。使用【阶段名称】加上具体建议。务必客观、可执行。";
|
||||
return fetchAI(script, system);
|
||||
};
|
||||
|
||||
export default {
|
||||
analyzeLifeEvent,
|
||||
generateEpicScript,
|
||||
generatePath
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* API 配置
|
||||
* 创建 axios 实例并配置拦截器
|
||||
*/
|
||||
|
||||
// API 基础地址
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
/**
|
||||
* 创建 axios 实例
|
||||
*/
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 请求拦截器
|
||||
* 自动添加 token 到请求头
|
||||
*/
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 响应拦截器
|
||||
* 统一处理响应和错误
|
||||
*/
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
const { data } = response;
|
||||
// 后端返回格式: { code, message, data }
|
||||
if (data.code === 200 || data.code === 0) {
|
||||
return data;
|
||||
}
|
||||
// 业务错误
|
||||
return Promise.reject(new Error(data.message || '请求失败'));
|
||||
},
|
||||
(error) => {
|
||||
// 网络错误或服务器错误
|
||||
if (error.response) {
|
||||
const { status, data } = error.response;
|
||||
if (status === 401) {
|
||||
// token 过期,清除登录状态
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.location.href = '/';
|
||||
}
|
||||
return Promise.reject(new Error(data?.message || `请求失败: ${status}`));
|
||||
}
|
||||
return Promise.reject(new Error(error.message || '网络错误'));
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,131 @@
|
||||
import api from './api';
|
||||
|
||||
/**
|
||||
* 认证服务
|
||||
* 处理登录、注册、验证码等认证相关接口
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取短信验证码
|
||||
* @param {string} phone - 手机号
|
||||
* @returns {Promise<Object>} 验证码响应
|
||||
*/
|
||||
export const getSmsCode = async (phone) => {
|
||||
const response = await api.get('/auth/sms-code', {
|
||||
params: { phone }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户登录(手机号 + 验证码)
|
||||
* @param {Object} params - 登录参数
|
||||
* @param {string} params.phone - 手机号
|
||||
* @param {string} params.smsCode - 短信验证码
|
||||
* @returns {Promise<Object>} 登录响应(包含 token)
|
||||
*/
|
||||
export const login = async ({ phone, smsCode }) => {
|
||||
const response = await api.post('/auth/login', {
|
||||
phone,
|
||||
smsCode
|
||||
});
|
||||
|
||||
// 保存 token
|
||||
if (response.data) {
|
||||
const { accessToken, refreshToken } = response.data;
|
||||
if (accessToken) {
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
}
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const logout = async () => {
|
||||
try {
|
||||
await api.post('/auth/logout');
|
||||
} finally {
|
||||
// 无论成功失败都清除本地 token
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 刷新 token
|
||||
* @returns {Promise<Object>} 新的 token
|
||||
*/
|
||||
export const refreshToken = async () => {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token');
|
||||
}
|
||||
|
||||
const response = await api.post('/auth/refreshToken', {
|
||||
refreshToken
|
||||
});
|
||||
|
||||
// 更新 token
|
||||
if (response.data) {
|
||||
const { accessToken, refreshToken: newRefreshToken } = response.data;
|
||||
if (accessToken) {
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
}
|
||||
if (newRefreshToken) {
|
||||
localStorage.setItem('refresh_token', newRefreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证 token 是否有效
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const validateToken = async () => {
|
||||
try {
|
||||
const response = await api.get('/auth/validateToken');
|
||||
return response.data === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
* @returns {Promise<Object>} 用户信息
|
||||
*/
|
||||
export const getCurrentUserInfo = async () => {
|
||||
const response = await api.get('/auth/userInfo');
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查手机号是否已注册
|
||||
* @param {string} phone - 手机号
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const checkPhone = async (phone) => {
|
||||
const response = await api.get('/auth/checkPhone', {
|
||||
params: { phone }
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export default {
|
||||
getSmsCode,
|
||||
login,
|
||||
logout,
|
||||
refreshToken,
|
||||
validateToken,
|
||||
getCurrentUserInfo,
|
||||
checkPhone
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
import api from './api';
|
||||
|
||||
/**
|
||||
* 爽文剧本服务
|
||||
* 处理剧本的增删改查
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取当前用户的所有剧本
|
||||
* @returns {Promise<Array>} 剧本列表
|
||||
*/
|
||||
export const getScriptList = async () => {
|
||||
const response = await api.get('/epicScript/listAll');
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 分页获取剧本
|
||||
* @param {Object} params - 分页参数
|
||||
* @param {number} params.pageNum - 页码
|
||||
* @param {number} params.pageSize - 每页数量
|
||||
* @returns {Promise<Object>} 分页结果
|
||||
*/
|
||||
export const getScriptPage = async ({ pageNum = 1, pageSize = 10 }) => {
|
||||
const response = await api.get('/epicScript/page', {
|
||||
params: { pageNum, pageSize }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据ID获取剧本详情
|
||||
* @param {string} id - 剧本ID
|
||||
* @returns {Promise<Object>} 剧本详情
|
||||
*/
|
||||
export const getScriptById = async (id) => {
|
||||
const response = await api.get('/epicScript/detail', {
|
||||
params: { id }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建剧本
|
||||
* @param {Object} scriptData - 剧本数据
|
||||
* @returns {Promise<Object>} 创建的剧本
|
||||
*/
|
||||
export const createScript = async (scriptData) => {
|
||||
const requestData = transformToBackendFormat(scriptData);
|
||||
const response = await api.post('/epicScript/create', requestData);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新剧本
|
||||
* @param {Object} scriptData - 剧本数据(必须包含 id)
|
||||
* @returns {Promise<Object>} 更新后的剧本
|
||||
*/
|
||||
export const updateScript = async (scriptData) => {
|
||||
const requestData = transformToBackendFormat(scriptData);
|
||||
const response = await api.put('/epicScript/update', requestData);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 选中剧本
|
||||
* @param {string} id - 剧本ID
|
||||
* @returns {Promise<Object>} 选中的剧本
|
||||
*/
|
||||
export const selectScript = async (id) => {
|
||||
const response = await api.put('/epicScript/select', null, {
|
||||
params: { id }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除剧本
|
||||
* @param {string} id - 剧本ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const deleteScript = async (id) => {
|
||||
const response = await api.delete('/epicScript/delete', {
|
||||
params: { id }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 将前端数据格式转换为后端格式
|
||||
* @param {Object} frontendData - 前端数据
|
||||
* @returns {Object} 后端格式数据
|
||||
*/
|
||||
const transformToBackendFormat = (frontendData) => {
|
||||
const {
|
||||
id,
|
||||
theme,
|
||||
style,
|
||||
length,
|
||||
content,
|
||||
isSelected
|
||||
} = frontendData;
|
||||
|
||||
// 解析内容生成标题和各部分
|
||||
let title = theme || '我的剧本';
|
||||
let plotIntro = '';
|
||||
let plotTurning = '';
|
||||
let plotClimax = '';
|
||||
let plotEnding = '';
|
||||
|
||||
if (content) {
|
||||
// 尝试从内容中提取各部分
|
||||
const sections = content.split(/【[^】]+】/);
|
||||
const titles = content.match(/【[^】]+】/g) || [];
|
||||
|
||||
titles.forEach((t, index) => {
|
||||
const sectionContent = sections[index + 1]?.trim() || '';
|
||||
if (t.includes('序幕') || t.includes('低谷')) {
|
||||
plotIntro = sectionContent;
|
||||
} else if (t.includes('转折') || t.includes('契机')) {
|
||||
plotTurning = sectionContent;
|
||||
} else if (t.includes('高潮') || t.includes('抉择')) {
|
||||
plotClimax = sectionContent;
|
||||
} else if (t.includes('结局') || t.includes('开始')) {
|
||||
plotEnding = sectionContent;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
theme,
|
||||
style,
|
||||
length,
|
||||
plotIntro,
|
||||
plotTurning,
|
||||
plotClimax,
|
||||
plotEnding,
|
||||
plotJson: content ? { fullContent: content } : null,
|
||||
isSelected
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 将后端数据格式转换为前端格式
|
||||
* @param {Object} backendData - 后端数据
|
||||
* @returns {Object} 前端格式数据
|
||||
*/
|
||||
export const transformToFrontendFormat = (backendData) => {
|
||||
if (!backendData) return null;
|
||||
|
||||
const {
|
||||
id,
|
||||
userId,
|
||||
title,
|
||||
theme,
|
||||
style,
|
||||
length,
|
||||
plotIntro,
|
||||
plotTurning,
|
||||
plotClimax,
|
||||
plotEnding,
|
||||
plotJson,
|
||||
isSelected,
|
||||
createTime
|
||||
} = backendData;
|
||||
|
||||
// 重建完整内容
|
||||
let content = '';
|
||||
if (plotJson?.fullContent) {
|
||||
content = plotJson.fullContent;
|
||||
} else {
|
||||
const parts = [];
|
||||
if (plotIntro) parts.push(`【序幕:低谷回响】\n${plotIntro}`);
|
||||
if (plotTurning) parts.push(`【转折:契机出现】\n${plotTurning}`);
|
||||
if (plotClimax) parts.push(`【高潮:命运抉择】\n${plotClimax}`);
|
||||
if (plotEnding) parts.push(`【结局:新的开始】\n${plotEnding}`);
|
||||
content = parts.join('\n\n');
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
userId,
|
||||
title: title || theme || '未命名剧本',
|
||||
theme: theme || '',
|
||||
style: style || '',
|
||||
length: length || 'medium',
|
||||
content,
|
||||
isSelected: isSelected || false,
|
||||
date: createTime ? new Date(createTime).toLocaleDateString() : new Date().toLocaleDateString()
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量转换后端数据为前端格式
|
||||
* @param {Array} backendList - 后端数据列表
|
||||
* @returns {Array} 前端格式数据列表
|
||||
*/
|
||||
export const transformListToFrontend = (backendList) => {
|
||||
if (!Array.isArray(backendList)) return [];
|
||||
return backendList.map(transformToFrontendFormat);
|
||||
};
|
||||
|
||||
export default {
|
||||
getScriptList,
|
||||
getScriptPage,
|
||||
getScriptById,
|
||||
createScript,
|
||||
updateScript,
|
||||
selectScript,
|
||||
deleteScript,
|
||||
transformToFrontendFormat,
|
||||
transformListToFrontend
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 服务层统一导出
|
||||
*/
|
||||
export { default as api } from './api';
|
||||
export { default as authService } from './auth';
|
||||
export { default as userProfileService } from './userProfile';
|
||||
export { default as lifeEventService } from './lifeEvent';
|
||||
export { default as epicScriptService } from './epicScript';
|
||||
export { default as lifePathService } from './lifePath';
|
||||
export { default as aiService } from './ai';
|
||||
|
||||
// 导出各服务的具体方法
|
||||
export * from './auth';
|
||||
export * from './userProfile';
|
||||
export * from './lifeEvent';
|
||||
export * from './epicScript';
|
||||
export * from './lifePath';
|
||||
export * from './ai';
|
||||
@@ -0,0 +1,164 @@
|
||||
import api from './api';
|
||||
|
||||
/**
|
||||
* 生命事件服务
|
||||
* 处理生命事件的增删改查
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取当前用户的所有生命事件
|
||||
* @returns {Promise<Array>} 生命事件列表
|
||||
*/
|
||||
export const getEventList = async () => {
|
||||
const response = await api.get('/lifeEvent/list');
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 分页获取生命事件
|
||||
* @param {Object} params - 分页参数
|
||||
* @param {number} params.pageNum - 页码
|
||||
* @param {number} params.pageSize - 每页数量
|
||||
* @returns {Promise<Object>} 分页结果
|
||||
*/
|
||||
export const getEventPage = async ({ pageNum = 1, pageSize = 10 }) => {
|
||||
const response = await api.get('/lifeEvent/page', {
|
||||
params: { pageNum, pageSize }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据ID获取生命事件详情
|
||||
* @param {string} id - 事件ID
|
||||
* @returns {Promise<Object>} 事件详情
|
||||
*/
|
||||
export const getEventById = async (id) => {
|
||||
const response = await api.get('/lifeEvent/detail', {
|
||||
params: { id }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建生命事件
|
||||
* @param {Object} eventData - 事件数据
|
||||
* @returns {Promise<Object>} 创建的事件
|
||||
*/
|
||||
export const createEvent = async (eventData) => {
|
||||
const requestData = transformToBackendFormat(eventData);
|
||||
const response = await api.post('/lifeEvent/create', requestData);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新生命事件
|
||||
* @param {Object} eventData - 事件数据(必须包含 id)
|
||||
* @returns {Promise<Object>} 更新后的事件
|
||||
*/
|
||||
export const updateEvent = async (eventData) => {
|
||||
const requestData = transformToBackendFormat(eventData);
|
||||
const response = await api.put('/lifeEvent/update', requestData);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除生命事件
|
||||
* @param {string} id - 事件ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const deleteEvent = async (id) => {
|
||||
const response = await api.delete('/lifeEvent/delete', {
|
||||
params: { id }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 将前端数据格式转换为后端格式
|
||||
* @param {Object} frontendData - 前端数据
|
||||
* @returns {Object} 后端格式数据
|
||||
*/
|
||||
const transformToBackendFormat = (frontendData) => {
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
time,
|
||||
content,
|
||||
aiFeedback,
|
||||
eventType = 'daily_log',
|
||||
emotionType,
|
||||
emotionScore,
|
||||
tags
|
||||
} = frontendData;
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
eventDate: time,
|
||||
content,
|
||||
aiReply: aiFeedback,
|
||||
eventType,
|
||||
emotionType,
|
||||
emotionScore,
|
||||
tags
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 将后端数据格式转换为前端格式
|
||||
* @param {Object} backendData - 后端数据
|
||||
* @returns {Object} 前端格式数据
|
||||
*/
|
||||
export const transformToFrontendFormat = (backendData) => {
|
||||
if (!backendData) return null;
|
||||
|
||||
const {
|
||||
id,
|
||||
userId,
|
||||
title,
|
||||
eventDate,
|
||||
content,
|
||||
aiReply,
|
||||
eventType,
|
||||
emotionType,
|
||||
emotionScore,
|
||||
tags,
|
||||
createTime
|
||||
} = backendData;
|
||||
|
||||
return {
|
||||
id,
|
||||
userId,
|
||||
title: title || '',
|
||||
time: eventDate || '',
|
||||
content: content || '',
|
||||
aiFeedback: aiReply || '',
|
||||
eventType: eventType || 'daily_log',
|
||||
emotionType,
|
||||
emotionScore,
|
||||
tags: tags || [],
|
||||
createTime
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量转换后端数据为前端格式
|
||||
* @param {Array} backendList - 后端数据列表
|
||||
* @returns {Array} 前端格式数据列表
|
||||
*/
|
||||
export const transformListToFrontend = (backendList) => {
|
||||
if (!Array.isArray(backendList)) return [];
|
||||
return backendList.map(transformToFrontendFormat);
|
||||
};
|
||||
|
||||
export default {
|
||||
getEventList,
|
||||
getEventPage,
|
||||
getEventById,
|
||||
createEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
transformToFrontendFormat,
|
||||
transformListToFrontend
|
||||
};
|
||||
@@ -0,0 +1,211 @@
|
||||
import api from './api';
|
||||
|
||||
/**
|
||||
* 实现路径服务
|
||||
* 处理路径的增删改查
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取当前用户的所有路径
|
||||
* @returns {Promise<Array>} 路径列表
|
||||
*/
|
||||
export const getPathList = async () => {
|
||||
const response = await api.get('/lifePath/listAll');
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 分页获取路径
|
||||
* @param {Object} params - 分页参数
|
||||
* @param {number} params.pageNum - 页码
|
||||
* @param {number} params.pageSize - 每页数量
|
||||
* @returns {Promise<Object>} 分页结果
|
||||
*/
|
||||
export const getPathPage = async ({ pageNum = 1, pageSize = 10 }) => {
|
||||
const response = await api.get('/lifePath/page', {
|
||||
params: { pageNum, pageSize }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据剧本ID获取路径
|
||||
* @param {string} scriptId - 剧本ID
|
||||
* @returns {Promise<Object>} 路径详情
|
||||
*/
|
||||
export const getPathByScriptId = async (scriptId) => {
|
||||
const response = await api.get('/lifePath/byScript', {
|
||||
params: { scriptId }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据ID获取路径详情
|
||||
* @param {string} id - 路径ID
|
||||
* @returns {Promise<Object>} 路径详情
|
||||
*/
|
||||
export const getPathById = async (id) => {
|
||||
const response = await api.get('/lifePath/detail', {
|
||||
params: { id }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建路径
|
||||
* @param {Object} pathData - 路径数据
|
||||
* @returns {Promise<Object>} 创建的路径
|
||||
*/
|
||||
export const createPath = async (pathData) => {
|
||||
const requestData = transformToBackendFormat(pathData);
|
||||
const response = await api.post('/lifePath/create', requestData);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新路径
|
||||
* @param {Object} pathData - 路径数据(必须包含 id)
|
||||
* @returns {Promise<Object>} 更新后的路径
|
||||
*/
|
||||
export const updatePath = async (pathData) => {
|
||||
const requestData = transformToBackendFormat(pathData);
|
||||
const response = await api.put('/lifePath/update', requestData);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除路径
|
||||
* @param {string} id - 路径ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const deletePath = async (id) => {
|
||||
const response = await api.delete('/lifePath/delete', {
|
||||
params: { id }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 将前端数据格式转换为后端格式
|
||||
* @param {Object} frontendData - 前端数据
|
||||
* @returns {Object} 后端格式数据
|
||||
*/
|
||||
const transformToBackendFormat = (frontendData) => {
|
||||
const {
|
||||
id,
|
||||
scriptId,
|
||||
title,
|
||||
description,
|
||||
content,
|
||||
status = 'active',
|
||||
progress = 0
|
||||
} = frontendData;
|
||||
|
||||
// 解析内容为步骤列表
|
||||
let steps = [];
|
||||
if (content) {
|
||||
// 尝试解析文本内容为步骤
|
||||
const stepMatches = content.match(/(\d+)\.\s*([^::]+)[::]\s*([^\n]+)/g);
|
||||
if (stepMatches) {
|
||||
steps = stepMatches.map((match, index) => {
|
||||
const parts = match.match(/(\d+)\.\s*([^::]+)[::]\s*(.+)/);
|
||||
return {
|
||||
phase: `阶段${index + 1}`,
|
||||
time: parts?.[2]?.trim() || '',
|
||||
content: parts?.[3]?.trim() || match,
|
||||
action: '',
|
||||
resources: '',
|
||||
habit: ''
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// 按换行分割
|
||||
const lines = content.split('\n').filter(line => line.trim());
|
||||
steps = lines.map((line, index) => ({
|
||||
phase: `阶段${index + 1}`,
|
||||
time: '',
|
||||
content: line.trim(),
|
||||
action: '',
|
||||
resources: '',
|
||||
habit: ''
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
scriptId,
|
||||
title: title || '实现路径',
|
||||
description,
|
||||
steps,
|
||||
status,
|
||||
progress
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 将后端数据格式转换为前端格式
|
||||
* @param {Object} backendData - 后端数据
|
||||
* @returns {Object} 前端格式数据
|
||||
*/
|
||||
export const transformToFrontendFormat = (backendData) => {
|
||||
if (!backendData) return null;
|
||||
|
||||
const {
|
||||
id,
|
||||
userId,
|
||||
scriptId,
|
||||
title,
|
||||
description,
|
||||
steps,
|
||||
status,
|
||||
progress,
|
||||
createTime
|
||||
} = backendData;
|
||||
|
||||
// 将步骤列表转换为文本内容
|
||||
let content = '';
|
||||
if (Array.isArray(steps) && steps.length > 0) {
|
||||
content = steps.map((step, index) => {
|
||||
const phase = step.phase || `阶段${index + 1}`;
|
||||
const time = step.time ? `(${step.time})` : '';
|
||||
return `${index + 1}. ${phase}${time}:${step.content || ''}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
userId,
|
||||
scriptId,
|
||||
title: title || '实现路径',
|
||||
description: description || '',
|
||||
content,
|
||||
steps: steps || [],
|
||||
status: status || 'active',
|
||||
progress: progress || 0,
|
||||
createTime
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量转换后端数据为前端格式
|
||||
* @param {Array} backendList - 后端数据列表
|
||||
* @returns {Array} 前端格式数据列表
|
||||
*/
|
||||
export const transformListToFrontend = (backendList) => {
|
||||
if (!Array.isArray(backendList)) return [];
|
||||
return backendList.map(transformToFrontendFormat);
|
||||
};
|
||||
|
||||
export default {
|
||||
getPathList,
|
||||
getPathPage,
|
||||
getPathByScriptId,
|
||||
getPathById,
|
||||
createPath,
|
||||
updatePath,
|
||||
deletePath,
|
||||
transformToFrontendFormat,
|
||||
transformListToFrontend
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
import api from './api';
|
||||
|
||||
/**
|
||||
* 用户档案服务
|
||||
* 处理用户档案的增删改查
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取当前用户档案
|
||||
* @returns {Promise<Object|null>} 用户档案
|
||||
*/
|
||||
export const getCurrentProfile = async () => {
|
||||
const response = await api.get('/user-profile/me');
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建用户档案
|
||||
* @param {Object} profileData - 档案数据
|
||||
* @returns {Promise<Object>} 创建的档案
|
||||
*/
|
||||
export const createProfile = async (profileData) => {
|
||||
// 转换前端数据格式为后端格式
|
||||
const requestData = transformToBackendFormat(profileData);
|
||||
const response = await api.post('/user-profile/create', requestData);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新用户档案
|
||||
* @param {Object} profileData - 档案数据(必须包含 id)
|
||||
* @returns {Promise<Object>} 更新后的档案
|
||||
*/
|
||||
export const updateProfile = async (profileData) => {
|
||||
const requestData = transformToBackendFormat(profileData);
|
||||
const response = await api.put('/user-profile/update', requestData);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除用户档案
|
||||
* @param {string} id - 档案ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const deleteProfile = async (id) => {
|
||||
const response = await api.delete('/user-profile/delete', {
|
||||
params: { id }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据ID获取档案详情
|
||||
* @param {string} id - 档案ID
|
||||
* @returns {Promise<Object>} 档案详情
|
||||
*/
|
||||
export const getProfileById = async (id) => {
|
||||
const response = await api.get('/user-profile/detail', {
|
||||
params: { id }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 将前端数据格式转换为后端格式
|
||||
* @param {Object} frontendData - 前端数据
|
||||
* @returns {Object} 后端格式数据
|
||||
*/
|
||||
const transformToBackendFormat = (frontendData) => {
|
||||
const {
|
||||
id,
|
||||
nickname,
|
||||
gender,
|
||||
zodiac,
|
||||
mbti,
|
||||
hobbies,
|
||||
childhood,
|
||||
joy,
|
||||
low,
|
||||
future
|
||||
} = frontendData;
|
||||
|
||||
return {
|
||||
id,
|
||||
nickname,
|
||||
gender,
|
||||
zodiac,
|
||||
mbti,
|
||||
// 兴趣爱好转为 JSON 字符串
|
||||
hobbies: Array.isArray(hobbies) ? JSON.stringify(hobbies) : hobbies,
|
||||
// 童年经历
|
||||
childhoodDate: childhood?.date || null,
|
||||
childhoodContent: childhood?.text || null,
|
||||
// 高光时刻(对应前端的 joy)
|
||||
peakDate: joy?.date || null,
|
||||
peakContent: joy?.text || null,
|
||||
// 低谷时期(对应前端的 low)
|
||||
valleyDate: low?.date || null,
|
||||
valleyContent: low?.text || null,
|
||||
// 未来期许
|
||||
futureVision: future?.vision || null,
|
||||
// 理想生活状态
|
||||
idealLife: future?.ideal || null
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 将后端数据格式转换为前端格式
|
||||
* @param {Object} backendData - 后端数据
|
||||
* @returns {Object} 前端格式数据
|
||||
*/
|
||||
export const transformToFrontendFormat = (backendData) => {
|
||||
if (!backendData) return null;
|
||||
|
||||
const {
|
||||
id,
|
||||
userId,
|
||||
nickname,
|
||||
gender,
|
||||
zodiac,
|
||||
mbti,
|
||||
hobbies,
|
||||
childhoodDate,
|
||||
childhoodContent,
|
||||
peakDate,
|
||||
peakContent,
|
||||
valleyDate,
|
||||
valleyContent,
|
||||
futureVision,
|
||||
idealLife
|
||||
} = backendData;
|
||||
|
||||
return {
|
||||
id,
|
||||
userId,
|
||||
nickname: nickname || '',
|
||||
gender: gender || '',
|
||||
zodiac: zodiac || '',
|
||||
mbti: mbti || '',
|
||||
// 兴趣爱好从 JSON 字符串解析
|
||||
hobbies: hobbies ? (typeof hobbies === 'string' ? JSON.parse(hobbies) : hobbies) : [],
|
||||
// 童年经历
|
||||
childhood: {
|
||||
date: childhoodDate || '',
|
||||
text: childhoodContent || ''
|
||||
},
|
||||
// 高光时刻
|
||||
joy: {
|
||||
date: peakDate || '',
|
||||
text: peakContent || ''
|
||||
},
|
||||
// 低谷时期
|
||||
low: {
|
||||
date: valleyDate || '',
|
||||
text: valleyContent || ''
|
||||
},
|
||||
// 未来期许
|
||||
future: {
|
||||
vision: futureVision || '',
|
||||
ideal: idealLife || ''
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
getCurrentProfile,
|
||||
createProfile,
|
||||
updateProfile,
|
||||
deleteProfile,
|
||||
getProfileById,
|
||||
transformToFrontendFormat
|
||||
};
|
||||
@@ -0,0 +1,489 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import * as authService from '../services/auth';
|
||||
import * as userProfileService from '../services/userProfile';
|
||||
import * as lifeEventService from '../services/lifeEvent';
|
||||
import * as epicScriptService from '../services/epicScript';
|
||||
import * as lifePathService from '../services/lifePath';
|
||||
|
||||
/**
|
||||
* 默认注册数据
|
||||
*/
|
||||
const defaultRegistrationData = {
|
||||
id: null,
|
||||
nickname: '',
|
||||
gender: '',
|
||||
zodiac: '',
|
||||
mbti: '',
|
||||
profession: '',
|
||||
hobbies: [],
|
||||
childhood: { date: '', text: '' },
|
||||
joy: { date: '', text: '' },
|
||||
low: { date: '', text: '' },
|
||||
future: { vision: '', ideal: '' }
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用状态存储
|
||||
* 使用 Zustand 进行状态管理,支持 localStorage 持久化
|
||||
*/
|
||||
const useStore = create(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// 认证状态
|
||||
isLoggedIn: false,
|
||||
phone: '',
|
||||
userId: null,
|
||||
|
||||
// 视图状态
|
||||
view: 'login',
|
||||
currentStep: 1,
|
||||
|
||||
// 用户注册数据
|
||||
registrationData: { ...defaultRegistrationData },
|
||||
|
||||
// 生命事件
|
||||
lifeEvents: [],
|
||||
|
||||
// 剧本
|
||||
scripts: [],
|
||||
selectedScriptId: null,
|
||||
|
||||
// 路径
|
||||
selectedPath: null,
|
||||
|
||||
// 加载状态
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
/**
|
||||
* 设置加载状态
|
||||
*/
|
||||
setLoading: (loading) => set({ loading }),
|
||||
|
||||
/**
|
||||
* 设置错误信息
|
||||
*/
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
/**
|
||||
* 获取短信验证码
|
||||
* @param {string} phone - 手机号
|
||||
*/
|
||||
getSmsCode: async (phone) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await authService.getSmsCode(phone);
|
||||
set({ loading: false });
|
||||
return response;
|
||||
} catch (error) {
|
||||
set({ loading: false, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 登录
|
||||
* @param {string} phone - 手机号
|
||||
* @param {string} smsCode - 验证码
|
||||
*/
|
||||
login: async (phone, smsCode) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await authService.login({ phone, smsCode });
|
||||
const { userId } = response.data || {};
|
||||
|
||||
set({
|
||||
isLoggedIn: true,
|
||||
phone,
|
||||
userId,
|
||||
view: 'onboarding',
|
||||
loading: false
|
||||
});
|
||||
|
||||
// 尝试加载用户档案
|
||||
try {
|
||||
await get().loadUserProfile();
|
||||
} catch {
|
||||
// 档案不存在,继续入站流程
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
set({ loading: false, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置登录状态(本地模式)
|
||||
*/
|
||||
setLogin: (isLoggedIn, phone = '') => set({
|
||||
isLoggedIn,
|
||||
phone,
|
||||
view: isLoggedIn ? 'onboarding' : 'login'
|
||||
}),
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
logout: async () => {
|
||||
try {
|
||||
await authService.logout();
|
||||
} catch {
|
||||
// 忽略登出错误
|
||||
}
|
||||
set({
|
||||
isLoggedIn: false,
|
||||
phone: '',
|
||||
userId: null,
|
||||
view: 'login',
|
||||
currentStep: 1,
|
||||
registrationData: { ...defaultRegistrationData },
|
||||
lifeEvents: [],
|
||||
scripts: [],
|
||||
selectedScriptId: null,
|
||||
selectedPath: null
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置当前视图
|
||||
*/
|
||||
setView: (view) => set({ view }),
|
||||
|
||||
/**
|
||||
* 设置当前步骤
|
||||
*/
|
||||
setCurrentStep: (step) => set({ currentStep: step }),
|
||||
|
||||
/**
|
||||
* 加载用户档案
|
||||
*/
|
||||
loadUserProfile: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const response = await userProfileService.getCurrentProfile();
|
||||
if (response.data) {
|
||||
const profileData = userProfileService.transformToFrontendFormat(response.data);
|
||||
set({
|
||||
registrationData: { ...defaultRegistrationData, ...profileData },
|
||||
loading: false
|
||||
});
|
||||
return profileData;
|
||||
}
|
||||
set({ loading: false });
|
||||
return null;
|
||||
} catch (error) {
|
||||
set({ loading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新注册数据(本地)
|
||||
*/
|
||||
updateRegistration: (data) => set((state) => ({
|
||||
registrationData: { ...state.registrationData, ...data }
|
||||
})),
|
||||
|
||||
/**
|
||||
* 保存用户档案到后端
|
||||
*/
|
||||
saveUserProfile: async () => {
|
||||
const { registrationData } = get();
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
let response;
|
||||
if (registrationData.id) {
|
||||
// 更新
|
||||
response = await userProfileService.updateProfile(registrationData);
|
||||
} else {
|
||||
// 创建
|
||||
response = await userProfileService.createProfile(registrationData);
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
const profileData = userProfileService.transformToFrontendFormat(response.data);
|
||||
set({
|
||||
registrationData: { ...registrationData, ...profileData },
|
||||
loading: false
|
||||
});
|
||||
} else {
|
||||
set({ loading: false });
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
set({ loading: false, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载生命事件列表
|
||||
*/
|
||||
loadLifeEvents: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const response = await lifeEventService.getEventList();
|
||||
const events = lifeEventService.transformListToFrontend(response.data || []);
|
||||
set({ lifeEvents: events, loading: false });
|
||||
return events;
|
||||
} catch (error) {
|
||||
set({ loading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加生命事件
|
||||
*/
|
||||
addLifeEvent: async (event) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await lifeEventService.createEvent(event);
|
||||
if (response.data) {
|
||||
const newEvent = lifeEventService.transformToFrontendFormat(response.data);
|
||||
set((state) => ({
|
||||
lifeEvents: [...state.lifeEvents, newEvent],
|
||||
loading: false
|
||||
}));
|
||||
return newEvent;
|
||||
}
|
||||
set({ loading: false });
|
||||
return null;
|
||||
} catch (error) {
|
||||
set({ loading: false, error: error.message });
|
||||
// 降级到本地存储
|
||||
const localEvent = { ...event, id: Date.now().toString() };
|
||||
set((state) => ({
|
||||
lifeEvents: [...state.lifeEvents, localEvent]
|
||||
}));
|
||||
return localEvent;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除生命事件
|
||||
*/
|
||||
deleteLifeEvent: async (id) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await lifeEventService.deleteEvent(id);
|
||||
set((state) => ({
|
||||
lifeEvents: state.lifeEvents.filter(e => e.id !== id),
|
||||
loading: false
|
||||
}));
|
||||
} catch (error) {
|
||||
set({ loading: false });
|
||||
// 降级到本地删除
|
||||
set((state) => ({
|
||||
lifeEvents: state.lifeEvents.filter(e => e.id !== id)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载剧本列表
|
||||
*/
|
||||
loadScripts: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const response = await epicScriptService.getScriptList();
|
||||
const scripts = epicScriptService.transformListToFrontend(response.data || []);
|
||||
// 找到选中的剧本
|
||||
const selectedScript = scripts.find(s => s.isSelected);
|
||||
set({
|
||||
scripts,
|
||||
selectedScriptId: selectedScript?.id || scripts[0]?.id || null,
|
||||
loading: false
|
||||
});
|
||||
return scripts;
|
||||
} catch (error) {
|
||||
set({ loading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加剧本
|
||||
*/
|
||||
addScript: async (script) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await epicScriptService.createScript({
|
||||
...script,
|
||||
isSelected: true
|
||||
});
|
||||
if (response.data) {
|
||||
const newScript = epicScriptService.transformToFrontendFormat(response.data);
|
||||
set((state) => ({
|
||||
scripts: [newScript, ...state.scripts],
|
||||
selectedScriptId: newScript.id,
|
||||
loading: false
|
||||
}));
|
||||
return newScript;
|
||||
}
|
||||
set({ loading: false });
|
||||
return null;
|
||||
} catch (error) {
|
||||
set({ loading: false, error: error.message });
|
||||
// 降级到本地存储
|
||||
const localScript = {
|
||||
...script,
|
||||
id: Date.now().toString(),
|
||||
date: new Date().toLocaleDateString()
|
||||
};
|
||||
set((state) => ({
|
||||
scripts: [localScript, ...state.scripts],
|
||||
selectedScriptId: localScript.id
|
||||
}));
|
||||
return localScript;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置选中的剧本ID
|
||||
*/
|
||||
setSelectedScriptId: async (id) => {
|
||||
set({ selectedScriptId: id });
|
||||
try {
|
||||
await epicScriptService.selectScript(id);
|
||||
} catch {
|
||||
// 忽略错误,本地已更新
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取选中的剧本
|
||||
*/
|
||||
getSelectedScript: () => {
|
||||
const state = get();
|
||||
return state.scripts.find(s => s.id === state.selectedScriptId);
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除剧本
|
||||
*/
|
||||
deleteScript: async (id) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await epicScriptService.deleteScript(id);
|
||||
set((state) => {
|
||||
const newScripts = state.scripts.filter(s => s.id !== id);
|
||||
return {
|
||||
scripts: newScripts,
|
||||
selectedScriptId: state.selectedScriptId === id
|
||||
? (newScripts[0]?.id || null)
|
||||
: state.selectedScriptId,
|
||||
loading: false
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
set({ loading: false });
|
||||
// 降级到本地删除
|
||||
set((state) => {
|
||||
const newScripts = state.scripts.filter(s => s.id !== id);
|
||||
return {
|
||||
scripts: newScripts,
|
||||
selectedScriptId: state.selectedScriptId === id
|
||||
? (newScripts[0]?.id || null)
|
||||
: state.selectedScriptId
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载路径
|
||||
*/
|
||||
loadPath: async (scriptId) => {
|
||||
if (!scriptId) return null;
|
||||
set({ loading: true });
|
||||
try {
|
||||
const response = await lifePathService.getPathByScriptId(scriptId);
|
||||
if (response.data) {
|
||||
const path = lifePathService.transformToFrontendFormat(response.data);
|
||||
set({ selectedPath: path.content, loading: false });
|
||||
return path;
|
||||
}
|
||||
set({ loading: false });
|
||||
return null;
|
||||
} catch {
|
||||
set({ loading: false });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置路径
|
||||
*/
|
||||
setPath: async (pathContent, scriptId) => {
|
||||
set({ selectedPath: pathContent });
|
||||
|
||||
if (scriptId) {
|
||||
try {
|
||||
// 检查是否已有路径
|
||||
const existingPath = await lifePathService.getPathByScriptId(scriptId).catch(() => null);
|
||||
|
||||
if (existingPath?.data?.id) {
|
||||
// 更新
|
||||
await lifePathService.updatePath({
|
||||
id: existingPath.data.id,
|
||||
scriptId,
|
||||
content: pathContent
|
||||
});
|
||||
} else {
|
||||
// 创建
|
||||
await lifePathService.createPath({
|
||||
scriptId,
|
||||
content: pathContent
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误,本地已更新
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除所有数据
|
||||
*/
|
||||
clear: async () => {
|
||||
try {
|
||||
await authService.logout();
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
localStorage.removeItem('life_trajectory_v3');
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
set({
|
||||
isLoggedIn: false,
|
||||
phone: '',
|
||||
userId: null,
|
||||
view: 'login',
|
||||
currentStep: 1,
|
||||
registrationData: { ...defaultRegistrationData },
|
||||
lifeEvents: [],
|
||||
scripts: [],
|
||||
selectedScriptId: null,
|
||||
selectedPath: null,
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
window.location.reload();
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 'life_trajectory_v3',
|
||||
onRehydrateStorage: () => (state, error) => {
|
||||
if (error) {
|
||||
console.error('Failed to load state from localStorage:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export default useStore;
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 常量定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 灵感标签集合
|
||||
*/
|
||||
export const inspirationClusters = {
|
||||
childhood: ['秋千', '晚霞', '糖果', '奔跑', '蝉鸣', '雨后泥土', '旧书包', '风筝'],
|
||||
joy: ['海浪', '拥抱', '掌声', '晨曦', '破土而出', '默契', '星空', '释放'],
|
||||
low: ['落叶', '雨伞', '长廊', '深呼吸', '自愈', '沉潜', '坚韧', '等待', '破茧']
|
||||
};
|
||||
|
||||
/**
|
||||
* 剧本风格选项
|
||||
*/
|
||||
export const scriptStyles = [
|
||||
{ value: '都市', label: '都市沉浮' },
|
||||
{ value: '古风', label: '快意恩仇' },
|
||||
{ value: '爱情', label: '唯美浪漫' },
|
||||
{ value: '科幻', label: '星际远征' },
|
||||
{ value: '喜剧', label: '荒诞不经' },
|
||||
{ value: '悬疑', label: '迷雾重重' },
|
||||
{ value: '恐怖', label: '午夜回响' }
|
||||
];
|
||||
|
||||
/**
|
||||
* 剧本篇幅选项
|
||||
*/
|
||||
export const scriptLengths = [
|
||||
{ value: '短', label: '极简' },
|
||||
{ value: '中', label: '连载' },
|
||||
{ value: '长', label: '史诗' }
|
||||
];
|
||||
|
||||
/**
|
||||
* 入站步骤配置
|
||||
*/
|
||||
export const onboardingSteps = [
|
||||
{ id: 1, title: '你是谁?', subtitle: '定义你生命坐标的初始属性。' },
|
||||
{ id: 2, title: '那段纯真的时光', subtitle: '回望足迹,这些瞬间如何塑造了此时的你。', type: 'childhood' },
|
||||
{ id: 3, title: '光芒闪耀的时刻', subtitle: '回望足迹,这些瞬间如何塑造了此时的你。', type: 'joy' },
|
||||
{ id: 4, title: '在暗夜中潜行', subtitle: '回望足迹,这些瞬间如何塑造了此时的你。', type: 'low' },
|
||||
{ id: 5, title: '未来想成为谁?', subtitle: '勾勒你对理想生活的全部向往。' }
|
||||
];
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Map, Loader2 } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { GlassCard, GlassButton } from '../components/ui';
|
||||
import useStore from '../store/useStore';
|
||||
import { generatePath } from '../services/ai';
|
||||
|
||||
/**
|
||||
* PathView 组件
|
||||
* 实现路径视图,基于剧本生成可执行的人生路径
|
||||
* @param {Object} props
|
||||
* @param {Function} props.onGoToScript - 跳转到剧本视图回调
|
||||
*/
|
||||
const PathView = ({ onGoToScript }) => {
|
||||
const { getSelectedScript, selectedPath, setPath, loadPath, selectedScriptId } = useStore();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const selectedScript = getSelectedScript();
|
||||
|
||||
// 加载已有路径
|
||||
useEffect(() => {
|
||||
if (selectedScriptId) {
|
||||
loadPath(selectedScriptId).catch(() => {
|
||||
// 后端不可用时忽略错误
|
||||
});
|
||||
}
|
||||
}, [selectedScriptId, loadPath]);
|
||||
|
||||
/**
|
||||
* 处理路径生成
|
||||
*/
|
||||
const handleGenerate = async () => {
|
||||
if (!selectedScript) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const path = await generatePath(selectedScript.content);
|
||||
await setPath(path, selectedScriptId);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate path:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析路径内容为步骤数组
|
||||
*/
|
||||
const parsePathSteps = (path) => {
|
||||
if (!path) return [];
|
||||
|
||||
return path
|
||||
.split(/【/)
|
||||
.filter(s => s.trim())
|
||||
.map((s, index) => {
|
||||
const parts = s.split(/】/);
|
||||
return {
|
||||
title: parts[0] || '',
|
||||
content: parts[1] || '',
|
||||
index: index + 1
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const pathSteps = parsePathSteps(selectedPath);
|
||||
|
||||
// 无剧本时显示提示
|
||||
if (!selectedScript) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-32 opacity-30 text-center">
|
||||
<Map className="w-16 h-16 mb-4" />
|
||||
<p className="font-serif italic text-xl">先生成剧本,方能洞察路径。</p>
|
||||
<GlassButton
|
||||
onClick={onGoToScript}
|
||||
className="mt-6 px-6 py-2 rounded-full text-xs"
|
||||
>
|
||||
去生成剧本
|
||||
</GlassButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-12 pb-20">
|
||||
{/* 标题区域 */}
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h3 className="text-4xl font-serif">实现路径</h3>
|
||||
<p className="text-sm text-white/30 mt-2">
|
||||
基于《{selectedScript.theme}》,拆解达成目标的每一步。
|
||||
</p>
|
||||
</div>
|
||||
<GlassButton
|
||||
onClick={handleGenerate}
|
||||
disabled={isLoading}
|
||||
className="px-8 py-3 rounded-full text-sm font-bold bg-blue-400/5 text-blue-300 border-blue-400/20"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
规划中...
|
||||
</>
|
||||
) : (
|
||||
selectedPath ? '重新推演' : '开启人生导航'
|
||||
)}
|
||||
</GlassButton>
|
||||
</div>
|
||||
|
||||
{/* 路径步骤展示 */}
|
||||
<div className="space-y-6">
|
||||
{pathSteps.length > 0 ? (
|
||||
pathSteps.map((step, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1, duration: 0.5 }}
|
||||
>
|
||||
<GlassCard className="border-l-4 border-l-blue-400/40 bg-blue-400/[0.01]" padding="lg">
|
||||
<h5 className="text-blue-200 font-bold mb-4 flex items-center gap-3">
|
||||
<span className="w-6 h-6 rounded-full bg-blue-400/20 text-[10px] flex items-center justify-center">
|
||||
{step.index}
|
||||
</span>
|
||||
{step.title}
|
||||
</h5>
|
||||
<div className="text-white/60 text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{step.content}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</motion.div>
|
||||
))
|
||||
) : (
|
||||
<div className="py-20 text-center text-white/20 italic font-serif">
|
||||
等待开启人生导航...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PathView;
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Settings, Settings2 } from 'lucide-react';
|
||||
import Modal from '../components/Modal';
|
||||
import { GlassButton, GlassInput } from '../components/ui';
|
||||
import useStore from '../store/useStore';
|
||||
|
||||
/**
|
||||
* ProfileModal 组件
|
||||
* 用户资料模态框,支持查看和编辑模式
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.isOpen - 是否打开
|
||||
* @param {Function} props.onClose - 关闭回调
|
||||
*/
|
||||
const ProfileModal = ({ isOpen, onClose }) => {
|
||||
const { registrationData, lifeEvents, scripts, updateRegistration, saveUserProfile, clear } = useStore();
|
||||
|
||||
// 编辑模式状态
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 编辑表单状态
|
||||
const [editForm, setEditForm] = useState({
|
||||
nickname: registrationData.nickname,
|
||||
profession: registrationData.profession || '',
|
||||
mbti: registrationData.mbti,
|
||||
zodiac: registrationData.zodiac,
|
||||
hobbies: registrationData.hobbies?.join(', ') || ''
|
||||
});
|
||||
|
||||
// 同步 registrationData 到 editForm
|
||||
useEffect(() => {
|
||||
setEditForm({
|
||||
nickname: registrationData.nickname,
|
||||
profession: registrationData.profession || '',
|
||||
mbti: registrationData.mbti,
|
||||
zodiac: registrationData.zodiac,
|
||||
hobbies: registrationData.hobbies?.join(', ') || ''
|
||||
});
|
||||
}, [registrationData]);
|
||||
|
||||
/**
|
||||
* 处理保存
|
||||
*/
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
updateRegistration({
|
||||
nickname: editForm.nickname,
|
||||
profession: editForm.profession,
|
||||
mbti: editForm.mbti,
|
||||
zodiac: editForm.zodiac,
|
||||
hobbies: editForm.hobbies.split(',').map(s => s.trim()).filter(s => s)
|
||||
});
|
||||
await saveUserProfile();
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
// 即使后端保存失败,本地已更新
|
||||
setIsEditing(false);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理取消编辑
|
||||
*/
|
||||
const handleCancel = () => {
|
||||
setEditForm({
|
||||
nickname: registrationData.nickname,
|
||||
profession: registrationData.profession || '',
|
||||
mbti: registrationData.mbti,
|
||||
zodiac: registrationData.zodiac,
|
||||
hobbies: registrationData.hobbies?.join(', ') || ''
|
||||
});
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理清除数据
|
||||
*/
|
||||
const handleClear = () => {
|
||||
if (confirm('确定要删除所有记录吗?此操作不可逆。')) {
|
||||
clear();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染查看模式
|
||||
*/
|
||||
const renderViewMode = () => (
|
||||
<div className="animate-fade-in space-y-8">
|
||||
{/* 用户头像和基本信息 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-orange-400/20 to-orange-600/20 flex items-center justify-center text-3xl border border-white/10">
|
||||
{(registrationData.nickname || '人').charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-2xl font-serif text-white/90">
|
||||
{registrationData.nickname || '旅行者'}
|
||||
</h4>
|
||||
<p className="text-[10px] text-white/30 uppercase tracking-[0.2em] mt-1">
|
||||
{registrationData.mbti || '-'} | {registrationData.zodiac || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计数据 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-white/[0.02] rounded-2xl border border-white/5 text-center">
|
||||
<div className="text-lg font-serif text-orange-200">{lifeEvents.length}</div>
|
||||
<div className="text-[9px] text-white/30 uppercase tracking-widest mt-1">生命足迹</div>
|
||||
</div>
|
||||
<div className="p-4 bg-white/[0.02] rounded-2xl border border-white/5 text-center">
|
||||
<div className="text-lg font-serif text-blue-200">{scripts.length}</div>
|
||||
<div className="text-[9px] text-white/30 uppercase tracking-widest mt-1">天命卷轴</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="space-y-4 pt-4 border-t border-white/5">
|
||||
<GlassButton
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="w-full py-4 text-sm font-bold flex gap-3 items-center justify-center"
|
||||
>
|
||||
<Settings className="w-4 h-4" /> 编辑资料
|
||||
</GlassButton>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="w-full py-4 text-[10px] text-red-400/40 hover:text-red-400 uppercase tracking-widest transition-colors"
|
||||
>
|
||||
清除数据并退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* 渲染编辑模式
|
||||
*/
|
||||
const renderEditMode = () => (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* 编辑标题 */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="w-12 h-12 rounded-full bg-orange-200/10 flex items-center justify-center">
|
||||
<Settings2 className="text-orange-200 w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xl font-serif">个人设定</h4>
|
||||
<p className="text-xs text-white/40">在这里调整你的人生航向基础信息</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 编辑表单 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||||
<GlassInput
|
||||
label="昵称"
|
||||
placeholder="你想被如何称呼?"
|
||||
value={editForm.nickname}
|
||||
onChange={(v) => setEditForm(prev => ({ ...prev, nickname: v }))}
|
||||
/>
|
||||
<GlassInput
|
||||
label="职业"
|
||||
placeholder="你当下的社会锚点"
|
||||
value={editForm.profession}
|
||||
onChange={(v) => setEditForm(prev => ({ ...prev, profession: v }))}
|
||||
/>
|
||||
<GlassInput
|
||||
label="MBTI"
|
||||
placeholder="性格色彩"
|
||||
value={editForm.mbti}
|
||||
onChange={(v) => setEditForm(prev => ({ ...prev, mbti: v }))}
|
||||
/>
|
||||
<GlassInput
|
||||
label="星座"
|
||||
placeholder="星辰指引"
|
||||
value={editForm.zodiac}
|
||||
onChange={(v) => setEditForm(prev => ({ ...prev, zodiac: v }))}
|
||||
/>
|
||||
</div>
|
||||
<GlassInput
|
||||
label="兴趣爱好"
|
||||
placeholder="让灵魂起舞的事物"
|
||||
value={editForm.hobbies}
|
||||
onChange={(v) => setEditForm(prev => ({ ...prev, hobbies: v }))}
|
||||
/>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-4 mt-8 pt-6 border-t border-white/5">
|
||||
<GlassButton
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
className="flex-1 py-3 bg-orange-200/10 text-orange-100 font-bold tracking-widest"
|
||||
>
|
||||
保存修改
|
||||
</GlassButton>
|
||||
<GlassButton
|
||||
onClick={handleCancel}
|
||||
className="px-6 py-3 text-white/40"
|
||||
>
|
||||
返回
|
||||
</GlassButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
{isEditing ? renderEditMode() : renderViewMode()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileModal;
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { UserCog, PenTool, Sparkles, BookOpen, Loader2 } from 'lucide-react';
|
||||
import { GlassCard, GlassButton, GlassInput, GlassSelect } from '../components/ui';
|
||||
import useStore from '../store/useStore';
|
||||
import { generateEpicScript } from '../services/ai';
|
||||
import { scriptStyles, scriptLengths } from '../utils/constants';
|
||||
|
||||
/**
|
||||
* ScriptView 组件
|
||||
* 爽文剧本视图,包含角色设定、创作需求和剧本展示
|
||||
* @param {Object} props
|
||||
* @param {Function} props.onOpenProfile - 打开用户资料模态框回调
|
||||
*/
|
||||
const ScriptView = ({ onOpenProfile }) => {
|
||||
const {
|
||||
registrationData,
|
||||
lifeEvents,
|
||||
scripts,
|
||||
selectedScriptId,
|
||||
addScript,
|
||||
setSelectedScriptId,
|
||||
getSelectedScript,
|
||||
loadScripts
|
||||
} = useStore();
|
||||
|
||||
// 加载剧本列表
|
||||
useEffect(() => {
|
||||
loadScripts().catch(() => {
|
||||
// 后端不可用时忽略错误
|
||||
});
|
||||
}, [loadScripts]);
|
||||
|
||||
// 表单状态
|
||||
const [theme, setTheme] = useState('');
|
||||
const [style, setStyle] = useState(scriptStyles[0].value);
|
||||
const [length, setLength] = useState(scriptLengths[0].value);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
/**
|
||||
* 处理剧本生成
|
||||
*/
|
||||
const handleGenerate = async () => {
|
||||
if (!theme) {
|
||||
alert('请输入主题');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const content = await generateEpicScript(
|
||||
{ theme, style, length, character: registrationData },
|
||||
lifeEvents
|
||||
);
|
||||
|
||||
addScript({ theme, style, length, content });
|
||||
setTheme('');
|
||||
} catch (error) {
|
||||
console.error('Failed to generate script:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化剧本内容,高亮【标题】
|
||||
*/
|
||||
const formatScriptContent = (content) => {
|
||||
if (!content) return '';
|
||||
return content.replace(
|
||||
/【([^】]+)】/g,
|
||||
'<div class="mt-8 mb-4 text-orange-100 font-bold text-lg border-l-2 border-orange-400 pl-4">【$1】</div>'
|
||||
);
|
||||
};
|
||||
|
||||
const selectedScript = getSelectedScript();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
{/* 左侧面板 */}
|
||||
<div className="lg:col-span-4 space-y-6">
|
||||
{/* 角色设定卡片 */}
|
||||
<GlassCard className="border-white/10 space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-white/5">
|
||||
<UserCog className="w-4 h-4 text-orange-200" />
|
||||
<h4 className="text-sm font-bold tracking-widest text-white/80 uppercase">角色设定</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-4 text-[11px]">
|
||||
<div>
|
||||
<label className="text-white/20 block">昵称</label>
|
||||
<span className="text-white/70">{registrationData.nickname || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-white/20 block">星座</label>
|
||||
<span className="text-white/70">{registrationData.zodiac || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-white/20 block">MBTI</label>
|
||||
<span className="text-white/70">{registrationData.mbti || '-'}</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-white/20 block">兴趣爱好</label>
|
||||
<span className="text-white/70">
|
||||
{registrationData.hobbies?.join(', ') || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onOpenProfile}
|
||||
className="w-full py-2 text-[10px] text-orange-200/50 hover:text-orange-200 border border-white/5 rounded-xl transition-all"
|
||||
>
|
||||
修改人设
|
||||
</button>
|
||||
</GlassCard>
|
||||
|
||||
{/* 创作需求表单 */}
|
||||
<GlassCard className="border-white/10 space-y-6">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-white/5">
|
||||
<PenTool className="w-4 h-4 text-orange-200" />
|
||||
<h4 className="text-sm font-bold tracking-widest text-white/80 uppercase">创作需求</h4>
|
||||
</div>
|
||||
<GlassInput
|
||||
label="剧本主题"
|
||||
placeholder="例如:我在职场逆袭了"
|
||||
value={theme}
|
||||
onChange={setTheme}
|
||||
/>
|
||||
<GlassSelect
|
||||
label="叙事风格"
|
||||
options={scriptStyles}
|
||||
value={style}
|
||||
onChange={setStyle}
|
||||
/>
|
||||
<GlassSelect
|
||||
label="剧本篇幅"
|
||||
options={scriptLengths}
|
||||
value={length}
|
||||
onChange={setLength}
|
||||
/>
|
||||
<GlassButton
|
||||
onClick={handleGenerate}
|
||||
disabled={isLoading}
|
||||
className="w-full py-4 bg-orange-200/5 text-orange-200 font-bold text-sm tracking-widest border-orange-200/20"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
编撰中...
|
||||
</>
|
||||
) : (
|
||||
'开启天命编撰'
|
||||
)}
|
||||
</GlassButton>
|
||||
</GlassCard>
|
||||
|
||||
{/* 历史卷轴列表 */}
|
||||
<div className="space-y-4">
|
||||
<h5 className="text-[10px] text-white/20 uppercase tracking-widest font-bold px-2">
|
||||
历史卷轴
|
||||
</h5>
|
||||
<div className="space-y-2 max-h-[25vh] overflow-y-auto custom-scrollbar">
|
||||
{scripts.length > 0 ? (
|
||||
scripts.map((script) => (
|
||||
<div
|
||||
key={script.id}
|
||||
onClick={() => setSelectedScriptId(script.id)}
|
||||
className={`
|
||||
p-3 glass-card text-left cursor-pointer hover:bg-white/5 border-white/5 transition-all
|
||||
${script.id === selectedScriptId ? 'border-orange-200/30 bg-orange-200/5' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="text-[11px] text-white/80 truncate">{script.theme}</div>
|
||||
<div className="text-[9px] text-white/30 flex justify-between mt-1">
|
||||
<span>{script.style} | {script.length}</span>
|
||||
<span>{script.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-center text-xs text-white/10 py-4 italic">暂无卷轴</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧剧本展示区 */}
|
||||
<div className="lg:col-span-8">
|
||||
<div className="h-full">
|
||||
{selectedScript ? (
|
||||
<GlassCard className="h-full overflow-y-auto custom-scrollbar border-orange-200/20 shadow-2xl animate-fade-in" padding="lg">
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<div className="flex justify-between items-center mb-8 pb-4 border-b border-white/5">
|
||||
<div>
|
||||
<h4 className="text-2xl font-serif text-orange-200">{selectedScript.theme}</h4>
|
||||
<p className="text-[10px] text-white/30 mt-1 uppercase tracking-widest">
|
||||
{selectedScript.style}篇 · {selectedScript.length}卷
|
||||
</p>
|
||||
</div>
|
||||
<BookOpen className="text-white/20" />
|
||||
</div>
|
||||
<div
|
||||
className="text-white/70 leading-loose whitespace-pre-wrap space-y-6 text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: formatScriptContent(selectedScript.content) }}
|
||||
/>
|
||||
</div>
|
||||
</GlassCard>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center opacity-20 py-32">
|
||||
<Sparkles className="w-20 h-20 mb-6" />
|
||||
<p className="text-xl font-serif">请在左侧设定需求,开启你的天命爽文</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScriptView;
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Wind, Sparkles } from 'lucide-react';
|
||||
import { GlassCard, GlassButton, GlassInput, GlassTextarea } from '../components/ui';
|
||||
import Modal from '../components/Modal';
|
||||
import useStore from '../store/useStore';
|
||||
import { analyzeLifeEvent } from '../services/ai';
|
||||
|
||||
/**
|
||||
* TimelineView 组件
|
||||
* 生命长河视图,显示和管理生命事件
|
||||
*/
|
||||
const TimelineView = () => {
|
||||
const { lifeEvents, addLifeEvent, loadLifeEvents } = useStore();
|
||||
|
||||
// 加载生命事件
|
||||
useEffect(() => {
|
||||
loadLifeEvents().catch(() => {
|
||||
// 后端不可用时忽略错误
|
||||
});
|
||||
}, [loadLifeEvents]);
|
||||
|
||||
// 模态框状态
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 表单状态
|
||||
const [eventForm, setEventForm] = useState({
|
||||
title: '',
|
||||
time: '',
|
||||
content: ''
|
||||
});
|
||||
|
||||
/**
|
||||
* 处理表单提交
|
||||
*/
|
||||
const handleSubmit = async () => {
|
||||
if (!eventForm.title || !eventForm.time || !eventForm.content) {
|
||||
alert('请完整填写记录。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 调用 AI 分析
|
||||
const aiFeedback = await analyzeLifeEvent(eventForm);
|
||||
|
||||
// 添加事件
|
||||
addLifeEvent({
|
||||
...eventForm,
|
||||
aiFeedback
|
||||
});
|
||||
|
||||
// 重置表单并关闭模态框
|
||||
setEventForm({ title: '', time: '', content: '' });
|
||||
setIsModalOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to analyze event:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 按时间倒序排列事件
|
||||
*/
|
||||
const sortedEvents = [...lifeEvents].sort(
|
||||
(a, b) => new Date(b.time) - new Date(a.time)
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 标题区域 */}
|
||||
<div className="flex justify-between items-end mb-12">
|
||||
<div>
|
||||
<h3 className="text-4xl font-serif text-white/90">生命长河</h3>
|
||||
<p className="text-sm text-white/30 mt-2">塑造你的每一刻,都被星辰见证。</p>
|
||||
</div>
|
||||
<GlassButton
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="px-6 py-3 rounded-full text-sm font-bold flex items-center gap-2 bg-orange-200/5 text-orange-200 border-orange-200/20 shadow-lg"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> 记录足迹
|
||||
</GlassButton>
|
||||
</div>
|
||||
|
||||
{/* 时间线容器 */}
|
||||
<div className="relative pl-8">
|
||||
{sortedEvents.length > 0 && <div className="timeline-line" />}
|
||||
|
||||
<div className="space-y-10">
|
||||
{sortedEvents.length > 0 ? (
|
||||
sortedEvents.map((event) => (
|
||||
<div key={event.id} className="relative group">
|
||||
{/* 时间线点 */}
|
||||
<div className="timeline-dot absolute left-[-39px] top-6 z-10" />
|
||||
|
||||
{/* 事件卡片 */}
|
||||
<GlassCard className="border-white/5 hover:border-orange-200/20 transition-all duration-700">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h4 className="text-xl font-medium text-white/80">{event.title}</h4>
|
||||
<span className="text-[10px] font-mono tracking-widest text-white/30 uppercase">
|
||||
{event.time}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-white/60 leading-relaxed mb-6">
|
||||
{event.content}
|
||||
</p>
|
||||
|
||||
{/* AI 反馈区域 */}
|
||||
<div className="ai-glow-card p-5 rounded-2xl bg-orange-200/[0.02] border border-orange-200/5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Sparkles className="w-3 h-3 text-orange-200" />
|
||||
<span className="text-[9px] uppercase tracking-[0.2em] text-orange-200/60 font-bold">
|
||||
引路人洞察
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs italic text-white/50 leading-loose">
|
||||
{event.aiFeedback}
|
||||
</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
/* 空状态 */
|
||||
<div className="flex flex-col items-center justify-center py-32 text-center opacity-30">
|
||||
<Wind className="w-12 h-12 mb-4" />
|
||||
<p className="font-serif italic text-lg">此间尚无回响,等待你执笔...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 添加事件模态框 */}
|
||||
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} title="记录足迹">
|
||||
<div className="space-y-6">
|
||||
<GlassInput
|
||||
label="事件标题"
|
||||
placeholder="给这段经历起个名字"
|
||||
value={eventForm.title}
|
||||
onChange={(v) => setEventForm(prev => ({ ...prev, title: v }))}
|
||||
/>
|
||||
<GlassInput
|
||||
label="发生时间"
|
||||
type="date"
|
||||
value={eventForm.time}
|
||||
onChange={(v) => setEventForm(prev => ({ ...prev, time: v }))}
|
||||
/>
|
||||
<GlassTextarea
|
||||
label="经历详情"
|
||||
placeholder="当时发生了什么?你的感受如何?"
|
||||
value={eventForm.content}
|
||||
onChange={(v) => setEventForm(prev => ({ ...prev, content: v }))}
|
||||
rows={5}
|
||||
/>
|
||||
<GlassButton
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
loading={isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? '正在共鸣生命轨迹...' : '开启 AI 疗愈'}
|
||||
</GlassButton>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineView;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 3000
|
||||
}
|
||||
})
|
||||
@@ -1199,6 +1199,7 @@ CREATE TABLE t_user_profile (
|
||||
valley_date DATE COMMENT '低谷时刻日期',
|
||||
valley_content TEXT COMMENT '低谷时刻内容',
|
||||
future_vision TEXT COMMENT '未来愿景',
|
||||
ideal_life TEXT COMMENT '理想生活状态',
|
||||
scripts JSON COMMENT '生成的剧本列表 (JSON)',
|
||||
paths JSON COMMENT '选择的路径列表 (JSON)',
|
||||
status TINYINT DEFAULT 1 COMMENT '状态: 0-禁用, 1-正常',
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- ============================================================================
|
||||
-- 迁移脚本: 添加 ideal_life 字段到 t_user_profile 表
|
||||
-- 日期: 2025-12-22
|
||||
-- 描述: 为用户档案表添加理想生活状态字段
|
||||
-- ============================================================================
|
||||
|
||||
-- 检查字段是否存在,不存在则添加
|
||||
SET @dbname = DATABASE();
|
||||
SET @tablename = 't_user_profile';
|
||||
SET @columnname = 'ideal_life';
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @dbname
|
||||
AND TABLE_NAME = @tablename
|
||||
AND COLUMN_NAME = @columnname
|
||||
) > 0,
|
||||
'SELECT 1',
|
||||
CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' TEXT COMMENT ''理想生活状态'' AFTER future_vision')
|
||||
));
|
||||
|
||||
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||
EXECUTE alterIfNotExists;
|
||||
DEALLOCATE PREPARE alterIfNotExists;
|
||||
Reference in New Issue
Block a user