人生轨迹功能模块补充
This commit is contained in:
Generated
+1216
-12
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.9",
|
||||
@@ -22,6 +24,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
@@ -29,10 +33,13 @@
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"fast-check": "^4.5.2",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^27.3.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vite": "npm:rolldown-vite@7.2.5"
|
||||
"vite": "npm:rolldown-vite@7.2.5",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@7.2.5"
|
||||
|
||||
@@ -56,7 +56,9 @@ function CurrentPage() {
|
||||
return (
|
||||
<LoginPage
|
||||
onLoginSuccess={() => {
|
||||
Store.completeOnboarding();
|
||||
// 登录成功后,检查用户是否已完成引导
|
||||
// 如果已完成,直接进入仪表盘;否则进入引导流程
|
||||
setView('onboarding');
|
||||
}}
|
||||
onBack={() => setView('landing')}
|
||||
onSignUp={() => setView('onboarding')}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import request from '../utils/request';
|
||||
|
||||
/**
|
||||
* 认证相关 API
|
||||
*/
|
||||
export const authApi = {
|
||||
/**
|
||||
* 发送短信验证码
|
||||
* @param {string} phone - 手机号
|
||||
*/
|
||||
sendSmsCode(phone) {
|
||||
return request({
|
||||
url: '/auth/sms-code',
|
||||
method: 'get',
|
||||
params: { phone }
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 手机号验证码登录
|
||||
* @param {Object} data - 登录参数
|
||||
* @param {string} data.phone - 手机号
|
||||
* @param {string} data.smsCode - 短信验证码
|
||||
*/
|
||||
login(data) {
|
||||
return request({
|
||||
url: '/auth/login',
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
getUserInfo() {
|
||||
return request({
|
||||
url: '/auth/userInfo',
|
||||
method: 'get'
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
logout() {
|
||||
return request({
|
||||
url: '/auth/logout',
|
||||
method: 'post'
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 刷新令牌
|
||||
* @param {string} refreshToken - 刷新令牌
|
||||
*/
|
||||
refreshToken(refreshToken) {
|
||||
return request({
|
||||
url: '/auth/refreshToken',
|
||||
method: 'post',
|
||||
data: { refreshToken }
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 验证令牌
|
||||
*/
|
||||
validateToken() {
|
||||
return request({
|
||||
url: '/auth/validateToken',
|
||||
method: 'get'
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查手机号是否存在
|
||||
* @param {string} phone - 手机号
|
||||
*/
|
||||
checkPhone(phone) {
|
||||
return request({
|
||||
url: '/auth/checkPhone',
|
||||
method: 'get',
|
||||
params: { phone }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
import request from '../utils/request';
|
||||
|
||||
/**
|
||||
* 生命事件 API
|
||||
*/
|
||||
export const lifeEventApi = {
|
||||
/**
|
||||
* 分页查询生命事件
|
||||
*/
|
||||
getPage(params) {
|
||||
return request({
|
||||
url: '/lifeEvent/page',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取所有生命事件列表
|
||||
*/
|
||||
getList() {
|
||||
return request({
|
||||
url: '/lifeEvent/list',
|
||||
method: 'get'
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取生命事件详情
|
||||
*/
|
||||
getById(id) {
|
||||
return request({
|
||||
url: '/lifeEvent/detail',
|
||||
method: 'get',
|
||||
params: { id }
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建生命事件
|
||||
*/
|
||||
create(data) {
|
||||
return request({
|
||||
url: '/lifeEvent/create',
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新生命事件
|
||||
*/
|
||||
update(data) {
|
||||
return request({
|
||||
url: '/lifeEvent/update',
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除生命事件
|
||||
*/
|
||||
delete(id) {
|
||||
return request({
|
||||
url: '/lifeEvent/delete',
|
||||
method: 'delete',
|
||||
params: { id }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 爽文剧本 API
|
||||
*/
|
||||
export const epicScriptApi = {
|
||||
/**
|
||||
* 分页查询爽文剧本
|
||||
*/
|
||||
getPage(params) {
|
||||
return request({
|
||||
url: '/epicScript/page',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取所有爽文剧本列表
|
||||
*/
|
||||
getList() {
|
||||
return request({
|
||||
url: '/epicScript/listAll',
|
||||
method: 'get'
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取爽文剧本详情
|
||||
*/
|
||||
getById(id) {
|
||||
return request({
|
||||
url: '/epicScript/detail',
|
||||
method: 'get',
|
||||
params: { id }
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建爽文剧本
|
||||
*/
|
||||
create(data) {
|
||||
return request({
|
||||
url: '/epicScript/create',
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新爽文剧本
|
||||
*/
|
||||
update(data) {
|
||||
return request({
|
||||
url: '/epicScript/update',
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 选中剧本
|
||||
*/
|
||||
select(id) {
|
||||
return request({
|
||||
url: '/epicScript/select',
|
||||
method: 'put',
|
||||
params: { id }
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除爽文剧本
|
||||
*/
|
||||
delete(id) {
|
||||
return request({
|
||||
url: '/epicScript/delete',
|
||||
method: 'delete',
|
||||
params: { id }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 实现路径 API
|
||||
*/
|
||||
export const lifePathApi = {
|
||||
/**
|
||||
* 分页查询实现路径
|
||||
*/
|
||||
getPage(params) {
|
||||
return request({
|
||||
url: '/lifePath/page',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取所有实现路径列表
|
||||
*/
|
||||
getList() {
|
||||
return request({
|
||||
url: '/lifePath/listAll',
|
||||
method: 'get'
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据剧本ID获取实现路径
|
||||
*/
|
||||
getByScriptId(scriptId) {
|
||||
return request({
|
||||
url: '/lifePath/byScript',
|
||||
method: 'get',
|
||||
params: { scriptId }
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取实现路径详情
|
||||
*/
|
||||
getById(id) {
|
||||
return request({
|
||||
url: '/lifePath/detail',
|
||||
method: 'get',
|
||||
params: { id }
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建实现路径
|
||||
*/
|
||||
create(data) {
|
||||
return request({
|
||||
url: '/lifePath/create',
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新实现路径
|
||||
*/
|
||||
update(data) {
|
||||
return request({
|
||||
url: '/lifePath/update',
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除实现路径
|
||||
*/
|
||||
delete(id) {
|
||||
return request({
|
||||
url: '/lifePath/delete',
|
||||
method: 'delete',
|
||||
params: { id }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useStoreData } from '../hooks/useStoreData';
|
||||
import { Store } from '../utils/store';
|
||||
import { userApi } from '../api/user';
|
||||
import { User, Settings, LogOut, X, Edit2 } from 'lucide-react';
|
||||
import { GlassCard } from './ui/GlassCard';
|
||||
import { Button } from './ui/Button';
|
||||
@@ -155,23 +156,44 @@ function EditProfileModal({ onClose, userProfile }) {
|
||||
gender: userProfile.gender || 'secret'
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
|
||||
// 更新用户资料
|
||||
Store.updateProfile({
|
||||
const updatedProfile = {
|
||||
nickname: formData.nickname,
|
||||
mbti: formData.mbti,
|
||||
zodiac: formData.zodiac,
|
||||
hobbies: formData.hobbies.split(',').map(s => s.trim()).filter(s => s),
|
||||
gender: formData.gender
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
// 1. 更新本地 Store
|
||||
Store.updateProfile(updatedProfile);
|
||||
|
||||
// 2. 同步到后端
|
||||
const currentProfile = await userApi.getCurrentUser();
|
||||
if (currentProfile.data && currentProfile.data.id) {
|
||||
await userApi.updateUserProfile({
|
||||
id: currentProfile.data.id,
|
||||
nickname: updatedProfile.nickname,
|
||||
mbti: updatedProfile.mbti,
|
||||
zodiac: updatedProfile.zodiac,
|
||||
hobbies: JSON.stringify(updatedProfile.hobbies),
|
||||
gender: updatedProfile.gender
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setIsSaving(false);
|
||||
onClose();
|
||||
}, 300);
|
||||
} catch (e) {
|
||||
console.error('保存资料失败:', e);
|
||||
setError(e.response?.data?.message || '保存失败,请重试');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -255,6 +277,13 @@ function EditProfileModal({ onClose, userProfile }) {
|
||||
<option value="female">女</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-200 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { useStoreData } from '../../hooks/useStoreData';
|
||||
import { Store } from '../../utils/store';
|
||||
import { AI } from '../../utils/aiLogic';
|
||||
import { userApi } from '../../api/user';
|
||||
import {
|
||||
Map,
|
||||
Ghost,
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { Store } from '../../utils/store';
|
||||
import { AI } from '../../utils/aiLogic';
|
||||
import { useStoreData } from '../../hooks/useStoreData';
|
||||
import { BookHeart, Bot, Send, Loader2, PenTool, HeartHandshake, Microscope, Sprout, Quote } from 'lucide-react';
|
||||
import { BookHeart, Bot, Send, Loader2, PenTool, HeartHandshake, Microscope, Sprout, Quote, Sparkles } from 'lucide-react';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Input, Textarea } from '../ui/Input';
|
||||
import { GlassCard as Card } from '../ui/GlassCard';
|
||||
|
||||
+230
-123
@@ -1,154 +1,261 @@
|
||||
import React, { useState } from 'react';
|
||||
import { User, Lock, Eye, EyeOff, ArrowRight, ArrowLeft } from 'lucide-react';
|
||||
/**
|
||||
* 登录页面组件
|
||||
* 实现手机号验证码登录功能
|
||||
* Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { ArrowRight, Phone, KeyRound } from 'lucide-react';
|
||||
import { GlassCard } from '../components/ui/GlassCard';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Input } from '../components/ui/Input';
|
||||
import { Checkbox } from '../components/ui/Checkbox';
|
||||
import { Store } from '../utils/store';
|
||||
import { authApi } from '../api/auth';
|
||||
import { userApi } from '../api/user';
|
||||
|
||||
export function LoginPage({ onLoginSuccess, onBack, onSignUp }) {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
rememberMe: false
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
/**
|
||||
* 验证手机号格式
|
||||
* Property 1: 手机号格式验证
|
||||
* @param {string} phone - 手机号
|
||||
* @returns {boolean} 是否为有效的11位手机号
|
||||
*/
|
||||
export function validatePhone(phone) {
|
||||
return /^1[3-9]\d{9}$/.test(phone);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录页面
|
||||
* @param {Object} props
|
||||
* @param {Function} props.onLoginSuccess - 登录成功回调
|
||||
*/
|
||||
export function LoginPage({ onLoginSuccess }) {
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 验证码倒计时
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [countdown]);
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
*/
|
||||
const handleSendCode = useCallback(async () => {
|
||||
setError('');
|
||||
|
||||
if (!validatePhone(phone)) {
|
||||
setError('请输入正确的11位手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authApi.sendSmsCode(phone);
|
||||
setCountdown(60);
|
||||
} catch (e) {
|
||||
const errorMsg = e.response?.data?.message || '验证码发送失败,请稍后重试';
|
||||
setError(errorMsg);
|
||||
}
|
||||
}, [phone]);
|
||||
|
||||
/**
|
||||
* 提交登录
|
||||
*/
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!validatePhone(phone)) {
|
||||
setError('请输入正确的11位手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
if (code.length !== 6) {
|
||||
setError('请输入6位验证码');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Mock login delay
|
||||
setTimeout(() => {
|
||||
if (!formData.username || !formData.password) {
|
||||
setError('请输入用户名和密码');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Simple mock validation
|
||||
if (formData.password.length < 6) {
|
||||
setError('密码长度不能少于6位');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Success
|
||||
Store.updateProfile({
|
||||
nickname: formData.username,
|
||||
// In a real app, we'd store a token
|
||||
});
|
||||
try {
|
||||
const response = await authApi.login({ phone, smsCode: code });
|
||||
|
||||
setIsLoading(false);
|
||||
// 保存token到localStorage
|
||||
if (response.data?.token) {
|
||||
localStorage.setItem('token', response.data.token);
|
||||
}
|
||||
if (response.data?.refreshToken) {
|
||||
localStorage.setItem('refreshToken', response.data.refreshToken);
|
||||
}
|
||||
|
||||
// 从后端加载用户档案数据
|
||||
try {
|
||||
const profileRes = await userApi.getCurrentUser();
|
||||
if (profileRes.data && profileRes.data.id) {
|
||||
// 用户已有档案,同步到本地Store
|
||||
const backendData = profileRes.data;
|
||||
const mappedProfile = {
|
||||
nickname: backendData.nickname || '',
|
||||
gender: backendData.gender || 'secret',
|
||||
zodiac: backendData.zodiac || '',
|
||||
mbti: backendData.mbti || '',
|
||||
hobbies: backendData.hobbies ? JSON.parse(backendData.hobbies) : [],
|
||||
history: {
|
||||
childhood: {
|
||||
date: backendData.childhoodDate || '',
|
||||
content: backendData.childhoodContent || ''
|
||||
},
|
||||
peak: {
|
||||
date: backendData.peakDate || '',
|
||||
content: backendData.peakContent || ''
|
||||
},
|
||||
valley: {
|
||||
date: backendData.valleyDate || '',
|
||||
content: backendData.valleyContent || ''
|
||||
}
|
||||
},
|
||||
futureVision: backendData.futureVision || ''
|
||||
};
|
||||
|
||||
Store.updateProfile(mappedProfile);
|
||||
|
||||
// 同步剧本和路径数据
|
||||
if (backendData.scripts) {
|
||||
try {
|
||||
const scripts = JSON.parse(backendData.scripts);
|
||||
if (Array.isArray(scripts) && scripts.length > 0) {
|
||||
const data = Store.get();
|
||||
data.generatedScripts = scripts;
|
||||
Store.save(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('解析剧本数据失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (backendData.paths) {
|
||||
try {
|
||||
const paths = JSON.parse(backendData.paths);
|
||||
if (Array.isArray(paths) && paths.length > 0) {
|
||||
const data = Store.get();
|
||||
data.paths = paths;
|
||||
Store.save(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('解析路径数据失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果用户已有完整档案(有昵称和MBTI),标记为已完成引导
|
||||
if (backendData.nickname && backendData.mbti) {
|
||||
Store.completeOnboarding();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('加载用户档案失败,将进入引导流程', e);
|
||||
}
|
||||
|
||||
// 更新本地用户信息
|
||||
Store.updateProfile({ phone });
|
||||
onLoginSuccess();
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
const errorMsg = e.response?.data?.message || '登录失败,请稍后重试';
|
||||
setError(errorMsg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 relative z-10 animate-fade-in">
|
||||
|
||||
<div className="absolute top-8 left-8">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-white/60 hover:text-white transition-colors group"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
|
||||
<span>返回首页</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-screen flex items-center justify-center p-6 animate-fade-in">
|
||||
<GlassCard className="max-w-md w-full p-10 space-y-8 border-white/5 shadow-2xl rounded-[32px]">
|
||||
{/* 标题区域 */}
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-3xl font-serif tracking-wider text-white/90">欢迎回来</h2>
|
||||
<p className="text-sm text-white/40 italic">开启你的数字生命档案</p>
|
||||
</div>
|
||||
|
||||
<GlassCard className="w-full max-w-md p-8 md:p-10 relative overflow-hidden">
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-primary/20 rounded-full blur-3xl pointer-events-none"></div>
|
||||
<div className="absolute -bottom-10 -left-10 w-40 h-40 bg-aurora-green/20 rounded-full blur-3xl pointer-events-none"></div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="text-3xl font-bold text-white mb-2 tracking-tight">欢迎回来</h2>
|
||||
<p className="text-gray-400">登录您的 Emotion Museum 账号</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 手机号输入 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] text-white/30 uppercase tracking-[0.2em] font-bold">
|
||||
手机号码
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30 group-focus-within:text-orange-200 transition-colors" />
|
||||
<Input
|
||||
type="tel"
|
||||
placeholder="输入手机号"
|
||||
maxLength={11}
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
|
||||
className="pl-11 text-center tracking-[0.1em] bg-black/20 border-white/5 rounded-2xl focus:border-orange-200/50 focus:shadow-[0_0_20px_rgba(255,171,145,0.1)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-300 ml-1">用户名 / 邮箱</label>
|
||||
{/* 验证码输入 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2 space-y-2">
|
||||
<label className="text-[10px] text-white/30 uppercase tracking-[0.2em] font-bold">
|
||||
验证码
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-primary transition-colors" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入您的账号"
|
||||
className="pl-12 w-full"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({...formData, username: e.target.value})}
|
||||
<KeyRound className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30 group-focus-within:text-orange-200 transition-colors" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="六位验证码"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="pl-11 text-center bg-black/20 border-white/5 rounded-2xl focus:border-orange-200/50 focus:shadow-[0_0_20px_rgba(255,171,145,0.1)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-300 ml-1">密码</label>
|
||||
<div className="relative group">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-primary transition-colors" />
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="请输入您的密码"
|
||||
className="pl-12 pr-12 w-full"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({...formData, password: e.target.value})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors focus:outline-none"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Checkbox
|
||||
label="记住我"
|
||||
checked={formData.rememberMe}
|
||||
onChange={(checked) => setFormData({...formData, rememberMe: checked})}
|
||||
/>
|
||||
<button type="button" className="text-sm text-primary hover:text-aurora-green transition-colors">
|
||||
忘记密码?
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendCode}
|
||||
disabled={countdown > 0 || !phone}
|
||||
className="w-full h-[46px] rounded-2xl border border-white/5 bg-white/5 text-[10px] uppercase tracking-tighter hover:bg-white/10 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{countdown > 0 ? `${countdown}S` : '获取'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-200 text-sm flex items-center gap-2 animate-shake">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{!isLoading && (
|
||||
<>
|
||||
立即登录 <ArrowRight className="w-4 h-4 ml-2" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 text-center text-sm text-gray-400">
|
||||
还没有账号?
|
||||
<button
|
||||
onClick={onSignUp}
|
||||
className="text-primary hover:text-aurora-green font-bold ml-1 hover:underline transition-all"
|
||||
>
|
||||
立即注册
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-200 text-sm flex items-center gap-2 animate-shake">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500 flex-shrink-0"></span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isLoading}
|
||||
disabled={!phone || !code}
|
||||
className="w-full py-4 rounded-2xl bg-orange-200/5 text-orange-200 font-bold tracking-[0.3em] hover:bg-orange-200/10 border-orange-200/20 shadow-lg shadow-orange-900/10"
|
||||
variant="secondary"
|
||||
>
|
||||
{!isLoading && (
|
||||
<>
|
||||
开启旅程 <ArrowRight className="w-4 h-4 ml-2" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* 协议提示 */}
|
||||
<p className="text-[10px] text-center text-white/20 px-4 leading-relaxed">
|
||||
登录即代表同意《用户协议》与《隐私政策》,我们将妥善保管您的生命数据。
|
||||
</p>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
/**
|
||||
* 引导流程页面组件
|
||||
* 实现5步引导流程:基本信息、童年记忆、开心经历、低谷时光、未来愿景
|
||||
* Requirements: 2.1-2.11
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Store } from '../utils/store';
|
||||
import { userApi } from '../api/user';
|
||||
import { ArrowLeft, ArrowRight, Check, X, Sparkles, Star, AlertCircle, CheckCircle, Loader } from 'lucide-react';
|
||||
@@ -6,14 +11,50 @@ import { Button } from '../components/ui/Button';
|
||||
import { Input, Select, Textarea } from '../components/ui/Input';
|
||||
import clsx from 'clsx';
|
||||
|
||||
/** 星座选项 */
|
||||
const ZODIAC_SIGNS = [
|
||||
"白羊座", "金牛座", "双子座", "巨蟹座",
|
||||
"狮子座", "处女座", "天秤座", "天蝎座",
|
||||
"射手座", "摩羯座", "水瓶座", "双鱼座"
|
||||
];
|
||||
|
||||
/** MBTI 类型选项 */
|
||||
const MBTI_TYPES = ['INTJ','INTP','ENTJ','ENTP','INFJ','INFP','ENFJ','ENFP','ISTJ','ISFJ','ESTJ','ESFJ','ISTP','ISFP','ESTP','ESFP'];
|
||||
|
||||
/**
|
||||
* 灵感标签词库
|
||||
* 用于帮助用户快速填写记忆描述
|
||||
*/
|
||||
const INSPIRATION_CLUSTERS = {
|
||||
childhood: ['秋千', '晚霞', '糖果', '奔跑', '蝉鸣', '雨后泥土', '旧书包', '风筝'],
|
||||
peak: ['海浪', '拥抱', '掌声', '晨曦', '破土而出', '默契', '星空', '释放'],
|
||||
valley: ['落叶', '雨伞', '长廊', '深呼吸', '自愈', '沉潜', '坚韧', '等待', '破茧']
|
||||
};
|
||||
|
||||
/**
|
||||
* 灵感标签组件
|
||||
* 点击标签将文字插入到文本框中
|
||||
* @param {Object} props
|
||||
* @param {string} props.type - 标签类型 (childhood/peak/valley)
|
||||
* @param {Function} props.onInsert - 插入文字回调
|
||||
*/
|
||||
function InspirationTags({ type, onInsert }) {
|
||||
const words = INSPIRATION_CLUSTERS[type] || [];
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{words.map(word => (
|
||||
<span
|
||||
key={word}
|
||||
onClick={() => onInsert(word)}
|
||||
className="px-3 py-1 bg-white/5 border border-white/5 rounded-full text-[10px] text-white/40 cursor-pointer hover:bg-orange-200/10 hover:text-orange-200 transition-all select-none"
|
||||
>
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OnboardingPage({ onFinish }) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [formData, setFormData] = useState(Store.get().userProfile);
|
||||
@@ -110,7 +151,7 @@ export function OnboardingPage({ onFinish }) {
|
||||
gender: formData.gender,
|
||||
zodiac: formData.zodiac,
|
||||
mbti: formData.mbti,
|
||||
hobbies: JSON.stringify(formData.hobbies),
|
||||
hobbies: JSON.stringify(formData.hobbies || []),
|
||||
childhoodDate: formData.history?.childhood?.date || null,
|
||||
childhoodContent: formData.history?.childhood?.content || '',
|
||||
peakDate: formData.history?.peak?.date || null,
|
||||
@@ -118,26 +159,24 @@ export function OnboardingPage({ onFinish }) {
|
||||
valleyDate: formData.history?.valley?.date || null,
|
||||
valleyContent: formData.history?.valley?.content || '',
|
||||
futureVision: formData.futureVision,
|
||||
// Also sync scripts/paths if they exist in store (re-registration case)
|
||||
scripts: JSON.stringify(Store.get().generatedScripts || []),
|
||||
paths: JSON.stringify(Store.get().paths || [])
|
||||
};
|
||||
|
||||
// 3. Call backend
|
||||
// Check if profile exists first
|
||||
// 3. Call backend - check if profile exists first
|
||||
const currentProfile = await userApi.getCurrentUser();
|
||||
if (currentProfile.data) {
|
||||
// Update
|
||||
if (currentProfile.data && currentProfile.data.id) {
|
||||
// Update existing profile
|
||||
await userApi.updateUserProfile({ ...requestData, id: currentProfile.data.id });
|
||||
} else {
|
||||
// Create
|
||||
// Create new profile
|
||||
await userApi.createUserProfile(requestData);
|
||||
}
|
||||
|
||||
onFinish();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('保存失败,请重试');
|
||||
console.error('保存档案失败:', e);
|
||||
showToast(e.response?.data?.message || '保存失败,请重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -253,6 +292,11 @@ export function OnboardingPage({ onFinish }) {
|
||||
|
||||
<label className="block text-xs font-bold text-amber-300/80 mb-2">记忆中的画面</label>
|
||||
<Textarea rows={5} placeholder="比如:外婆的蒲扇,炎热的下午,或者第一次离家..." value={formData.history?.childhood?.content || ''} onChange={e => updateHistory('childhood', 'content', e.target.value)} />
|
||||
|
||||
<InspirationTags
|
||||
type="childhood"
|
||||
onInsert={(word) => updateHistory('childhood', 'content', (formData.history?.childhood?.content || '') + word)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -271,6 +315,11 @@ export function OnboardingPage({ onFinish }) {
|
||||
|
||||
<label className="block text-xs font-bold text-primary/80 mb-2">那发生了什么?</label>
|
||||
<Textarea rows={5} placeholder="比如:收到心仪的offer,一次完美的旅行,或者被理解的瞬间..." value={formData.history?.peak?.content || ''} onChange={e => updateHistory('peak', 'content', e.target.value)} />
|
||||
|
||||
<InspirationTags
|
||||
type="peak"
|
||||
onInsert={(word) => updateHistory('peak', 'content', (formData.history?.peak?.content || '') + word)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -289,6 +338,11 @@ export function OnboardingPage({ onFinish }) {
|
||||
|
||||
<label className="block text-xs font-bold text-blue-400/80 mb-2">当时的感受与成长</label>
|
||||
<Textarea rows={5} placeholder="那个挑战是什么?现在回看,它带给了你什么?" value={formData.history?.valley?.content || ''} onChange={e => updateHistory('valley', 'content', e.target.value)} />
|
||||
|
||||
<InspirationTags
|
||||
type="valley"
|
||||
onInsert={(word) => updateHistory('valley', 'content', (formData.history?.valley?.content || '') + word)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Vitest 测试环境设置
|
||||
* 配置 jsdom 环境和 localStorage mock
|
||||
*/
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
store: {},
|
||||
getItem: function(key) {
|
||||
return this.store[key] || null;
|
||||
},
|
||||
setItem: function(key, value) {
|
||||
this.store[key] = value.toString();
|
||||
},
|
||||
removeItem: function(key) {
|
||||
delete this.store[key];
|
||||
},
|
||||
clear: function() {
|
||||
this.store = {};
|
||||
}
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock
|
||||
});
|
||||
|
||||
// Mock window.location.reload
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
reload: vi.fn(),
|
||||
href: ''
|
||||
},
|
||||
writable: true
|
||||
});
|
||||
|
||||
// Reset localStorage before each test
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
});
|
||||
+224
-156
@@ -1,162 +1,230 @@
|
||||
/**
|
||||
* AI Logic Module
|
||||
* Simulates intelligent healing responses and content generation.
|
||||
* AI 服务模块
|
||||
* 提供生命事件分析、剧本生成、路径规划等 AI 功能
|
||||
* 保持与原 PncyssD/api.js 相同的 API 调用逻辑
|
||||
*/
|
||||
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
const API_KEY = "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";
|
||||
const BASE_URL = "https://openrouter.ai/api/v1/chat/completions";
|
||||
|
||||
const ADJECTIVES = {
|
||||
INTJ: ['深刻', '逻辑严密', '富有远见'],
|
||||
INFP: ['温柔', '富有同理心', '充满想象力'],
|
||||
ENFP: ['热情', '鼓舞人心', '充满可能性'],
|
||||
ISTJ: ['稳重', '务实', '值得信赖'],
|
||||
INFJ: ['深邃', '直觉敏锐', '利他'],
|
||||
|
||||
DEFAULT: ['特别', '真诚', '有力量']
|
||||
};
|
||||
|
||||
export const AI = {
|
||||
/**
|
||||
* Generate a healing reply for a timeline log.
|
||||
* Analyzes sentiment and provides Analysis, Growth, and Healing.
|
||||
*/
|
||||
async generateReply(content, profile) {
|
||||
await sleep(1500 + Math.random() * 1000); // Simulate 1.5-2.5s thinking
|
||||
|
||||
const mbti = profile.mbti || 'DEFAULT';
|
||||
const traits = ADJECTIVES[mbti] || ADJECTIVES.DEFAULT;
|
||||
const trait = traits[Math.floor(Math.random() * traits.length)];
|
||||
const zodiac = profile.zodiac || '星辰';
|
||||
|
||||
|
||||
let mood = 'neutral';
|
||||
if (/(开心|成功|棒|好|爱|幸福|顺利)/.test(content)) mood = 'positive';
|
||||
if (/(难过|累|失败|痛|丧|焦虑|迷茫)/.test(content)) mood = 'negative';
|
||||
|
||||
let analysis = "";
|
||||
let growth = "";
|
||||
let healing = "";
|
||||
|
||||
if (mood === 'positive') {
|
||||
analysis = `这不仅仅是一个事件,而是你内心能量充盈的体现。作为${mbti},你敏锐地捕捉到了生活中的光亮。`;
|
||||
growth = `请记住这种${trait}的感觉,它构成了你人格中坚韧的底色。你的每一次喜悦,都在为未来的挑战积蓄心理资本。`;
|
||||
healing = `愿这一刻的温暖,像${zodiac}的光芒一样,长久地照耀你的内心。`;
|
||||
} else if (mood === 'negative') {
|
||||
analysis = `感到低落并非示弱,而是灵魂在提醒你需要休息。${mbti}的你往往思考得很深,容易承担过多情绪。`;
|
||||
growth = `每一次的阵痛都是成长的伏笔。你正在经历破茧成蝶前的静默,这本身就是一种力量。`;
|
||||
healing = `允许自己暂停,就像月亮也有阴晴圆缺。给那个受伤的自己一个拥抱,风雨之后,必见彩虹。`;
|
||||
} else {
|
||||
analysis = `记录本身就是一种深刻的觉察。你在平淡的流年中,依然保持着${trait}的观察力。`;
|
||||
growth = `生活的大部分时间是平静的,能在其中找到节奏,是你最宝贵的能力。`;
|
||||
healing = `在这漫长的岁月里,你就是自己最忠实的见证者。`;
|
||||
}
|
||||
|
||||
return {
|
||||
analysis,
|
||||
growth,
|
||||
healing
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a "Cool Story" (爽文) script.
|
||||
* Uses profile history (Valley/Peak) to create a narrative arc.
|
||||
*/
|
||||
async generateScript(profile, timeline, requirements) {
|
||||
await sleep(2000 + Math.random() * 1000);
|
||||
|
||||
const id = Date.now().toString();
|
||||
const hobby = (profile.hobbies && profile.hobbies.length > 0) ? profile.hobbies[0] : "隐藏的天赋";
|
||||
const theme = requirements.theme || "自我超越";
|
||||
|
||||
|
||||
const valleyEvent = profile.history?.valley?.content
|
||||
? `那段"${profile.history.valley.content.substring(0, 15)}..."的经历`
|
||||
: "曾经那段默默无闻的时光";
|
||||
|
||||
const peakEvent = profile.history?.peak?.content
|
||||
? `就像"${profile.history.peak.content.substring(0, 15)}..."那次一样`
|
||||
: "如同星辰觉醒";
|
||||
|
||||
const templates = {
|
||||
career: {
|
||||
intro: `故事始于${valleyEvent}。${profile.nickname}虽然身处低谷,但作为${profile.mbti},${profile.gender === 'male' ? '他' : '她'}内心深处对"${theme}"的渴望从未熄灭。${profile.zodiac}骨子里的韧性,支撑着${profile.gender === 'male' ? '他' : '她'}熬过了最黑的夜。`,
|
||||
turning: `转折发生在一个不起眼的午后。公司面临前所未有的技术难题,所有人束手无策。${profile.nickname}利用业余时间钻研的${hobby},意外发现了破局的关键。`,
|
||||
explosion: `在项目汇报会上,${profile.nickname}条理清晰地展示了方案,那种自信${peakEvent}。原本轻视的人都闭上了嘴。不仅完美解决了危机,更直接为公司带来了巨大的收益,一战成名!`,
|
||||
ending: `最终,${profile.nickname}站在了行业的顶峰,实现了"${theme}"的宏愿。回首往事,轻舟已过万重山,${profile.gender === 'male' ? '他' : '她'}终于成为了自己想成为的人。`
|
||||
},
|
||||
love: {
|
||||
intro: `在${valleyEvent}的日子里,${profile.nickname}习惯了独自一人。${profile.mbti}的特质让${profile.gender === 'male' ? '他' : '她'}虽然渴望爱,却更害怕受伤。`,
|
||||
turning: `直到那次${hobby}社团的聚会,命运的齿轮开始转动。${profile.nickname}不经意间流露出的${ADJECTIVES[profile.mbti]?.[0] || '独特'}气质,深深吸引了那个命中注定的人。`,
|
||||
explosion: `面对现实的阻碍和误解,${profile.nickname}没有退缩。${profile.zodiac}赋予的勇气觉醒,${profile.gender === 'male' ? '他' : '她'}坚定地跨越了山海,只为奔赴那份真挚的感情。那一刻,全世界都在为爱让路。`,
|
||||
ending: `正如${profile.futureVision || '童话故事'}里的结局,两人在夕阳下相拥。${profile.nickname}发现,原来最好的爱,是让你成为更好的自己。`
|
||||
},
|
||||
fantasy: {
|
||||
intro: `在这个看似平凡的世界,${profile.nickname}总感觉自己格格不入。${valleyEvent},其实是灵力觉醒前的阵痛。${profile.zodiac}星盘早已预示了不凡的命运。`,
|
||||
turning: `当${hobby}这一媒介触碰到古老的法阵,封印解除!${profile.nickname}发现自己竟然是百年难遇的${profile.mbti}系元素掌控者。`,
|
||||
explosion: `暗黑势力降临城市,绝望蔓延。关键时刻,${profile.nickname}挺身而出,爆发出了${peakEvent}般耀眼的光芒,一击必杀,守护了心中的"${theme}"。`,
|
||||
ending: `成为了传说中的守护神,${profile.nickname}站在云端俯瞰大地。${profile.futureVision || '和平'}的景象映入眼帘,传奇才刚刚开始。`
|
||||
}
|
||||
};
|
||||
|
||||
const t = templates[requirements.style] || templates.career;
|
||||
|
||||
return {
|
||||
id,
|
||||
createdAt: new Date().toISOString(),
|
||||
title: requirements.theme.substring(0, 10) + (requirements.style === 'fantasy' ? '·觉醒篇' : requirements.style === 'love' ? '·情缘篇' : '·逆袭篇'),
|
||||
theme: requirements.theme,
|
||||
style: requirements.style,
|
||||
plot: t
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate implementation path based on script.
|
||||
*/
|
||||
async generatePath(script, profile) {
|
||||
await sleep(1500);
|
||||
|
||||
const hobby = (profile.hobbies && profile.hobbies.length > 0) ? profile.hobbies[0] : "关键技能";
|
||||
|
||||
return {
|
||||
id: Date.now().toString(),
|
||||
scriptId: script.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
steps: [
|
||||
{
|
||||
phase: "第一阶段:沉淀与积累",
|
||||
time: "第1-2个月",
|
||||
content: `针对剧本中提到的"${hobby}",开始系统性学习。不要急于求成,利用${profile.mbti}擅长的深度思考,打好基础。`,
|
||||
action: `购买两本高评分书籍或订阅一个专业专栏,每天坚持阅读学习45分钟。`,
|
||||
resources: `专业书籍、在线MOOC平台`,
|
||||
habit: `建立"日落复盘"机制,记录当天的收获。`
|
||||
},
|
||||
{
|
||||
phase: "第二阶段:破局尝试",
|
||||
time: "第3-6个月",
|
||||
content: `正如剧本中"${script.plot.turning.substring(0, 10)}..."所描述的,寻找一个小型的实践机会。将知识转化为行动。`,
|
||||
action: `完成一个最小可行性项目(Side Project)并在社交媒体分享。`,
|
||||
resources: `GitHub/Behance/小红书等展示平台`,
|
||||
habit: `每周连接一位同频的伙伴。`
|
||||
},
|
||||
{
|
||||
phase: "第三阶段:爆发冲刺",
|
||||
time: "第6-12个月",
|
||||
content: `制造你的高光时刻。${profile.futureVision ? '向着"' + profile.futureVision + '"靠近' : '向着行业头部进发'}。主动承担高难度任务。`,
|
||||
action: `参加一次行业比赛,或在团队中主导一个关键项目。`,
|
||||
resources: `导师资源、行业峰会`,
|
||||
habit: `冥想与可视化练习,强化自信心。`
|
||||
},
|
||||
{
|
||||
phase: "第四阶段:愿景显化",
|
||||
time: "1年后",
|
||||
content: `将能力转化为影响力,实现"${script.theme}"。保持谦逊,同时不吝啬展示自己的光芒。`,
|
||||
action: `整理你的方法论,开设分享会或撰写系列文章。`,
|
||||
resources: `个人品牌渠道`,
|
||||
habit: `终身学习,保持空杯心态。`
|
||||
}
|
||||
]
|
||||
};
|
||||
/**
|
||||
* AI 服务对象
|
||||
* 封装所有 AI 相关的 API 调用
|
||||
*/
|
||||
export const AIService = {
|
||||
/**
|
||||
* 基础 AI 请求方法
|
||||
* @param {string} prompt - 用户提示词
|
||||
* @param {string} systemMsg - 系统消息
|
||||
* @returns {Promise<string>} AI 响应内容
|
||||
*/
|
||||
async fetchAI(prompt, systemMsg) {
|
||||
try {
|
||||
const response = await fetch(BASE_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "deepseek/deepseek-chat-v3-0324:free",
|
||||
messages: [
|
||||
{ role: "system", content: systemMsg },
|
||||
{ role: "user", content: prompt }
|
||||
]
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
return data.choices[0].message.content;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return "(AI 暂时陷入了沉思,请稍后再试)";
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 分析生命事件
|
||||
* 从用户记录的事件中发掘成长的力量,提供情感价值、成长总结和疗愈鼓励
|
||||
* @param {Object} event - 生命事件对象
|
||||
* @param {string} event.title - 事件标题
|
||||
* @param {string} event.time - 事件时间
|
||||
* @param {string} event.content - 事件内容
|
||||
* @returns {Promise<string>} AI 分析反馈
|
||||
*/
|
||||
async analyzeLifeEvent(event) {
|
||||
const system = "你是一位温柔的生命引路人,擅长从平凡事件中发掘成长的力量。请分析用户记录的事件,提供情感价值、成长总结和疗愈鼓励。保持字数在150字左右。";
|
||||
const prompt = `事件标题:${event.title}\n时间:${event.time}\n内容:${event.content}`;
|
||||
return this.fetchAI(prompt, system);
|
||||
},
|
||||
|
||||
/**
|
||||
* 生成爽文剧本
|
||||
* 根据用户的角色设定和过往经历,生成充满爽感的未来人生剧本
|
||||
* @param {Object} params - 剧本参数
|
||||
* @param {Object} params.character - 角色信息
|
||||
* @param {string} params.character.nickname - 昵称
|
||||
* @param {string} params.character.mbti - MBTI 类型
|
||||
* @param {Array<string>} params.character.hobbies - 兴趣爱好
|
||||
* @param {string} params.character.zodiac - 星座
|
||||
* @param {string} params.theme - 剧本主题
|
||||
* @param {string} params.style - 叙事风格
|
||||
* @param {string} params.length - 篇幅要求
|
||||
* @param {Array<Object>} events - 生命事件列表
|
||||
* @returns {Promise<string>} 生成的剧本内容
|
||||
*/
|
||||
async generateEpicScript(params, events) {
|
||||
const system = `你是一位金牌爽文编剧。根据用户的角色设定和过往经历,生成一段符合用户设定、充满爽感的未来人生剧本。剧本必须包含起承转合,使用【标题】标记段落。`;
|
||||
const charInfo = `姓名:${params.character.nickname}, 性格:${params.character.mbti}, 兴趣:${params.character.hobbies.join(',')}, 星座:${params.character.zodiac}`;
|
||||
const eventSummary = events.map(e => e.title).join(', ');
|
||||
const prompt = `角色信息:${charInfo}\n过往经历关键词:${eventSummary}\n用户指定主题:${params.theme}\n指定风格:${params.style}\n篇幅要求:${params.length}\n\n请以此创作一段热血、精彩的人生剧本。`;
|
||||
return this.fetchAI(prompt, system);
|
||||
},
|
||||
|
||||
/**
|
||||
* 生成实现路径
|
||||
* 将剧本拆解为现实中可操作的路径
|
||||
* @param {string} script - 剧本内容
|
||||
* @returns {Promise<string>} 生成的路径规划
|
||||
*/
|
||||
async generatePath(script) {
|
||||
const system = "你是一位人生规划导师。请将用户生成的剧本拆解为现实中可操作的路径。使用【阶段名称】加上具体建议。务必客观、可执行。";
|
||||
return this.fetchAI(script, system);
|
||||
}
|
||||
};
|
||||
|
||||
export default AIService;
|
||||
|
||||
/**
|
||||
* AI 别名导出
|
||||
* 兼容现有视图组件的导入方式
|
||||
*/
|
||||
export const AI = {
|
||||
/**
|
||||
* 生成 AI 回复(用于生命轨迹)
|
||||
*/
|
||||
async generateReply(content, userProfile) {
|
||||
const system = "你是一位温柔的生命引路人,擅长从平凡事件中发掘成长的力量。请分析用户记录的事件,提供情感价值、成长总结和疗愈鼓励。保持字数在150字左右。";
|
||||
const prompt = `用户信息:${userProfile.nickname || '旅人'},${userProfile.mbti || '未知'}类型\n\n用户分享:${content}`;
|
||||
return AIService.fetchAI(prompt, system);
|
||||
},
|
||||
|
||||
/**
|
||||
* 生成剧本
|
||||
*/
|
||||
async generateScript(userProfile, lifeTimeline, requirements) {
|
||||
const system = `你是一位金牌爽文编剧。根据用户的角色设定和过往经历,生成一段符合用户设定、充满爽感的未来人生剧本。
|
||||
|
||||
请按以下JSON格式返回(不要包含markdown代码块标记):
|
||||
{
|
||||
"title": "剧本标题",
|
||||
"plot": {
|
||||
"intro": "序幕内容",
|
||||
"turning": "转折内容",
|
||||
"climax": "高潮内容",
|
||||
"ending": "结局内容"
|
||||
}
|
||||
}`;
|
||||
|
||||
const charInfo = `姓名:${userProfile.nickname}, 性格:${userProfile.mbti}, 兴趣:${(userProfile.hobbies || []).join(',')}, 星座:${userProfile.zodiac}`;
|
||||
const eventSummary = (lifeTimeline || []).map(e => e.title).join(', ');
|
||||
const styleMap = { career: '职场逆袭', love: '情感圆满', fantasy: '玄幻觉醒' };
|
||||
|
||||
const prompt = `角色信息:${charInfo}
|
||||
过往经历关键词:${eventSummary || '暂无'}
|
||||
用户指定主题:${requirements.theme}
|
||||
指定风格:${styleMap[requirements.style] || requirements.style}
|
||||
篇幅要求:${requirements.length === 'long' ? '长篇' : '标准篇'}
|
||||
|
||||
请以此创作一段热血、精彩的人生剧本。`;
|
||||
|
||||
const response = await AIService.fetchAI(prompt, system);
|
||||
|
||||
// 尝试解析 JSON 响应
|
||||
try {
|
||||
const parsed = JSON.parse(response.replace(/```json\n?|\n?```/g, '').trim());
|
||||
return {
|
||||
id: Date.now(),
|
||||
title: parsed.title || requirements.theme,
|
||||
style: requirements.style,
|
||||
length: requirements.length,
|
||||
plot: parsed.plot || { intro: response, turning: '', climax: '', ending: '' },
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
} catch (e) {
|
||||
// 如果解析失败,返回原始文本
|
||||
return {
|
||||
id: Date.now(),
|
||||
title: requirements.theme,
|
||||
style: requirements.style,
|
||||
length: requirements.length,
|
||||
plot: { intro: response, turning: '', climax: '', ending: '' },
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 生成实现路径
|
||||
*/
|
||||
async generatePath(script, userProfile) {
|
||||
const system = `你是一位人生规划导师。请将用户生成的剧本拆解为现实中可操作的路径。
|
||||
|
||||
请按以下JSON格式返回(不要包含markdown代码块标记):
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"phase": "阶段名称",
|
||||
"time": "时间范围",
|
||||
"content": "核心策略",
|
||||
"action": "关键行动",
|
||||
"resources": "所需资源",
|
||||
"habit": "养成习惯"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
请生成3-5个阶段。`;
|
||||
|
||||
const scriptContent = script.plot
|
||||
? `序幕:${script.plot.intro}\n转折:${script.plot.turning}\n高潮:${script.plot.climax}\n结局:${script.plot.ending}`
|
||||
: script.content || '';
|
||||
|
||||
const prompt = `剧本标题:${script.title}
|
||||
剧本内容:${scriptContent}
|
||||
|
||||
请基于此剧本,为用户规划可执行的实现路径。`;
|
||||
|
||||
const response = await AIService.fetchAI(prompt, system);
|
||||
|
||||
// 尝试解析 JSON 响应
|
||||
try {
|
||||
const parsed = JSON.parse(response.replace(/```json\n?|\n?```/g, '').trim());
|
||||
return {
|
||||
id: Date.now(),
|
||||
scriptId: script.id,
|
||||
steps: parsed.steps || [],
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
} catch (e) {
|
||||
// 如果解析失败,尝试从文本中提取阶段
|
||||
const steps = response.split(/【/).filter(s => s.trim()).map((s, i) => {
|
||||
const parts = s.split(/】/);
|
||||
return {
|
||||
phase: parts[0] || `阶段 ${i + 1}`,
|
||||
time: '待定',
|
||||
content: parts[1] || s,
|
||||
action: '',
|
||||
resources: '',
|
||||
habit: ''
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: Date.now(),
|
||||
scriptId: script.id,
|
||||
steps: steps.length > 0 ? steps : [{ phase: '规划中', time: '待定', content: response, action: '', resources: '', habit: '' }],
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -21,9 +21,11 @@ const DEFAULT_STATE = {
|
||||
},
|
||||
futureVision: ""
|
||||
},
|
||||
lifeTimeline: [],
|
||||
generatedScripts: [],
|
||||
paths: []
|
||||
lifeTimeline: [], // 生命事件列表
|
||||
generatedScripts: [], // 生成的剧本列表
|
||||
paths: [], // 实现路径列表
|
||||
selectedScriptId: null, // 当前选中的剧本ID
|
||||
selectedPath: null // 当前选中的路径内容
|
||||
};
|
||||
|
||||
export const Store = {
|
||||
@@ -109,5 +111,61 @@ export const Store = {
|
||||
const data = this.get();
|
||||
data.paths = data.paths.filter(p => p.id !== id);
|
||||
this.save(data);
|
||||
},
|
||||
|
||||
/**
|
||||
* 选择剧本
|
||||
* @param {number} id - 剧本ID
|
||||
*/
|
||||
selectScript(id) {
|
||||
const data = this.get();
|
||||
data.selectedScriptId = id;
|
||||
this.save(data);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取当前选中的剧本
|
||||
* @returns {Object|null} 选中的剧本对象
|
||||
*/
|
||||
getSelectedScript() {
|
||||
const data = this.get();
|
||||
if (!data.selectedScriptId) return null;
|
||||
return data.generatedScripts.find(s => s.id === data.selectedScriptId) || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置当前路径
|
||||
* @param {string} path - 路径内容
|
||||
*/
|
||||
setSelectedPath(path) {
|
||||
const data = this.get();
|
||||
data.selectedPath = path;
|
||||
this.save(data);
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加剧本并自动选中
|
||||
* @param {Object} script - 剧本对象
|
||||
*/
|
||||
addScriptAndSelect(script) {
|
||||
const data = this.get();
|
||||
const newScript = {
|
||||
...script,
|
||||
id: Date.now(),
|
||||
date: new Date().toLocaleDateString()
|
||||
};
|
||||
data.generatedScripts.unshift(newScript);
|
||||
data.selectedScriptId = newScript.id;
|
||||
this.save(data);
|
||||
return newScript;
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除所有数据并退出
|
||||
*/
|
||||
clear() {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem('token');
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
/**
|
||||
* Vitest 配置文件
|
||||
* 用于运行单元测试和属性测试
|
||||
*/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.js'],
|
||||
include: ['src/**/*.{test,spec}.{js,jsx}'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user