前端重构实现

This commit is contained in:
2025-12-22 16:38:06 +08:00
parent cd6d995d5a
commit 26574e3db7
54 changed files with 8976 additions and 0 deletions
+42
View File
@@ -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;
}
+140
View File
@@ -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;
+1
View File
@@ -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

+29
View File
@@ -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;
+87
View File
@@ -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;
+40
View File
@@ -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;
+8
View File
@@ -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';
+44
View File
@@ -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;
+272
View File
@@ -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;
}
}
+14
View File
@@ -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>
);
+86
View File
@@ -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;
+145
View File
@@ -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;
+158
View File
@@ -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;
+82
View File
@@ -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
};
+69
View File
@@ -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;
+131
View File
@@ -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
};
+215
View File
@@ -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
};
+18
View File
@@ -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';
+164
View File
@@ -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
};
+211
View File
@@ -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
};
+172
View File
@@ -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
};
+489
View File
@@ -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;
+45
View File
@@ -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: '勾勒你对理想生活的全部向往。' }
];
+143
View File
@@ -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;
+214
View File
@@ -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;
+219
View File
@@ -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;
+171
View File
@@ -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;