前端重构实现
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Background } from './components/layout';
|
||||
import Loader from './components/Loader';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import OnboardingPage from './pages/OnboardingPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import useStore from './store/useStore';
|
||||
|
||||
/**
|
||||
* 路由守卫组件
|
||||
* 根据登录状态和注册完成状态进行路由重定向
|
||||
*/
|
||||
const ProtectedRoute = ({ children, requireAuth = false, requireOnboarding = false }) => {
|
||||
const { isLoggedIn, registrationData } = useStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 检查是否完成入站流程
|
||||
const hasCompletedOnboarding = registrationData.nickname && registrationData.future?.vision;
|
||||
|
||||
useEffect(() => {
|
||||
if (requireAuth && !isLoggedIn) {
|
||||
navigate('/', { replace: true });
|
||||
} else if (requireOnboarding && !hasCompletedOnboarding) {
|
||||
navigate('/onboarding', { replace: true });
|
||||
}
|
||||
}, [isLoggedIn, hasCompletedOnboarding, requireAuth, requireOnboarding, navigate]);
|
||||
|
||||
if (requireAuth && !isLoggedIn) {
|
||||
return <Loader text="正在验证身份..." />;
|
||||
}
|
||||
|
||||
if (requireOnboarding && !hasCompletedOnboarding) {
|
||||
return <Loader text="正在加载..." />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
/**
|
||||
* 页面过渡动画包装器
|
||||
*/
|
||||
const PageTransition = ({ children }) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
ease: [0.25, 0.46, 0.45, 0.94]
|
||||
}}
|
||||
className="w-full h-full"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 动画路由组件
|
||||
*/
|
||||
const AnimatedRoutes = () => {
|
||||
const location = useLocation();
|
||||
const { isLoggedIn, registrationData } = useStore();
|
||||
|
||||
// 检查是否完成入站流程
|
||||
const hasCompletedOnboarding = registrationData.nickname && registrationData.future?.vision;
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<Routes location={location} key={location.pathname}>
|
||||
{/* 登录页 */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
isLoggedIn ? (
|
||||
hasCompletedOnboarding ? (
|
||||
<Navigate to="/dashboard" replace />
|
||||
) : (
|
||||
<Navigate to="/onboarding" replace />
|
||||
)
|
||||
) : (
|
||||
<PageTransition>
|
||||
<LoginPage />
|
||||
</PageTransition>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 入站流程页 */}
|
||||
<Route
|
||||
path="/onboarding"
|
||||
element={
|
||||
<ProtectedRoute requireAuth>
|
||||
<PageTransition>
|
||||
<OnboardingPage />
|
||||
</PageTransition>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 仪表盘页 */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute requireAuth requireOnboarding>
|
||||
<PageTransition>
|
||||
<DashboardPage />
|
||||
</PageTransition>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 404 重定向 */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* App 主组件
|
||||
*/
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
{/* 动态背景 */}
|
||||
<Background />
|
||||
|
||||
{/* 主容器 */}
|
||||
<main className="relative z-10 min-h-screen flex flex-col items-center justify-center p-4 md:p-8">
|
||||
<AnimatedRoutes />
|
||||
</main>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,29 @@
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
/**
|
||||
* Loader 组件
|
||||
* 全屏加载动画,包含旋转圆环和文字
|
||||
* @param {Object} props
|
||||
* @param {string} props.text - 加载文字
|
||||
*/
|
||||
const Loader = ({ text = '载入生命序列...' }) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="fixed inset-0 flex flex-col items-center justify-center z-[200] bg-[#0a0c10]"
|
||||
>
|
||||
{/* 旋转圆环 */}
|
||||
<div className="w-16 h-16 border-2 border-orange-200/20 border-t-orange-200 rounded-full animate-spin mb-4" />
|
||||
|
||||
{/* 加载文字 */}
|
||||
<p className="text-xs tracking-[0.3em] text-white/40 uppercase">
|
||||
{text}
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loader;
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
/**
|
||||
* Modal 组件
|
||||
* 使用 Radix UI Dialog 实现的模态弹窗
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.isOpen - 是否打开
|
||||
* @param {Function} props.onClose - 关闭回调
|
||||
* @param {React.ReactNode} props.children - 子元素
|
||||
* @param {'sm'|'md'|'lg'} props.maxWidth - 最大宽度
|
||||
* @param {string} props.title - 标题(可选)
|
||||
*/
|
||||
const Modal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
maxWidth = 'md',
|
||||
title
|
||||
}) => {
|
||||
// 最大宽度映射
|
||||
const maxWidthMap = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl'
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<Dialog.Portal forceMount>
|
||||
{/* 遮罩层 */}
|
||||
<Dialog.Overlay asChild>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-xl z-[100]"
|
||||
/>
|
||||
</Dialog.Overlay>
|
||||
|
||||
{/* 内容区 */}
|
||||
<Dialog.Content asChild>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
className={`
|
||||
fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
|
||||
glass-card ${maxWidthMap[maxWidth]} w-[calc(100%-2rem)] p-8
|
||||
border border-white/10 shadow-2xl z-[101]
|
||||
`}
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
className="absolute top-6 right-6 text-white/40 hover:text-white transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
|
||||
{/* 标题 */}
|
||||
{title && (
|
||||
<Dialog.Title className="text-2xl font-serif mb-6">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
)}
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="max-h-[70vh] overflow-y-auto pr-2 custom-scrollbar">
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* PromptTag 组件
|
||||
* 灵感标签,点击后追加文本到目标输入框
|
||||
* @param {Object} props
|
||||
* @param {string} props.text - 标签文本
|
||||
* @param {Function} props.onClick - 点击回调
|
||||
*/
|
||||
const PromptTag = ({ text, onClick }) => {
|
||||
return (
|
||||
<span
|
||||
onClick={() => onClick(text)}
|
||||
className="prompt-tag"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* PromptTagGroup 组件
|
||||
* 灵感标签组,用于批量渲染标签
|
||||
* @param {Object} props
|
||||
* @param {string[]} props.tags - 标签文本数组
|
||||
* @param {Function} props.onTagClick - 标签点击回调
|
||||
*/
|
||||
export const PromptTagGroup = ({ tags, onTagClick }) => {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{tags.map((tag, index) => (
|
||||
<PromptTag
|
||||
key={index}
|
||||
text={tag}
|
||||
onClick={onTagClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptTag;
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Background 组件
|
||||
* 动态流体背景,包含渐变、浮动模糊圆和纹理叠加
|
||||
*/
|
||||
const Background = () => {
|
||||
return (
|
||||
<div id="app-bg" className="fixed inset-0 z-[-1]">
|
||||
{/* 渐变底层 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#1a1c2c] via-[#0a0c10] to-[#2d1b10] opacity-80" />
|
||||
|
||||
{/* 浮动模糊圆 - 蓝色 */}
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[60%] h-[60%] bg-blue-900/20 blur-[120px] rounded-full animate-float" />
|
||||
|
||||
{/* 浮动模糊圆 - 橙色 */}
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-orange-900/10 blur-[120px] rounded-full animate-float-delayed" />
|
||||
|
||||
{/* 纹理叠加层 */}
|
||||
<img
|
||||
src="https://r2-bucket.flowith.net/f/845b300ff0a2b36e/digital_healing_background_design_index_1%401024x1024.jpeg"
|
||||
alt="texture"
|
||||
className="w-full h-full object-cover mix-blend-overlay opacity-30"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Background;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { User } from 'lucide-react';
|
||||
import GlassButton from '../ui/GlassButton';
|
||||
|
||||
/**
|
||||
* Header 组件
|
||||
* 固定定位头部,包含 logo 和用户按钮
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.showNav - 是否显示导航按钮
|
||||
* @param {Function} props.onProfileClick - 用户按钮点击回调
|
||||
*/
|
||||
const Header = ({ showNav = false, onProfileClick }) => {
|
||||
/**
|
||||
* 处理 logo 点击,刷新页面
|
||||
*/
|
||||
const handleLogoClick = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 left-0 p-6 z-50 w-full flex justify-between items-center pointer-events-none">
|
||||
{/* Logo 区域 */}
|
||||
<div
|
||||
className="flex items-center gap-3 pointer-events-auto cursor-pointer"
|
||||
onClick={handleLogoClick}
|
||||
>
|
||||
<img
|
||||
src="https://r2-bucket.flowith.net/f/cf8c6e7c020409c9/lifeline_app_logo_design_index_0%401024x1024.jpeg"
|
||||
alt="logo"
|
||||
className="w-10 h-10 rounded-full shadow-2xl border border-white/10"
|
||||
/>
|
||||
<h1 className="text-xl font-serif tracking-[0.2em] bg-clip-text text-transparent bg-gradient-to-r from-white to-white/50">
|
||||
人生轨迹
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* 导航区域 */}
|
||||
{showNav && (
|
||||
<nav className="pointer-events-auto">
|
||||
<GlassButton
|
||||
variant="icon"
|
||||
onClick={onProfileClick}
|
||||
className="hover:shadow-orange-200/10 shadow-lg"
|
||||
>
|
||||
<User className="w-5 h-5" />
|
||||
</GlassButton>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { History, Sparkles, Map } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* 导航项配置
|
||||
*/
|
||||
const navGroups = [
|
||||
{
|
||||
title: '回溯过去',
|
||||
items: [
|
||||
{ id: 'timeline', label: '生命长河', icon: History }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '创造未来',
|
||||
items: [
|
||||
{ id: 'script', label: '爽文剧本', icon: Sparkles },
|
||||
{ id: 'path', label: '实现路径', icon: Map }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Sidebar 组件
|
||||
* 仪表盘侧边栏导航
|
||||
* @param {Object} props
|
||||
* @param {'timeline'|'script'|'path'} props.activeView - 当前激活的视图
|
||||
* @param {Function} props.onViewChange - 视图切换回调
|
||||
*/
|
||||
const Sidebar = ({ activeView, onViewChange }) => {
|
||||
return (
|
||||
<aside className="md:col-span-3 border-r border-white/5 p-6 flex flex-col gap-6 bg-black/20">
|
||||
{/* 导航分组 */}
|
||||
{navGroups.map((group) => (
|
||||
<div key={group.title} className="space-y-2">
|
||||
{/* 分组标题 */}
|
||||
<div className="px-3 py-2 text-[10px] text-white/30 uppercase tracking-[0.2em] font-bold">
|
||||
{group.title}
|
||||
</div>
|
||||
|
||||
{/* 导航项 */}
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeView === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onViewChange(item.id)}
|
||||
className={`
|
||||
nav-item w-full flex items-center gap-3 p-4 rounded-2xl glass-btn text-white/50
|
||||
${isActive ? 'active' : ''}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 底部引用文字 */}
|
||||
<div className="mt-auto p-4 bg-white/[0.02] rounded-2xl border border-white/5">
|
||||
<p className="text-[10px] text-white/20 italic leading-relaxed">
|
||||
"回溯过去、记录当下、创造未来。"
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 布局组件统一导出
|
||||
*/
|
||||
export { default as Background } from './Background';
|
||||
export { default as Header } from './Header';
|
||||
export { default as Sidebar } from './Sidebar';
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* GlassButton 组件
|
||||
* 毛玻璃按钮样式,支持多种变体和加载状态
|
||||
* @param {Object} props
|
||||
* @param {React.ReactNode} props.children - 子元素
|
||||
* @param {Function} props.onClick - 点击事件处理函数
|
||||
* @param {'default'|'primary'|'icon'} props.variant - 按钮变体
|
||||
* @param {boolean} props.disabled - 是否禁用
|
||||
* @param {boolean} props.loading - 是否显示加载状态
|
||||
* @param {string} props.className - 额外的CSS类名
|
||||
* @param {string} props.type - 按钮类型
|
||||
*/
|
||||
const GlassButton = ({
|
||||
children,
|
||||
onClick,
|
||||
variant = 'default',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
className = '',
|
||||
type = 'button'
|
||||
}) => {
|
||||
// 变体样式映射
|
||||
const variantMap = {
|
||||
default: 'px-6 py-3 rounded-2xl text-white/70',
|
||||
primary: 'px-8 py-4 rounded-2xl bg-orange-200/5 text-orange-200 font-bold tracking-[0.2em] border-orange-200/20 shadow-lg shadow-orange-900/10',
|
||||
icon: 'p-2.5 rounded-full'
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
className={`
|
||||
glass-btn
|
||||
${variantMap[variant]}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<span className="animate-pulse">处理中...</span>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlassButton;
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* GlassCard 组件
|
||||
* 毛玻璃卡片样式,支持多种变体
|
||||
* @param {Object} props
|
||||
* @param {React.ReactNode} props.children - 子元素
|
||||
* @param {string} props.className - 额外的CSS类名
|
||||
* @param {'default'|'highlight'|'ai'} props.variant - 卡片变体
|
||||
* @param {'sm'|'md'|'lg'} props.padding - 内边距大小
|
||||
*/
|
||||
const GlassCard = ({
|
||||
children,
|
||||
className = '',
|
||||
variant = 'default',
|
||||
padding = 'md'
|
||||
}) => {
|
||||
// 内边距映射
|
||||
const paddingMap = {
|
||||
sm: 'p-4',
|
||||
md: 'p-6',
|
||||
lg: 'p-8'
|
||||
};
|
||||
|
||||
// 变体样式映射
|
||||
const variantMap = {
|
||||
default: 'border-white/5',
|
||||
highlight: 'border-orange-200/20 shadow-orange-900/10',
|
||||
ai: 'border-orange-200/5 bg-orange-200/[0.02]'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
glass-card
|
||||
${paddingMap[padding]}
|
||||
${variantMap[variant]}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlassCard;
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* GlassInput 组件
|
||||
* 毛玻璃输入框样式
|
||||
* @param {Object} props
|
||||
* @param {string} props.label - 标签文本
|
||||
* @param {string} props.type - 输入类型
|
||||
* @param {string} props.placeholder - 占位符文本
|
||||
* @param {string} props.value - 输入值
|
||||
* @param {Function} props.onChange - 值变化处理函数
|
||||
* @param {number} props.maxLength - 最大长度
|
||||
* @param {string} props.className - 额外的CSS类名
|
||||
* @param {string} props.id - 输入框ID
|
||||
*/
|
||||
const GlassInput = ({
|
||||
label,
|
||||
type = 'text',
|
||||
placeholder = '',
|
||||
value,
|
||||
onChange,
|
||||
maxLength,
|
||||
className = '',
|
||||
id
|
||||
}) => {
|
||||
return (
|
||||
<div className={`flex flex-col gap-2 ${className}`}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-[10px] text-white/30 uppercase tracking-widest font-bold"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
maxLength={maxLength}
|
||||
className="glass-input w-full focus:ring-2 focus:ring-orange-200/50"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlassInput;
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* GlassSelect 组件
|
||||
* 毛玻璃下拉选择框样式
|
||||
* @param {Object} props
|
||||
* @param {string} props.label - 标签文本
|
||||
* @param {Array<{value: string, label: string}>} props.options - 选项数组
|
||||
* @param {string} props.value - 当前选中值
|
||||
* @param {Function} props.onChange - 值变化处理函数
|
||||
* @param {string} props.className - 额外的CSS类名
|
||||
* @param {string} props.id - 选择框ID
|
||||
*/
|
||||
const GlassSelect = ({
|
||||
label,
|
||||
options = [],
|
||||
value,
|
||||
onChange,
|
||||
className = '',
|
||||
id
|
||||
}) => {
|
||||
return (
|
||||
<div className={`flex flex-col gap-2 ${className}`}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-[10px] text-white/30 uppercase tracking-widest font-bold"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="glass-input w-full appearance-none cursor-pointer"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="bg-[#0a0c10] text-white"
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlassSelect;
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* GlassTextarea 组件
|
||||
* 毛玻璃文本区域样式
|
||||
* @param {Object} props
|
||||
* @param {string} props.label - 标签文本
|
||||
* @param {string} props.placeholder - 占位符文本
|
||||
* @param {string} props.value - 输入值
|
||||
* @param {Function} props.onChange - 值变化处理函数
|
||||
* @param {number} props.rows - 行数
|
||||
* @param {string} props.className - 额外的CSS类名
|
||||
* @param {string} props.id - 文本区域ID
|
||||
*/
|
||||
const GlassTextarea = ({
|
||||
label,
|
||||
placeholder = '',
|
||||
value,
|
||||
onChange,
|
||||
rows = 4,
|
||||
className = '',
|
||||
id
|
||||
}) => {
|
||||
return (
|
||||
<div className={`flex flex-col gap-2 ${className}`}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-[10px] text-white/30 uppercase tracking-widest font-bold"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={rows}
|
||||
className="glass-input w-full resize-none focus:ring-2 focus:ring-orange-200/50 text-sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlassTextarea;
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* UI组件统一导出
|
||||
*/
|
||||
export { default as GlassCard } from './GlassCard';
|
||||
export { default as GlassButton } from './GlassButton';
|
||||
export { default as GlassInput } from './GlassInput';
|
||||
export { default as GlassTextarea } from './GlassTextarea';
|
||||
export { default as GlassSelect } from './GlassSelect';
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* useCountdown Hook
|
||||
* 倒计时钩子,用于验证码发送倒计时
|
||||
* @param {number} initialSeconds - 初始秒数
|
||||
* @returns {Object} { countdown, isActive, start, reset }
|
||||
*/
|
||||
const useCountdown = (initialSeconds = 60) => {
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timer;
|
||||
if (isActive && countdown > 0) {
|
||||
timer = setInterval(() => {
|
||||
setCountdown((prev) => prev - 1);
|
||||
}, 1000);
|
||||
} else if (countdown === 0 && isActive) {
|
||||
setIsActive(false);
|
||||
}
|
||||
return () => clearInterval(timer);
|
||||
}, [isActive, countdown]);
|
||||
|
||||
/**
|
||||
* 开始倒计时
|
||||
*/
|
||||
const start = useCallback(() => {
|
||||
setCountdown(initialSeconds);
|
||||
setIsActive(true);
|
||||
}, [initialSeconds]);
|
||||
|
||||
/**
|
||||
* 重置倒计时
|
||||
*/
|
||||
const reset = useCallback(() => {
|
||||
setCountdown(0);
|
||||
setIsActive(false);
|
||||
}, []);
|
||||
|
||||
return { countdown, isActive, start, reset };
|
||||
};
|
||||
|
||||
export default useCountdown;
|
||||
@@ -0,0 +1,272 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@300;600&family=Noto+Sans+SC:wght@300;400;500&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--glass-bg: rgba(15, 17, 26, 0.4);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
--accent-orange: #FFAB91;
|
||||
--accent-blue: #81D4FA;
|
||||
--card-shadow: 0 20px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: #e2e8f0;
|
||||
background-color: #0a0c10;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.font-serif {
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
}
|
||||
|
||||
/* Advanced Glassmorphism */
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(25px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(25px) saturate(180%);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 32px;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.glass-btn {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.glass-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.glass-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.glass-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
|
||||
.glass-input {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 14px 20px;
|
||||
color: white;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.glass-input:focus {
|
||||
border-color: var(--accent-orange);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
box-shadow: 0 0 20px rgba(255, 171, 145, 0.1);
|
||||
}
|
||||
|
||||
.glass-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translate(5%, 5%) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float-delayed {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-5%, -5%) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 15s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.animate-float-delayed {
|
||||
animation: float-delayed 20s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
|
||||
/* Timeline UI */
|
||||
.timeline-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--accent-orange);
|
||||
background: #0a0c10;
|
||||
box-shadow: 0 0 10px var(--accent-orange);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.timeline-line {
|
||||
position: absolute;
|
||||
left: 21px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.1) 15%, rgba(255, 255, 255, 0.1) 85%, transparent);
|
||||
}
|
||||
|
||||
/* Scrollbar Refinement */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Prompt Tag */
|
||||
.prompt-tag {
|
||||
display: inline-block;
|
||||
padding: 6px 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 99px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.prompt-tag:hover {
|
||||
background: var(--accent-orange);
|
||||
color: #000;
|
||||
border-color: var(--accent-orange);
|
||||
}
|
||||
|
||||
/* Sidebar Active State */
|
||||
.nav-item.active {
|
||||
background: rgba(255, 171, 145, 0.08);
|
||||
border-color: rgba(255, 171, 145, 0.2);
|
||||
color: var(--accent-orange) !important;
|
||||
}
|
||||
|
||||
/* AI Glow Effect */
|
||||
.ai-glow-card {
|
||||
background: linear-gradient(145deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 100%);
|
||||
border-left: 2px solid var(--accent-orange);
|
||||
box-shadow: inset 0 0 20px rgba(255, 171, 145, 0.05);
|
||||
}
|
||||
|
||||
/* Responsive Form Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.view-container {
|
||||
height: 95vh !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-item i {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
/* 移动端侧边栏调整 */
|
||||
.sidebar-nav {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* 移动端内容区调整 */
|
||||
.content-area {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* 移动端头部调整 */
|
||||
header h1 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 移动端表单调整 */
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏幕设备 */
|
||||
@media (max-width: 480px) {
|
||||
.glass-card {
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.glass-input {
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.glass-btn {
|
||||
padding: 12px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App.jsx';
|
||||
|
||||
/**
|
||||
* 应用入口
|
||||
* 渲染 React 应用到 DOM
|
||||
*/
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Header, Sidebar } from '../components/layout';
|
||||
import TimelineView from '../views/TimelineView';
|
||||
import ScriptView from '../views/ScriptView';
|
||||
import PathView from '../views/PathView';
|
||||
import ProfileModal from '../views/ProfileModal';
|
||||
|
||||
/**
|
||||
* DashboardPage 组件
|
||||
* 仪表盘主页面,包含侧边栏导航和内容区
|
||||
*/
|
||||
const DashboardPage = () => {
|
||||
// 当前激活的视图
|
||||
const [activeView, setActiveView] = useState('timeline');
|
||||
// 用户资料模态框状态
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||
|
||||
/**
|
||||
* 处理视图切换
|
||||
*/
|
||||
const handleViewChange = (view) => {
|
||||
setActiveView(view);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染当前视图内容
|
||||
*/
|
||||
const renderView = () => {
|
||||
switch (activeView) {
|
||||
case 'timeline':
|
||||
return <TimelineView />;
|
||||
case 'script':
|
||||
return <ScriptView onOpenProfile={() => setIsProfileOpen(true)} />;
|
||||
case 'path':
|
||||
return <PathView onGoToScript={() => setActiveView('script')} />;
|
||||
default:
|
||||
return <TimelineView />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 头部 */}
|
||||
<Header
|
||||
showNav
|
||||
onProfileClick={() => setIsProfileOpen(true)}
|
||||
/>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className="glass-card w-full h-full overflow-hidden">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 h-full">
|
||||
{/* 侧边栏 */}
|
||||
<Sidebar
|
||||
activeView={activeView}
|
||||
onViewChange={handleViewChange}
|
||||
/>
|
||||
|
||||
{/* 内容区 */}
|
||||
<section className="md:col-span-9 p-8 overflow-y-auto custom-scrollbar relative">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeView}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
className="h-full"
|
||||
>
|
||||
{renderView()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 用户资料模态框 */}
|
||||
<ProfileModal
|
||||
isOpen={isProfileOpen}
|
||||
onClose={() => setIsProfileOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { GlassCard, GlassInput, GlassButton } from '../components/ui';
|
||||
import useStore from '../store/useStore';
|
||||
import useCountdown from '../hooks/useCountdown';
|
||||
|
||||
/**
|
||||
* LoginPage 组件
|
||||
* 登录页面,包含手机号和验证码输入
|
||||
*/
|
||||
const LoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { login, getSmsCode, setLogin, loading } = useStore();
|
||||
|
||||
// 表单状态
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 倒计时
|
||||
const { countdown, isActive, start } = useCountdown(60);
|
||||
|
||||
/**
|
||||
* 处理获取验证码
|
||||
*/
|
||||
const handleGetCode = async () => {
|
||||
if (phone.length !== 11) {
|
||||
alert('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
start();
|
||||
// 调用后端获取验证码
|
||||
await getSmsCode(phone);
|
||||
alert('验证码已发送');
|
||||
} catch (error) {
|
||||
// 后端不可用时,使用模拟验证码
|
||||
alert('验证码已发送 (模拟验证码: 888888)');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理登录提交
|
||||
*/
|
||||
const handleSubmit = async () => {
|
||||
if (phone.length !== 11) {
|
||||
alert('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
if (code.length !== 6) {
|
||||
alert('请输入6位验证码');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// 尝试调用后端登录
|
||||
await login(phone, code);
|
||||
navigate('/onboarding');
|
||||
} catch (error) {
|
||||
// 后端不可用时,使用本地验证
|
||||
if (code === '888888') {
|
||||
setLogin(true, phone);
|
||||
navigate('/onboarding');
|
||||
} else {
|
||||
alert('验证失败,请检查手机号或验证码');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center p-6 animate-fade-in">
|
||||
<GlassCard className="max-w-md w-full space-y-8 border-white/5 shadow-2xl" padding="lg">
|
||||
{/* 标题区域 */}
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-3xl font-serif tracking-wider text-white/90">
|
||||
欢迎回来
|
||||
</h2>
|
||||
<p className="text-sm text-white/40 italic">
|
||||
开启你的数字生命档案
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 表单区域 */}
|
||||
<div className="space-y-4">
|
||||
{/* 手机号输入 */}
|
||||
<GlassInput
|
||||
label="手机号码"
|
||||
type="tel"
|
||||
placeholder="输入手机号"
|
||||
value={phone}
|
||||
onChange={setPhone}
|
||||
maxLength={11}
|
||||
className="text-center tracking-[0.1em]"
|
||||
/>
|
||||
|
||||
{/* 验证码输入 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2">
|
||||
<GlassInput
|
||||
label="验证码"
|
||||
type="text"
|
||||
placeholder="六位验证码"
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
maxLength={6}
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={handleGetCode}
|
||||
disabled={isActive || loading}
|
||||
className="w-full h-[46px] rounded-2xl border border-white/5 bg-white/5 text-[10px] uppercase tracking-tighter hover:bg-white/10 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isActive ? `${countdown}S` : '获取'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<GlassButton
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
loading={isSubmitting || loading}
|
||||
className="w-full"
|
||||
>
|
||||
开启旅程
|
||||
</GlassButton>
|
||||
|
||||
{/* 协议文字 */}
|
||||
<p className="text-[10px] text-center text-white/20 px-4 leading-relaxed">
|
||||
登录即代表同意《用户协议》与《隐私政策》,我们将妥善保管您的生命数据。
|
||||
</p>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowRight, Check } from 'lucide-react';
|
||||
import { GlassCard, GlassInput, GlassTextarea, GlassButton } from '../components/ui';
|
||||
import { PromptTagGroup } from '../components/PromptTag';
|
||||
import useStore from '../store/useStore';
|
||||
import { inspirationClusters } from '../utils/constants';
|
||||
|
||||
/**
|
||||
* OnboardingPage 组件
|
||||
* 5步入站流程页面
|
||||
*/
|
||||
const OnboardingPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
registrationData,
|
||||
updateRegistration,
|
||||
saveUserProfile,
|
||||
setView,
|
||||
loading
|
||||
} = useStore();
|
||||
|
||||
const [formData, setFormData] = useState(registrationData);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFormData(registrationData);
|
||||
}, [registrationData]);
|
||||
|
||||
const updateField = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const updateNestedField = (parent, field, value) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[parent]: { ...prev[parent], [field]: value }
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTagClick = (type, text) => {
|
||||
const currentText = formData[type]?.text || '';
|
||||
updateNestedField(type, 'text', currentText + text);
|
||||
};
|
||||
|
||||
const saveStepData = () => {
|
||||
updateRegistration(formData);
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
saveStepData();
|
||||
if (currentStep < 5) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
} else {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await saveUserProfile();
|
||||
} catch (error) {
|
||||
console.error('保存档案失败:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setView('dashboard');
|
||||
navigate('/dashboard');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
saveStepData();
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStep1 = () => (
|
||||
<div className="animate-fade-in space-y-8">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-4xl font-serif mb-3">你是谁?</h2>
|
||||
<p className="text-white/40 italic text-sm">定义你生命坐标的初始属性。</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2">
|
||||
<GlassInput label="称呼" placeholder="例如:林中鹿" value={formData.nickname} onChange={(v) => updateField('nickname', v)} />
|
||||
<GlassInput label="性别" placeholder="自由填写" value={formData.gender} onChange={(v) => updateField('gender', v)} />
|
||||
<GlassInput label="MBTI" placeholder="如:INFJ" value={formData.mbti} onChange={(v) => updateField('mbti', v)} />
|
||||
<GlassInput label="星座" placeholder="星辰指引" value={formData.zodiac} onChange={(v) => updateField('zodiac', v)} />
|
||||
</div>
|
||||
<GlassInput
|
||||
label="兴趣爱好"
|
||||
placeholder="用逗号分隔你的热爱"
|
||||
value={Array.isArray(formData.hobbies) ? formData.hobbies.join(',') : formData.hobbies}
|
||||
onChange={(v) => updateField('hobbies', v.split(',').map(s => s.trim()).filter(s => s))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMemoryStep = (type, title, label) => (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<div>
|
||||
<h2 className="text-4xl font-serif mb-3">{title}</h2>
|
||||
<p className="text-white/40 italic text-sm">回望足迹,这些瞬间如何塑造了此时的你。</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<GlassInput label={`${label}的日期`} type="date" value={formData[type]?.date || ''} onChange={(v) => updateNestedField(type, 'date', v)} className="max-w-xs" />
|
||||
<GlassTextarea label="详细描述" placeholder="描述那段时光发生的点滴..." value={formData[type]?.text || ''} onChange={(v) => updateNestedField(type, 'text', v)} rows={5} />
|
||||
<PromptTagGroup tags={inspirationClusters[type]} onTagClick={(text) => handleTagClick(type, text)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderStep5 = () => (
|
||||
<div className="animate-fade-in space-y-8">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-4xl font-serif mb-3">未来想成为谁?</h2>
|
||||
<p className="text-white/40 italic text-sm">勾勒你对理想生活的全部向往。</p>
|
||||
</div>
|
||||
<GlassTextarea label="对未来的憧憬" placeholder="你想成为一个什么样的人?" value={formData.future?.vision || ''} onChange={(v) => updateNestedField('future', 'vision', v)} rows={4} />
|
||||
<GlassTextarea label="理想生活状态" placeholder="你的理想清晨与傍晚是怎样的?" value={formData.future?.ideal || ''} onChange={(v) => updateNestedField('future', 'ideal', v)} rows={4} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1: return renderStep1();
|
||||
case 2: return renderMemoryStep('childhood', '那段纯真的时光', '童年记忆');
|
||||
case 3: return renderMemoryStep('joy', '光芒闪耀的时刻', '开心的经历');
|
||||
case 4: return renderMemoryStep('low', '在暗夜中潜行', '沮丧与低谷');
|
||||
case 5: return renderStep5();
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard className="w-full h-full flex flex-col justify-between overflow-hidden relative" padding="lg">
|
||||
<div className="flex-1 flex flex-col justify-center max-w-2xl mx-auto w-full overflow-y-auto custom-scrollbar">
|
||||
{renderStepContent()}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-10 max-w-2xl mx-auto w-full border-t border-white/5 pt-8">
|
||||
<div className="flex gap-2">
|
||||
{[1, 2, 3, 4, 5].map((step) => (
|
||||
<div key={step} className={`h-1 rounded-full transition-all duration-500 ${step === currentStep ? 'w-8 bg-orange-200' : 'w-3 bg-white/10'}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
{currentStep > 1 && (
|
||||
<button onClick={handlePrev} className="text-white/40 px-6 py-2 text-sm hover:text-white transition-colors">返回</button>
|
||||
)}
|
||||
<GlassButton onClick={handleNext} loading={isSaving || loading} className="px-8 py-3 rounded-full text-orange-200 font-bold tracking-widest text-sm shadow-xl shadow-orange-900/10">
|
||||
{currentStep === 5 ? (<>开启人生 <Check className="w-4 h-4 ml-2" /></>) : (<>继续 <ArrowRight className="w-4 h-4 ml-2" /></>)}
|
||||
</GlassButton>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingPage;
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* AI 服务模块
|
||||
* 封装 OpenRouter API 调用
|
||||
*/
|
||||
|
||||
const API_KEY = "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";
|
||||
const BASE_URL = "https://openrouter.ai/api/v1/chat/completions";
|
||||
|
||||
/**
|
||||
* 调用 AI API
|
||||
* @param {string} prompt - 用户提示
|
||||
* @param {string} systemMsg - 系统消息
|
||||
* @returns {Promise<string>} AI 响应内容
|
||||
*/
|
||||
const fetchAI = async (prompt, systemMsg) => {
|
||||
try {
|
||||
const response = await fetch(BASE_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "deepseek/deepseek-chat-v3-0324:free",
|
||||
messages: [
|
||||
{ role: "system", content: systemMsg },
|
||||
{ role: "user", content: prompt }
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return data.choices[0].message.content;
|
||||
} catch (error) {
|
||||
console.error('AI API Error:', error);
|
||||
return "(AI 暂时陷入了沉思,请稍后再试)";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 分析生命事件
|
||||
* @param {Object} event - 事件对象 { title, time, content }
|
||||
* @returns {Promise<string>} AI 分析反馈
|
||||
*/
|
||||
export const analyzeLifeEvent = async (event) => {
|
||||
const system = "你是一位温柔的生命引路人,擅长从平凡事件中发掘成长的力量。请分析用户记录的事件,提供情感价值、成长总结和疗愈鼓励。保持字数在150字左右。";
|
||||
const prompt = `事件标题:${event.title}\n时间:${event.time}\n内容:${event.content}`;
|
||||
return fetchAI(prompt, system);
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成爽文剧本
|
||||
* @param {Object} params - 参数对象 { theme, style, length, character }
|
||||
* @param {Array} events - 生命事件数组
|
||||
* @returns {Promise<string>} 生成的剧本内容
|
||||
*/
|
||||
export const generateEpicScript = async (params, events = []) => {
|
||||
const system = `你是一位金牌爽文编剧。根据用户的角色设定和过往经历,生成一段符合用户设定、充满爽感的未来人生剧本。剧本必须包含起承转合,使用【标题】标记段落。`;
|
||||
|
||||
const charInfo = `姓名:${params.character.nickname}, 性格:${params.character.mbti}, 兴趣:${params.character.hobbies?.join(',') || ''}, 星座:${params.character.zodiac}`;
|
||||
const eventSummary = events.map(e => e.title).join(', ');
|
||||
|
||||
const prompt = `角色信息:${charInfo}\n过往经历关键词:${eventSummary}\n用户指定主题:${params.theme}\n指定风格:${params.style}\n篇幅要求:${params.length}\n\n请以此创作一段热血、精彩的人生剧本。`;
|
||||
|
||||
return fetchAI(prompt, system);
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成实现路径
|
||||
* @param {string} script - 剧本内容
|
||||
* @returns {Promise<string>} 生成的路径内容
|
||||
*/
|
||||
export const generatePath = async (script) => {
|
||||
const system = "你是一位人生规划导师。请将用户生成的剧本拆解为现实中可操作的路径。使用【阶段名称】加上具体建议。务必客观、可执行。";
|
||||
return fetchAI(script, system);
|
||||
};
|
||||
|
||||
export default {
|
||||
analyzeLifeEvent,
|
||||
generateEpicScript,
|
||||
generatePath
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* API 配置
|
||||
* 创建 axios 实例并配置拦截器
|
||||
*/
|
||||
|
||||
// API 基础地址
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
/**
|
||||
* 创建 axios 实例
|
||||
*/
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 请求拦截器
|
||||
* 自动添加 token 到请求头
|
||||
*/
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 响应拦截器
|
||||
* 统一处理响应和错误
|
||||
*/
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
const { data } = response;
|
||||
// 后端返回格式: { code, message, data }
|
||||
if (data.code === 200 || data.code === 0) {
|
||||
return data;
|
||||
}
|
||||
// 业务错误
|
||||
return Promise.reject(new Error(data.message || '请求失败'));
|
||||
},
|
||||
(error) => {
|
||||
// 网络错误或服务器错误
|
||||
if (error.response) {
|
||||
const { status, data } = error.response;
|
||||
if (status === 401) {
|
||||
// token 过期,清除登录状态
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.location.href = '/';
|
||||
}
|
||||
return Promise.reject(new Error(data?.message || `请求失败: ${status}`));
|
||||
}
|
||||
return Promise.reject(new Error(error.message || '网络错误'));
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,131 @@
|
||||
import api from './api';
|
||||
|
||||
/**
|
||||
* 认证服务
|
||||
* 处理登录、注册、验证码等认证相关接口
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取短信验证码
|
||||
* @param {string} phone - 手机号
|
||||
* @returns {Promise<Object>} 验证码响应
|
||||
*/
|
||||
export const getSmsCode = async (phone) => {
|
||||
const response = await api.get('/auth/sms-code', {
|
||||
params: { phone }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户登录(手机号 + 验证码)
|
||||
* @param {Object} params - 登录参数
|
||||
* @param {string} params.phone - 手机号
|
||||
* @param {string} params.smsCode - 短信验证码
|
||||
* @returns {Promise<Object>} 登录响应(包含 token)
|
||||
*/
|
||||
export const login = async ({ phone, smsCode }) => {
|
||||
const response = await api.post('/auth/login', {
|
||||
phone,
|
||||
smsCode
|
||||
});
|
||||
|
||||
// 保存 token
|
||||
if (response.data) {
|
||||
const { accessToken, refreshToken } = response.data;
|
||||
if (accessToken) {
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
}
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const logout = async () => {
|
||||
try {
|
||||
await api.post('/auth/logout');
|
||||
} finally {
|
||||
// 无论成功失败都清除本地 token
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 刷新 token
|
||||
* @returns {Promise<Object>} 新的 token
|
||||
*/
|
||||
export const refreshToken = async () => {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token');
|
||||
}
|
||||
|
||||
const response = await api.post('/auth/refreshToken', {
|
||||
refreshToken
|
||||
});
|
||||
|
||||
// 更新 token
|
||||
if (response.data) {
|
||||
const { accessToken, refreshToken: newRefreshToken } = response.data;
|
||||
if (accessToken) {
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
}
|
||||
if (newRefreshToken) {
|
||||
localStorage.setItem('refresh_token', newRefreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证 token 是否有效
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const validateToken = async () => {
|
||||
try {
|
||||
const response = await api.get('/auth/validateToken');
|
||||
return response.data === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
* @returns {Promise<Object>} 用户信息
|
||||
*/
|
||||
export const getCurrentUserInfo = async () => {
|
||||
const response = await api.get('/auth/userInfo');
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查手机号是否已注册
|
||||
* @param {string} phone - 手机号
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const checkPhone = async (phone) => {
|
||||
const response = await api.get('/auth/checkPhone', {
|
||||
params: { phone }
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export default {
|
||||
getSmsCode,
|
||||
login,
|
||||
logout,
|
||||
refreshToken,
|
||||
validateToken,
|
||||
getCurrentUserInfo,
|
||||
checkPhone
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
import api from './api';
|
||||
|
||||
/**
|
||||
* 爽文剧本服务
|
||||
* 处理剧本的增删改查
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取当前用户的所有剧本
|
||||
* @returns {Promise<Array>} 剧本列表
|
||||
*/
|
||||
export const getScriptList = async () => {
|
||||
const response = await api.get('/epicScript/listAll');
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 分页获取剧本
|
||||
* @param {Object} params - 分页参数
|
||||
* @param {number} params.pageNum - 页码
|
||||
* @param {number} params.pageSize - 每页数量
|
||||
* @returns {Promise<Object>} 分页结果
|
||||
*/
|
||||
export const getScriptPage = async ({ pageNum = 1, pageSize = 10 }) => {
|
||||
const response = await api.get('/epicScript/page', {
|
||||
params: { pageNum, pageSize }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据ID获取剧本详情
|
||||
* @param {string} id - 剧本ID
|
||||
* @returns {Promise<Object>} 剧本详情
|
||||
*/
|
||||
export const getScriptById = async (id) => {
|
||||
const response = await api.get('/epicScript/detail', {
|
||||
params: { id }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建剧本
|
||||
* @param {Object} scriptData - 剧本数据
|
||||
* @returns {Promise<Object>} 创建的剧本
|
||||
*/
|
||||
export const createScript = async (scriptData) => {
|
||||
const requestData = transformToBackendFormat(scriptData);
|
||||
const response = await api.post('/epicScript/create', requestData);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新剧本
|
||||
* @param {Object} scriptData - 剧本数据(必须包含 id)
|
||||
* @returns {Promise<Object>} 更新后的剧本
|
||||
*/
|
||||
export const updateScript = async (scriptData) => {
|
||||
const requestData = transformToBackendFormat(scriptData);
|
||||
const response = await api.put('/epicScript/update', requestData);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 选中剧本
|
||||
* @param {string} id - 剧本ID
|
||||
* @returns {Promise<Object>} 选中的剧本
|
||||
*/
|
||||
export const selectScript = async (id) => {
|
||||
const response = await api.put('/epicScript/select', null, {
|
||||
params: { id }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除剧本
|
||||
* @param {string} id - 剧本ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const deleteScript = async (id) => {
|
||||
const response = await api.delete('/epicScript/delete', {
|
||||
params: { id }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 将前端数据格式转换为后端格式
|
||||
* @param {Object} frontendData - 前端数据
|
||||
* @returns {Object} 后端格式数据
|
||||
*/
|
||||
const transformToBackendFormat = (frontendData) => {
|
||||
const {
|
||||
id,
|
||||
theme,
|
||||
style,
|
||||
length,
|
||||
content,
|
||||
isSelected
|
||||
} = frontendData;
|
||||
|
||||
// 解析内容生成标题和各部分
|
||||
let title = theme || '我的剧本';
|
||||
let plotIntro = '';
|
||||
let plotTurning = '';
|
||||
let plotClimax = '';
|
||||
let plotEnding = '';
|
||||
|
||||
if (content) {
|
||||
// 尝试从内容中提取各部分
|
||||
const sections = content.split(/【[^】]+】/);
|
||||
const titles = content.match(/【[^】]+】/g) || [];
|
||||
|
||||
titles.forEach((t, index) => {
|
||||
const sectionContent = sections[index + 1]?.trim() || '';
|
||||
if (t.includes('序幕') || t.includes('低谷')) {
|
||||
plotIntro = sectionContent;
|
||||
} else if (t.includes('转折') || t.includes('契机')) {
|
||||
plotTurning = sectionContent;
|
||||
} else if (t.includes('高潮') || t.includes('抉择')) {
|
||||
plotClimax = sectionContent;
|
||||
} else if (t.includes('结局') || t.includes('开始')) {
|
||||
plotEnding = sectionContent;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
theme,
|
||||
style,
|
||||
length,
|
||||
plotIntro,
|
||||
plotTurning,
|
||||
plotClimax,
|
||||
plotEnding,
|
||||
plotJson: content ? { fullContent: content } : null,
|
||||
isSelected
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 将后端数据格式转换为前端格式
|
||||
* @param {Object} backendData - 后端数据
|
||||
* @returns {Object} 前端格式数据
|
||||
*/
|
||||
export const transformToFrontendFormat = (backendData) => {
|
||||
if (!backendData) return null;
|
||||
|
||||
const {
|
||||
id,
|
||||
userId,
|
||||
title,
|
||||
theme,
|
||||
style,
|
||||
length,
|
||||
plotIntro,
|
||||
plotTurning,
|
||||
plotClimax,
|
||||
plotEnding,
|
||||
plotJson,
|
||||
isSelected,
|
||||
createTime
|
||||
} = backendData;
|
||||
|
||||
// 重建完整内容
|
||||
let content = '';
|
||||
if (plotJson?.fullContent) {
|
||||
content = plotJson.fullContent;
|
||||
} else {
|
||||
const parts = [];
|
||||
if (plotIntro) parts.push(`【序幕:低谷回响】\n${plotIntro}`);
|
||||
if (plotTurning) parts.push(`【转折:契机出现】\n${plotTurning}`);
|
||||
if (plotClimax) parts.push(`【高潮:命运抉择】\n${plotClimax}`);
|
||||
if (plotEnding) parts.push(`【结局:新的开始】\n${plotEnding}`);
|
||||
content = parts.join('\n\n');
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
userId,
|
||||
title: title || theme || '未命名剧本',
|
||||
theme: theme || '',
|
||||
style: style || '',
|
||||
length: length || 'medium',
|
||||
content,
|
||||
isSelected: isSelected || false,
|
||||
date: createTime ? new Date(createTime).toLocaleDateString() : new Date().toLocaleDateString()
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量转换后端数据为前端格式
|
||||
* @param {Array} backendList - 后端数据列表
|
||||
* @returns {Array} 前端格式数据列表
|
||||
*/
|
||||
export const transformListToFrontend = (backendList) => {
|
||||
if (!Array.isArray(backendList)) return [];
|
||||
return backendList.map(transformToFrontendFormat);
|
||||
};
|
||||
|
||||
export default {
|
||||
getScriptList,
|
||||
getScriptPage,
|
||||
getScriptById,
|
||||
createScript,
|
||||
updateScript,
|
||||
selectScript,
|
||||
deleteScript,
|
||||
transformToFrontendFormat,
|
||||
transformListToFrontend
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 服务层统一导出
|
||||
*/
|
||||
export { default as api } from './api';
|
||||
export { default as authService } from './auth';
|
||||
export { default as userProfileService } from './userProfile';
|
||||
export { default as lifeEventService } from './lifeEvent';
|
||||
export { default as epicScriptService } from './epicScript';
|
||||
export { default as lifePathService } from './lifePath';
|
||||
export { default as aiService } from './ai';
|
||||
|
||||
// 导出各服务的具体方法
|
||||
export * from './auth';
|
||||
export * from './userProfile';
|
||||
export * from './lifeEvent';
|
||||
export * from './epicScript';
|
||||
export * from './lifePath';
|
||||
export * from './ai';
|
||||
@@ -0,0 +1,164 @@
|
||||
import api from './api';
|
||||
|
||||
/**
|
||||
* 生命事件服务
|
||||
* 处理生命事件的增删改查
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取当前用户的所有生命事件
|
||||
* @returns {Promise<Array>} 生命事件列表
|
||||
*/
|
||||
export const getEventList = async () => {
|
||||
const response = await api.get('/lifeEvent/list');
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 分页获取生命事件
|
||||
* @param {Object} params - 分页参数
|
||||
* @param {number} params.pageNum - 页码
|
||||
* @param {number} params.pageSize - 每页数量
|
||||
* @returns {Promise<Object>} 分页结果
|
||||
*/
|
||||
export const getEventPage = async ({ pageNum = 1, pageSize = 10 }) => {
|
||||
const response = await api.get('/lifeEvent/page', {
|
||||
params: { pageNum, pageSize }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据ID获取生命事件详情
|
||||
* @param {string} id - 事件ID
|
||||
* @returns {Promise<Object>} 事件详情
|
||||
*/
|
||||
export const getEventById = async (id) => {
|
||||
const response = await api.get('/lifeEvent/detail', {
|
||||
params: { id }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建生命事件
|
||||
* @param {Object} eventData - 事件数据
|
||||
* @returns {Promise<Object>} 创建的事件
|
||||
*/
|
||||
export const createEvent = async (eventData) => {
|
||||
const requestData = transformToBackendFormat(eventData);
|
||||
const response = await api.post('/lifeEvent/create', requestData);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新生命事件
|
||||
* @param {Object} eventData - 事件数据(必须包含 id)
|
||||
* @returns {Promise<Object>} 更新后的事件
|
||||
*/
|
||||
export const updateEvent = async (eventData) => {
|
||||
const requestData = transformToBackendFormat(eventData);
|
||||
const response = await api.put('/lifeEvent/update', requestData);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除生命事件
|
||||
* @param {string} id - 事件ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const deleteEvent = async (id) => {
|
||||
const response = await api.delete('/lifeEvent/delete', {
|
||||
params: { id }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 将前端数据格式转换为后端格式
|
||||
* @param {Object} frontendData - 前端数据
|
||||
* @returns {Object} 后端格式数据
|
||||
*/
|
||||
const transformToBackendFormat = (frontendData) => {
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
time,
|
||||
content,
|
||||
aiFeedback,
|
||||
eventType = 'daily_log',
|
||||
emotionType,
|
||||
emotionScore,
|
||||
tags
|
||||
} = frontendData;
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
eventDate: time,
|
||||
content,
|
||||
aiReply: aiFeedback,
|
||||
eventType,
|
||||
emotionType,
|
||||
emotionScore,
|
||||
tags
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 将后端数据格式转换为前端格式
|
||||
* @param {Object} backendData - 后端数据
|
||||
* @returns {Object} 前端格式数据
|
||||
*/
|
||||
export const transformToFrontendFormat = (backendData) => {
|
||||
if (!backendData) return null;
|
||||
|
||||
const {
|
||||
id,
|
||||
userId,
|
||||
title,
|
||||
eventDate,
|
||||
content,
|
||||
aiReply,
|
||||
eventType,
|
||||
emotionType,
|
||||
emotionScore,
|
||||
tags,
|
||||
createTime
|
||||
} = backendData;
|
||||
|
||||
return {
|
||||
id,
|
||||
userId,
|
||||
title: title || '',
|
||||
time: eventDate || '',
|
||||
content: content || '',
|
||||
aiFeedback: aiReply || '',
|
||||
eventType: eventType || 'daily_log',
|
||||
emotionType,
|
||||
emotionScore,
|
||||
tags: tags || [],
|
||||
createTime
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量转换后端数据为前端格式
|
||||
* @param {Array} backendList - 后端数据列表
|
||||
* @returns {Array} 前端格式数据列表
|
||||
*/
|
||||
export const transformListToFrontend = (backendList) => {
|
||||
if (!Array.isArray(backendList)) return [];
|
||||
return backendList.map(transformToFrontendFormat);
|
||||
};
|
||||
|
||||
export default {
|
||||
getEventList,
|
||||
getEventPage,
|
||||
getEventById,
|
||||
createEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
transformToFrontendFormat,
|
||||
transformListToFrontend
|
||||
};
|
||||
@@ -0,0 +1,211 @@
|
||||
import api from './api';
|
||||
|
||||
/**
|
||||
* 实现路径服务
|
||||
* 处理路径的增删改查
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取当前用户的所有路径
|
||||
* @returns {Promise<Array>} 路径列表
|
||||
*/
|
||||
export const getPathList = async () => {
|
||||
const response = await api.get('/lifePath/listAll');
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 分页获取路径
|
||||
* @param {Object} params - 分页参数
|
||||
* @param {number} params.pageNum - 页码
|
||||
* @param {number} params.pageSize - 每页数量
|
||||
* @returns {Promise<Object>} 分页结果
|
||||
*/
|
||||
export const getPathPage = async ({ pageNum = 1, pageSize = 10 }) => {
|
||||
const response = await api.get('/lifePath/page', {
|
||||
params: { pageNum, pageSize }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据剧本ID获取路径
|
||||
* @param {string} scriptId - 剧本ID
|
||||
* @returns {Promise<Object>} 路径详情
|
||||
*/
|
||||
export const getPathByScriptId = async (scriptId) => {
|
||||
const response = await api.get('/lifePath/byScript', {
|
||||
params: { scriptId }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据ID获取路径详情
|
||||
* @param {string} id - 路径ID
|
||||
* @returns {Promise<Object>} 路径详情
|
||||
*/
|
||||
export const getPathById = async (id) => {
|
||||
const response = await api.get('/lifePath/detail', {
|
||||
params: { id }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建路径
|
||||
* @param {Object} pathData - 路径数据
|
||||
* @returns {Promise<Object>} 创建的路径
|
||||
*/
|
||||
export const createPath = async (pathData) => {
|
||||
const requestData = transformToBackendFormat(pathData);
|
||||
const response = await api.post('/lifePath/create', requestData);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新路径
|
||||
* @param {Object} pathData - 路径数据(必须包含 id)
|
||||
* @returns {Promise<Object>} 更新后的路径
|
||||
*/
|
||||
export const updatePath = async (pathData) => {
|
||||
const requestData = transformToBackendFormat(pathData);
|
||||
const response = await api.put('/lifePath/update', requestData);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除路径
|
||||
* @param {string} id - 路径ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const deletePath = async (id) => {
|
||||
const response = await api.delete('/lifePath/delete', {
|
||||
params: { id }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 将前端数据格式转换为后端格式
|
||||
* @param {Object} frontendData - 前端数据
|
||||
* @returns {Object} 后端格式数据
|
||||
*/
|
||||
const transformToBackendFormat = (frontendData) => {
|
||||
const {
|
||||
id,
|
||||
scriptId,
|
||||
title,
|
||||
description,
|
||||
content,
|
||||
status = 'active',
|
||||
progress = 0
|
||||
} = frontendData;
|
||||
|
||||
// 解析内容为步骤列表
|
||||
let steps = [];
|
||||
if (content) {
|
||||
// 尝试解析文本内容为步骤
|
||||
const stepMatches = content.match(/(\d+)\.\s*([^::]+)[::]\s*([^\n]+)/g);
|
||||
if (stepMatches) {
|
||||
steps = stepMatches.map((match, index) => {
|
||||
const parts = match.match(/(\d+)\.\s*([^::]+)[::]\s*(.+)/);
|
||||
return {
|
||||
phase: `阶段${index + 1}`,
|
||||
time: parts?.[2]?.trim() || '',
|
||||
content: parts?.[3]?.trim() || match,
|
||||
action: '',
|
||||
resources: '',
|
||||
habit: ''
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// 按换行分割
|
||||
const lines = content.split('\n').filter(line => line.trim());
|
||||
steps = lines.map((line, index) => ({
|
||||
phase: `阶段${index + 1}`,
|
||||
time: '',
|
||||
content: line.trim(),
|
||||
action: '',
|
||||
resources: '',
|
||||
habit: ''
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
scriptId,
|
||||
title: title || '实现路径',
|
||||
description,
|
||||
steps,
|
||||
status,
|
||||
progress
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 将后端数据格式转换为前端格式
|
||||
* @param {Object} backendData - 后端数据
|
||||
* @returns {Object} 前端格式数据
|
||||
*/
|
||||
export const transformToFrontendFormat = (backendData) => {
|
||||
if (!backendData) return null;
|
||||
|
||||
const {
|
||||
id,
|
||||
userId,
|
||||
scriptId,
|
||||
title,
|
||||
description,
|
||||
steps,
|
||||
status,
|
||||
progress,
|
||||
createTime
|
||||
} = backendData;
|
||||
|
||||
// 将步骤列表转换为文本内容
|
||||
let content = '';
|
||||
if (Array.isArray(steps) && steps.length > 0) {
|
||||
content = steps.map((step, index) => {
|
||||
const phase = step.phase || `阶段${index + 1}`;
|
||||
const time = step.time ? `(${step.time})` : '';
|
||||
return `${index + 1}. ${phase}${time}:${step.content || ''}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
userId,
|
||||
scriptId,
|
||||
title: title || '实现路径',
|
||||
description: description || '',
|
||||
content,
|
||||
steps: steps || [],
|
||||
status: status || 'active',
|
||||
progress: progress || 0,
|
||||
createTime
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量转换后端数据为前端格式
|
||||
* @param {Array} backendList - 后端数据列表
|
||||
* @returns {Array} 前端格式数据列表
|
||||
*/
|
||||
export const transformListToFrontend = (backendList) => {
|
||||
if (!Array.isArray(backendList)) return [];
|
||||
return backendList.map(transformToFrontendFormat);
|
||||
};
|
||||
|
||||
export default {
|
||||
getPathList,
|
||||
getPathPage,
|
||||
getPathByScriptId,
|
||||
getPathById,
|
||||
createPath,
|
||||
updatePath,
|
||||
deletePath,
|
||||
transformToFrontendFormat,
|
||||
transformListToFrontend
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
import api from './api';
|
||||
|
||||
/**
|
||||
* 用户档案服务
|
||||
* 处理用户档案的增删改查
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取当前用户档案
|
||||
* @returns {Promise<Object|null>} 用户档案
|
||||
*/
|
||||
export const getCurrentProfile = async () => {
|
||||
const response = await api.get('/user-profile/me');
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建用户档案
|
||||
* @param {Object} profileData - 档案数据
|
||||
* @returns {Promise<Object>} 创建的档案
|
||||
*/
|
||||
export const createProfile = async (profileData) => {
|
||||
// 转换前端数据格式为后端格式
|
||||
const requestData = transformToBackendFormat(profileData);
|
||||
const response = await api.post('/user-profile/create', requestData);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新用户档案
|
||||
* @param {Object} profileData - 档案数据(必须包含 id)
|
||||
* @returns {Promise<Object>} 更新后的档案
|
||||
*/
|
||||
export const updateProfile = async (profileData) => {
|
||||
const requestData = transformToBackendFormat(profileData);
|
||||
const response = await api.put('/user-profile/update', requestData);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除用户档案
|
||||
* @param {string} id - 档案ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const deleteProfile = async (id) => {
|
||||
const response = await api.delete('/user-profile/delete', {
|
||||
params: { id }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据ID获取档案详情
|
||||
* @param {string} id - 档案ID
|
||||
* @returns {Promise<Object>} 档案详情
|
||||
*/
|
||||
export const getProfileById = async (id) => {
|
||||
const response = await api.get('/user-profile/detail', {
|
||||
params: { id }
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 将前端数据格式转换为后端格式
|
||||
* @param {Object} frontendData - 前端数据
|
||||
* @returns {Object} 后端格式数据
|
||||
*/
|
||||
const transformToBackendFormat = (frontendData) => {
|
||||
const {
|
||||
id,
|
||||
nickname,
|
||||
gender,
|
||||
zodiac,
|
||||
mbti,
|
||||
hobbies,
|
||||
childhood,
|
||||
joy,
|
||||
low,
|
||||
future
|
||||
} = frontendData;
|
||||
|
||||
return {
|
||||
id,
|
||||
nickname,
|
||||
gender,
|
||||
zodiac,
|
||||
mbti,
|
||||
// 兴趣爱好转为 JSON 字符串
|
||||
hobbies: Array.isArray(hobbies) ? JSON.stringify(hobbies) : hobbies,
|
||||
// 童年经历
|
||||
childhoodDate: childhood?.date || null,
|
||||
childhoodContent: childhood?.text || null,
|
||||
// 高光时刻(对应前端的 joy)
|
||||
peakDate: joy?.date || null,
|
||||
peakContent: joy?.text || null,
|
||||
// 低谷时期(对应前端的 low)
|
||||
valleyDate: low?.date || null,
|
||||
valleyContent: low?.text || null,
|
||||
// 未来期许
|
||||
futureVision: future?.vision || null,
|
||||
// 理想生活状态
|
||||
idealLife: future?.ideal || null
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 将后端数据格式转换为前端格式
|
||||
* @param {Object} backendData - 后端数据
|
||||
* @returns {Object} 前端格式数据
|
||||
*/
|
||||
export const transformToFrontendFormat = (backendData) => {
|
||||
if (!backendData) return null;
|
||||
|
||||
const {
|
||||
id,
|
||||
userId,
|
||||
nickname,
|
||||
gender,
|
||||
zodiac,
|
||||
mbti,
|
||||
hobbies,
|
||||
childhoodDate,
|
||||
childhoodContent,
|
||||
peakDate,
|
||||
peakContent,
|
||||
valleyDate,
|
||||
valleyContent,
|
||||
futureVision,
|
||||
idealLife
|
||||
} = backendData;
|
||||
|
||||
return {
|
||||
id,
|
||||
userId,
|
||||
nickname: nickname || '',
|
||||
gender: gender || '',
|
||||
zodiac: zodiac || '',
|
||||
mbti: mbti || '',
|
||||
// 兴趣爱好从 JSON 字符串解析
|
||||
hobbies: hobbies ? (typeof hobbies === 'string' ? JSON.parse(hobbies) : hobbies) : [],
|
||||
// 童年经历
|
||||
childhood: {
|
||||
date: childhoodDate || '',
|
||||
text: childhoodContent || ''
|
||||
},
|
||||
// 高光时刻
|
||||
joy: {
|
||||
date: peakDate || '',
|
||||
text: peakContent || ''
|
||||
},
|
||||
// 低谷时期
|
||||
low: {
|
||||
date: valleyDate || '',
|
||||
text: valleyContent || ''
|
||||
},
|
||||
// 未来期许
|
||||
future: {
|
||||
vision: futureVision || '',
|
||||
ideal: idealLife || ''
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
getCurrentProfile,
|
||||
createProfile,
|
||||
updateProfile,
|
||||
deleteProfile,
|
||||
getProfileById,
|
||||
transformToFrontendFormat
|
||||
};
|
||||
@@ -0,0 +1,489 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import * as authService from '../services/auth';
|
||||
import * as userProfileService from '../services/userProfile';
|
||||
import * as lifeEventService from '../services/lifeEvent';
|
||||
import * as epicScriptService from '../services/epicScript';
|
||||
import * as lifePathService from '../services/lifePath';
|
||||
|
||||
/**
|
||||
* 默认注册数据
|
||||
*/
|
||||
const defaultRegistrationData = {
|
||||
id: null,
|
||||
nickname: '',
|
||||
gender: '',
|
||||
zodiac: '',
|
||||
mbti: '',
|
||||
profession: '',
|
||||
hobbies: [],
|
||||
childhood: { date: '', text: '' },
|
||||
joy: { date: '', text: '' },
|
||||
low: { date: '', text: '' },
|
||||
future: { vision: '', ideal: '' }
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用状态存储
|
||||
* 使用 Zustand 进行状态管理,支持 localStorage 持久化
|
||||
*/
|
||||
const useStore = create(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// 认证状态
|
||||
isLoggedIn: false,
|
||||
phone: '',
|
||||
userId: null,
|
||||
|
||||
// 视图状态
|
||||
view: 'login',
|
||||
currentStep: 1,
|
||||
|
||||
// 用户注册数据
|
||||
registrationData: { ...defaultRegistrationData },
|
||||
|
||||
// 生命事件
|
||||
lifeEvents: [],
|
||||
|
||||
// 剧本
|
||||
scripts: [],
|
||||
selectedScriptId: null,
|
||||
|
||||
// 路径
|
||||
selectedPath: null,
|
||||
|
||||
// 加载状态
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
/**
|
||||
* 设置加载状态
|
||||
*/
|
||||
setLoading: (loading) => set({ loading }),
|
||||
|
||||
/**
|
||||
* 设置错误信息
|
||||
*/
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
/**
|
||||
* 获取短信验证码
|
||||
* @param {string} phone - 手机号
|
||||
*/
|
||||
getSmsCode: async (phone) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await authService.getSmsCode(phone);
|
||||
set({ loading: false });
|
||||
return response;
|
||||
} catch (error) {
|
||||
set({ loading: false, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 登录
|
||||
* @param {string} phone - 手机号
|
||||
* @param {string} smsCode - 验证码
|
||||
*/
|
||||
login: async (phone, smsCode) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await authService.login({ phone, smsCode });
|
||||
const { userId } = response.data || {};
|
||||
|
||||
set({
|
||||
isLoggedIn: true,
|
||||
phone,
|
||||
userId,
|
||||
view: 'onboarding',
|
||||
loading: false
|
||||
});
|
||||
|
||||
// 尝试加载用户档案
|
||||
try {
|
||||
await get().loadUserProfile();
|
||||
} catch {
|
||||
// 档案不存在,继续入站流程
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
set({ loading: false, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置登录状态(本地模式)
|
||||
*/
|
||||
setLogin: (isLoggedIn, phone = '') => set({
|
||||
isLoggedIn,
|
||||
phone,
|
||||
view: isLoggedIn ? 'onboarding' : 'login'
|
||||
}),
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
logout: async () => {
|
||||
try {
|
||||
await authService.logout();
|
||||
} catch {
|
||||
// 忽略登出错误
|
||||
}
|
||||
set({
|
||||
isLoggedIn: false,
|
||||
phone: '',
|
||||
userId: null,
|
||||
view: 'login',
|
||||
currentStep: 1,
|
||||
registrationData: { ...defaultRegistrationData },
|
||||
lifeEvents: [],
|
||||
scripts: [],
|
||||
selectedScriptId: null,
|
||||
selectedPath: null
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置当前视图
|
||||
*/
|
||||
setView: (view) => set({ view }),
|
||||
|
||||
/**
|
||||
* 设置当前步骤
|
||||
*/
|
||||
setCurrentStep: (step) => set({ currentStep: step }),
|
||||
|
||||
/**
|
||||
* 加载用户档案
|
||||
*/
|
||||
loadUserProfile: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const response = await userProfileService.getCurrentProfile();
|
||||
if (response.data) {
|
||||
const profileData = userProfileService.transformToFrontendFormat(response.data);
|
||||
set({
|
||||
registrationData: { ...defaultRegistrationData, ...profileData },
|
||||
loading: false
|
||||
});
|
||||
return profileData;
|
||||
}
|
||||
set({ loading: false });
|
||||
return null;
|
||||
} catch (error) {
|
||||
set({ loading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新注册数据(本地)
|
||||
*/
|
||||
updateRegistration: (data) => set((state) => ({
|
||||
registrationData: { ...state.registrationData, ...data }
|
||||
})),
|
||||
|
||||
/**
|
||||
* 保存用户档案到后端
|
||||
*/
|
||||
saveUserProfile: async () => {
|
||||
const { registrationData } = get();
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
let response;
|
||||
if (registrationData.id) {
|
||||
// 更新
|
||||
response = await userProfileService.updateProfile(registrationData);
|
||||
} else {
|
||||
// 创建
|
||||
response = await userProfileService.createProfile(registrationData);
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
const profileData = userProfileService.transformToFrontendFormat(response.data);
|
||||
set({
|
||||
registrationData: { ...registrationData, ...profileData },
|
||||
loading: false
|
||||
});
|
||||
} else {
|
||||
set({ loading: false });
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
set({ loading: false, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载生命事件列表
|
||||
*/
|
||||
loadLifeEvents: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const response = await lifeEventService.getEventList();
|
||||
const events = lifeEventService.transformListToFrontend(response.data || []);
|
||||
set({ lifeEvents: events, loading: false });
|
||||
return events;
|
||||
} catch (error) {
|
||||
set({ loading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加生命事件
|
||||
*/
|
||||
addLifeEvent: async (event) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await lifeEventService.createEvent(event);
|
||||
if (response.data) {
|
||||
const newEvent = lifeEventService.transformToFrontendFormat(response.data);
|
||||
set((state) => ({
|
||||
lifeEvents: [...state.lifeEvents, newEvent],
|
||||
loading: false
|
||||
}));
|
||||
return newEvent;
|
||||
}
|
||||
set({ loading: false });
|
||||
return null;
|
||||
} catch (error) {
|
||||
set({ loading: false, error: error.message });
|
||||
// 降级到本地存储
|
||||
const localEvent = { ...event, id: Date.now().toString() };
|
||||
set((state) => ({
|
||||
lifeEvents: [...state.lifeEvents, localEvent]
|
||||
}));
|
||||
return localEvent;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除生命事件
|
||||
*/
|
||||
deleteLifeEvent: async (id) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await lifeEventService.deleteEvent(id);
|
||||
set((state) => ({
|
||||
lifeEvents: state.lifeEvents.filter(e => e.id !== id),
|
||||
loading: false
|
||||
}));
|
||||
} catch (error) {
|
||||
set({ loading: false });
|
||||
// 降级到本地删除
|
||||
set((state) => ({
|
||||
lifeEvents: state.lifeEvents.filter(e => e.id !== id)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载剧本列表
|
||||
*/
|
||||
loadScripts: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const response = await epicScriptService.getScriptList();
|
||||
const scripts = epicScriptService.transformListToFrontend(response.data || []);
|
||||
// 找到选中的剧本
|
||||
const selectedScript = scripts.find(s => s.isSelected);
|
||||
set({
|
||||
scripts,
|
||||
selectedScriptId: selectedScript?.id || scripts[0]?.id || null,
|
||||
loading: false
|
||||
});
|
||||
return scripts;
|
||||
} catch (error) {
|
||||
set({ loading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加剧本
|
||||
*/
|
||||
addScript: async (script) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await epicScriptService.createScript({
|
||||
...script,
|
||||
isSelected: true
|
||||
});
|
||||
if (response.data) {
|
||||
const newScript = epicScriptService.transformToFrontendFormat(response.data);
|
||||
set((state) => ({
|
||||
scripts: [newScript, ...state.scripts],
|
||||
selectedScriptId: newScript.id,
|
||||
loading: false
|
||||
}));
|
||||
return newScript;
|
||||
}
|
||||
set({ loading: false });
|
||||
return null;
|
||||
} catch (error) {
|
||||
set({ loading: false, error: error.message });
|
||||
// 降级到本地存储
|
||||
const localScript = {
|
||||
...script,
|
||||
id: Date.now().toString(),
|
||||
date: new Date().toLocaleDateString()
|
||||
};
|
||||
set((state) => ({
|
||||
scripts: [localScript, ...state.scripts],
|
||||
selectedScriptId: localScript.id
|
||||
}));
|
||||
return localScript;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置选中的剧本ID
|
||||
*/
|
||||
setSelectedScriptId: async (id) => {
|
||||
set({ selectedScriptId: id });
|
||||
try {
|
||||
await epicScriptService.selectScript(id);
|
||||
} catch {
|
||||
// 忽略错误,本地已更新
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取选中的剧本
|
||||
*/
|
||||
getSelectedScript: () => {
|
||||
const state = get();
|
||||
return state.scripts.find(s => s.id === state.selectedScriptId);
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除剧本
|
||||
*/
|
||||
deleteScript: async (id) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await epicScriptService.deleteScript(id);
|
||||
set((state) => {
|
||||
const newScripts = state.scripts.filter(s => s.id !== id);
|
||||
return {
|
||||
scripts: newScripts,
|
||||
selectedScriptId: state.selectedScriptId === id
|
||||
? (newScripts[0]?.id || null)
|
||||
: state.selectedScriptId,
|
||||
loading: false
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
set({ loading: false });
|
||||
// 降级到本地删除
|
||||
set((state) => {
|
||||
const newScripts = state.scripts.filter(s => s.id !== id);
|
||||
return {
|
||||
scripts: newScripts,
|
||||
selectedScriptId: state.selectedScriptId === id
|
||||
? (newScripts[0]?.id || null)
|
||||
: state.selectedScriptId
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载路径
|
||||
*/
|
||||
loadPath: async (scriptId) => {
|
||||
if (!scriptId) return null;
|
||||
set({ loading: true });
|
||||
try {
|
||||
const response = await lifePathService.getPathByScriptId(scriptId);
|
||||
if (response.data) {
|
||||
const path = lifePathService.transformToFrontendFormat(response.data);
|
||||
set({ selectedPath: path.content, loading: false });
|
||||
return path;
|
||||
}
|
||||
set({ loading: false });
|
||||
return null;
|
||||
} catch {
|
||||
set({ loading: false });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置路径
|
||||
*/
|
||||
setPath: async (pathContent, scriptId) => {
|
||||
set({ selectedPath: pathContent });
|
||||
|
||||
if (scriptId) {
|
||||
try {
|
||||
// 检查是否已有路径
|
||||
const existingPath = await lifePathService.getPathByScriptId(scriptId).catch(() => null);
|
||||
|
||||
if (existingPath?.data?.id) {
|
||||
// 更新
|
||||
await lifePathService.updatePath({
|
||||
id: existingPath.data.id,
|
||||
scriptId,
|
||||
content: pathContent
|
||||
});
|
||||
} else {
|
||||
// 创建
|
||||
await lifePathService.createPath({
|
||||
scriptId,
|
||||
content: pathContent
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误,本地已更新
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除所有数据
|
||||
*/
|
||||
clear: async () => {
|
||||
try {
|
||||
await authService.logout();
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
localStorage.removeItem('life_trajectory_v3');
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
set({
|
||||
isLoggedIn: false,
|
||||
phone: '',
|
||||
userId: null,
|
||||
view: 'login',
|
||||
currentStep: 1,
|
||||
registrationData: { ...defaultRegistrationData },
|
||||
lifeEvents: [],
|
||||
scripts: [],
|
||||
selectedScriptId: null,
|
||||
selectedPath: null,
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
window.location.reload();
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 'life_trajectory_v3',
|
||||
onRehydrateStorage: () => (state, error) => {
|
||||
if (error) {
|
||||
console.error('Failed to load state from localStorage:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export default useStore;
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 常量定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 灵感标签集合
|
||||
*/
|
||||
export const inspirationClusters = {
|
||||
childhood: ['秋千', '晚霞', '糖果', '奔跑', '蝉鸣', '雨后泥土', '旧书包', '风筝'],
|
||||
joy: ['海浪', '拥抱', '掌声', '晨曦', '破土而出', '默契', '星空', '释放'],
|
||||
low: ['落叶', '雨伞', '长廊', '深呼吸', '自愈', '沉潜', '坚韧', '等待', '破茧']
|
||||
};
|
||||
|
||||
/**
|
||||
* 剧本风格选项
|
||||
*/
|
||||
export const scriptStyles = [
|
||||
{ value: '都市', label: '都市沉浮' },
|
||||
{ value: '古风', label: '快意恩仇' },
|
||||
{ value: '爱情', label: '唯美浪漫' },
|
||||
{ value: '科幻', label: '星际远征' },
|
||||
{ value: '喜剧', label: '荒诞不经' },
|
||||
{ value: '悬疑', label: '迷雾重重' },
|
||||
{ value: '恐怖', label: '午夜回响' }
|
||||
];
|
||||
|
||||
/**
|
||||
* 剧本篇幅选项
|
||||
*/
|
||||
export const scriptLengths = [
|
||||
{ value: '短', label: '极简' },
|
||||
{ value: '中', label: '连载' },
|
||||
{ value: '长', label: '史诗' }
|
||||
];
|
||||
|
||||
/**
|
||||
* 入站步骤配置
|
||||
*/
|
||||
export const onboardingSteps = [
|
||||
{ id: 1, title: '你是谁?', subtitle: '定义你生命坐标的初始属性。' },
|
||||
{ id: 2, title: '那段纯真的时光', subtitle: '回望足迹,这些瞬间如何塑造了此时的你。', type: 'childhood' },
|
||||
{ id: 3, title: '光芒闪耀的时刻', subtitle: '回望足迹,这些瞬间如何塑造了此时的你。', type: 'joy' },
|
||||
{ id: 4, title: '在暗夜中潜行', subtitle: '回望足迹,这些瞬间如何塑造了此时的你。', type: 'low' },
|
||||
{ id: 5, title: '未来想成为谁?', subtitle: '勾勒你对理想生活的全部向往。' }
|
||||
];
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Map, Loader2 } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { GlassCard, GlassButton } from '../components/ui';
|
||||
import useStore from '../store/useStore';
|
||||
import { generatePath } from '../services/ai';
|
||||
|
||||
/**
|
||||
* PathView 组件
|
||||
* 实现路径视图,基于剧本生成可执行的人生路径
|
||||
* @param {Object} props
|
||||
* @param {Function} props.onGoToScript - 跳转到剧本视图回调
|
||||
*/
|
||||
const PathView = ({ onGoToScript }) => {
|
||||
const { getSelectedScript, selectedPath, setPath, loadPath, selectedScriptId } = useStore();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const selectedScript = getSelectedScript();
|
||||
|
||||
// 加载已有路径
|
||||
useEffect(() => {
|
||||
if (selectedScriptId) {
|
||||
loadPath(selectedScriptId).catch(() => {
|
||||
// 后端不可用时忽略错误
|
||||
});
|
||||
}
|
||||
}, [selectedScriptId, loadPath]);
|
||||
|
||||
/**
|
||||
* 处理路径生成
|
||||
*/
|
||||
const handleGenerate = async () => {
|
||||
if (!selectedScript) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const path = await generatePath(selectedScript.content);
|
||||
await setPath(path, selectedScriptId);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate path:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析路径内容为步骤数组
|
||||
*/
|
||||
const parsePathSteps = (path) => {
|
||||
if (!path) return [];
|
||||
|
||||
return path
|
||||
.split(/【/)
|
||||
.filter(s => s.trim())
|
||||
.map((s, index) => {
|
||||
const parts = s.split(/】/);
|
||||
return {
|
||||
title: parts[0] || '',
|
||||
content: parts[1] || '',
|
||||
index: index + 1
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const pathSteps = parsePathSteps(selectedPath);
|
||||
|
||||
// 无剧本时显示提示
|
||||
if (!selectedScript) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-32 opacity-30 text-center">
|
||||
<Map className="w-16 h-16 mb-4" />
|
||||
<p className="font-serif italic text-xl">先生成剧本,方能洞察路径。</p>
|
||||
<GlassButton
|
||||
onClick={onGoToScript}
|
||||
className="mt-6 px-6 py-2 rounded-full text-xs"
|
||||
>
|
||||
去生成剧本
|
||||
</GlassButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-12 pb-20">
|
||||
{/* 标题区域 */}
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h3 className="text-4xl font-serif">实现路径</h3>
|
||||
<p className="text-sm text-white/30 mt-2">
|
||||
基于《{selectedScript.theme}》,拆解达成目标的每一步。
|
||||
</p>
|
||||
</div>
|
||||
<GlassButton
|
||||
onClick={handleGenerate}
|
||||
disabled={isLoading}
|
||||
className="px-8 py-3 rounded-full text-sm font-bold bg-blue-400/5 text-blue-300 border-blue-400/20"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
规划中...
|
||||
</>
|
||||
) : (
|
||||
selectedPath ? '重新推演' : '开启人生导航'
|
||||
)}
|
||||
</GlassButton>
|
||||
</div>
|
||||
|
||||
{/* 路径步骤展示 */}
|
||||
<div className="space-y-6">
|
||||
{pathSteps.length > 0 ? (
|
||||
pathSteps.map((step, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1, duration: 0.5 }}
|
||||
>
|
||||
<GlassCard className="border-l-4 border-l-blue-400/40 bg-blue-400/[0.01]" padding="lg">
|
||||
<h5 className="text-blue-200 font-bold mb-4 flex items-center gap-3">
|
||||
<span className="w-6 h-6 rounded-full bg-blue-400/20 text-[10px] flex items-center justify-center">
|
||||
{step.index}
|
||||
</span>
|
||||
{step.title}
|
||||
</h5>
|
||||
<div className="text-white/60 text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{step.content}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</motion.div>
|
||||
))
|
||||
) : (
|
||||
<div className="py-20 text-center text-white/20 italic font-serif">
|
||||
等待开启人生导航...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PathView;
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Settings, Settings2 } from 'lucide-react';
|
||||
import Modal from '../components/Modal';
|
||||
import { GlassButton, GlassInput } from '../components/ui';
|
||||
import useStore from '../store/useStore';
|
||||
|
||||
/**
|
||||
* ProfileModal 组件
|
||||
* 用户资料模态框,支持查看和编辑模式
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.isOpen - 是否打开
|
||||
* @param {Function} props.onClose - 关闭回调
|
||||
*/
|
||||
const ProfileModal = ({ isOpen, onClose }) => {
|
||||
const { registrationData, lifeEvents, scripts, updateRegistration, saveUserProfile, clear } = useStore();
|
||||
|
||||
// 编辑模式状态
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 编辑表单状态
|
||||
const [editForm, setEditForm] = useState({
|
||||
nickname: registrationData.nickname,
|
||||
profession: registrationData.profession || '',
|
||||
mbti: registrationData.mbti,
|
||||
zodiac: registrationData.zodiac,
|
||||
hobbies: registrationData.hobbies?.join(', ') || ''
|
||||
});
|
||||
|
||||
// 同步 registrationData 到 editForm
|
||||
useEffect(() => {
|
||||
setEditForm({
|
||||
nickname: registrationData.nickname,
|
||||
profession: registrationData.profession || '',
|
||||
mbti: registrationData.mbti,
|
||||
zodiac: registrationData.zodiac,
|
||||
hobbies: registrationData.hobbies?.join(', ') || ''
|
||||
});
|
||||
}, [registrationData]);
|
||||
|
||||
/**
|
||||
* 处理保存
|
||||
*/
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
updateRegistration({
|
||||
nickname: editForm.nickname,
|
||||
profession: editForm.profession,
|
||||
mbti: editForm.mbti,
|
||||
zodiac: editForm.zodiac,
|
||||
hobbies: editForm.hobbies.split(',').map(s => s.trim()).filter(s => s)
|
||||
});
|
||||
await saveUserProfile();
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
// 即使后端保存失败,本地已更新
|
||||
setIsEditing(false);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理取消编辑
|
||||
*/
|
||||
const handleCancel = () => {
|
||||
setEditForm({
|
||||
nickname: registrationData.nickname,
|
||||
profession: registrationData.profession || '',
|
||||
mbti: registrationData.mbti,
|
||||
zodiac: registrationData.zodiac,
|
||||
hobbies: registrationData.hobbies?.join(', ') || ''
|
||||
});
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理清除数据
|
||||
*/
|
||||
const handleClear = () => {
|
||||
if (confirm('确定要删除所有记录吗?此操作不可逆。')) {
|
||||
clear();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染查看模式
|
||||
*/
|
||||
const renderViewMode = () => (
|
||||
<div className="animate-fade-in space-y-8">
|
||||
{/* 用户头像和基本信息 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-orange-400/20 to-orange-600/20 flex items-center justify-center text-3xl border border-white/10">
|
||||
{(registrationData.nickname || '人').charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-2xl font-serif text-white/90">
|
||||
{registrationData.nickname || '旅行者'}
|
||||
</h4>
|
||||
<p className="text-[10px] text-white/30 uppercase tracking-[0.2em] mt-1">
|
||||
{registrationData.mbti || '-'} | {registrationData.zodiac || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计数据 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-white/[0.02] rounded-2xl border border-white/5 text-center">
|
||||
<div className="text-lg font-serif text-orange-200">{lifeEvents.length}</div>
|
||||
<div className="text-[9px] text-white/30 uppercase tracking-widest mt-1">生命足迹</div>
|
||||
</div>
|
||||
<div className="p-4 bg-white/[0.02] rounded-2xl border border-white/5 text-center">
|
||||
<div className="text-lg font-serif text-blue-200">{scripts.length}</div>
|
||||
<div className="text-[9px] text-white/30 uppercase tracking-widest mt-1">天命卷轴</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="space-y-4 pt-4 border-t border-white/5">
|
||||
<GlassButton
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="w-full py-4 text-sm font-bold flex gap-3 items-center justify-center"
|
||||
>
|
||||
<Settings className="w-4 h-4" /> 编辑资料
|
||||
</GlassButton>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="w-full py-4 text-[10px] text-red-400/40 hover:text-red-400 uppercase tracking-widest transition-colors"
|
||||
>
|
||||
清除数据并退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* 渲染编辑模式
|
||||
*/
|
||||
const renderEditMode = () => (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* 编辑标题 */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="w-12 h-12 rounded-full bg-orange-200/10 flex items-center justify-center">
|
||||
<Settings2 className="text-orange-200 w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xl font-serif">个人设定</h4>
|
||||
<p className="text-xs text-white/40">在这里调整你的人生航向基础信息</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 编辑表单 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||||
<GlassInput
|
||||
label="昵称"
|
||||
placeholder="你想被如何称呼?"
|
||||
value={editForm.nickname}
|
||||
onChange={(v) => setEditForm(prev => ({ ...prev, nickname: v }))}
|
||||
/>
|
||||
<GlassInput
|
||||
label="职业"
|
||||
placeholder="你当下的社会锚点"
|
||||
value={editForm.profession}
|
||||
onChange={(v) => setEditForm(prev => ({ ...prev, profession: v }))}
|
||||
/>
|
||||
<GlassInput
|
||||
label="MBTI"
|
||||
placeholder="性格色彩"
|
||||
value={editForm.mbti}
|
||||
onChange={(v) => setEditForm(prev => ({ ...prev, mbti: v }))}
|
||||
/>
|
||||
<GlassInput
|
||||
label="星座"
|
||||
placeholder="星辰指引"
|
||||
value={editForm.zodiac}
|
||||
onChange={(v) => setEditForm(prev => ({ ...prev, zodiac: v }))}
|
||||
/>
|
||||
</div>
|
||||
<GlassInput
|
||||
label="兴趣爱好"
|
||||
placeholder="让灵魂起舞的事物"
|
||||
value={editForm.hobbies}
|
||||
onChange={(v) => setEditForm(prev => ({ ...prev, hobbies: v }))}
|
||||
/>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-4 mt-8 pt-6 border-t border-white/5">
|
||||
<GlassButton
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
className="flex-1 py-3 bg-orange-200/10 text-orange-100 font-bold tracking-widest"
|
||||
>
|
||||
保存修改
|
||||
</GlassButton>
|
||||
<GlassButton
|
||||
onClick={handleCancel}
|
||||
className="px-6 py-3 text-white/40"
|
||||
>
|
||||
返回
|
||||
</GlassButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
{isEditing ? renderEditMode() : renderViewMode()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileModal;
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { UserCog, PenTool, Sparkles, BookOpen, Loader2 } from 'lucide-react';
|
||||
import { GlassCard, GlassButton, GlassInput, GlassSelect } from '../components/ui';
|
||||
import useStore from '../store/useStore';
|
||||
import { generateEpicScript } from '../services/ai';
|
||||
import { scriptStyles, scriptLengths } from '../utils/constants';
|
||||
|
||||
/**
|
||||
* ScriptView 组件
|
||||
* 爽文剧本视图,包含角色设定、创作需求和剧本展示
|
||||
* @param {Object} props
|
||||
* @param {Function} props.onOpenProfile - 打开用户资料模态框回调
|
||||
*/
|
||||
const ScriptView = ({ onOpenProfile }) => {
|
||||
const {
|
||||
registrationData,
|
||||
lifeEvents,
|
||||
scripts,
|
||||
selectedScriptId,
|
||||
addScript,
|
||||
setSelectedScriptId,
|
||||
getSelectedScript,
|
||||
loadScripts
|
||||
} = useStore();
|
||||
|
||||
// 加载剧本列表
|
||||
useEffect(() => {
|
||||
loadScripts().catch(() => {
|
||||
// 后端不可用时忽略错误
|
||||
});
|
||||
}, [loadScripts]);
|
||||
|
||||
// 表单状态
|
||||
const [theme, setTheme] = useState('');
|
||||
const [style, setStyle] = useState(scriptStyles[0].value);
|
||||
const [length, setLength] = useState(scriptLengths[0].value);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
/**
|
||||
* 处理剧本生成
|
||||
*/
|
||||
const handleGenerate = async () => {
|
||||
if (!theme) {
|
||||
alert('请输入主题');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const content = await generateEpicScript(
|
||||
{ theme, style, length, character: registrationData },
|
||||
lifeEvents
|
||||
);
|
||||
|
||||
addScript({ theme, style, length, content });
|
||||
setTheme('');
|
||||
} catch (error) {
|
||||
console.error('Failed to generate script:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化剧本内容,高亮【标题】
|
||||
*/
|
||||
const formatScriptContent = (content) => {
|
||||
if (!content) return '';
|
||||
return content.replace(
|
||||
/【([^】]+)】/g,
|
||||
'<div class="mt-8 mb-4 text-orange-100 font-bold text-lg border-l-2 border-orange-400 pl-4">【$1】</div>'
|
||||
);
|
||||
};
|
||||
|
||||
const selectedScript = getSelectedScript();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
{/* 左侧面板 */}
|
||||
<div className="lg:col-span-4 space-y-6">
|
||||
{/* 角色设定卡片 */}
|
||||
<GlassCard className="border-white/10 space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-white/5">
|
||||
<UserCog className="w-4 h-4 text-orange-200" />
|
||||
<h4 className="text-sm font-bold tracking-widest text-white/80 uppercase">角色设定</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-4 text-[11px]">
|
||||
<div>
|
||||
<label className="text-white/20 block">昵称</label>
|
||||
<span className="text-white/70">{registrationData.nickname || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-white/20 block">星座</label>
|
||||
<span className="text-white/70">{registrationData.zodiac || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-white/20 block">MBTI</label>
|
||||
<span className="text-white/70">{registrationData.mbti || '-'}</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-white/20 block">兴趣爱好</label>
|
||||
<span className="text-white/70">
|
||||
{registrationData.hobbies?.join(', ') || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onOpenProfile}
|
||||
className="w-full py-2 text-[10px] text-orange-200/50 hover:text-orange-200 border border-white/5 rounded-xl transition-all"
|
||||
>
|
||||
修改人设
|
||||
</button>
|
||||
</GlassCard>
|
||||
|
||||
{/* 创作需求表单 */}
|
||||
<GlassCard className="border-white/10 space-y-6">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-white/5">
|
||||
<PenTool className="w-4 h-4 text-orange-200" />
|
||||
<h4 className="text-sm font-bold tracking-widest text-white/80 uppercase">创作需求</h4>
|
||||
</div>
|
||||
<GlassInput
|
||||
label="剧本主题"
|
||||
placeholder="例如:我在职场逆袭了"
|
||||
value={theme}
|
||||
onChange={setTheme}
|
||||
/>
|
||||
<GlassSelect
|
||||
label="叙事风格"
|
||||
options={scriptStyles}
|
||||
value={style}
|
||||
onChange={setStyle}
|
||||
/>
|
||||
<GlassSelect
|
||||
label="剧本篇幅"
|
||||
options={scriptLengths}
|
||||
value={length}
|
||||
onChange={setLength}
|
||||
/>
|
||||
<GlassButton
|
||||
onClick={handleGenerate}
|
||||
disabled={isLoading}
|
||||
className="w-full py-4 bg-orange-200/5 text-orange-200 font-bold text-sm tracking-widest border-orange-200/20"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
编撰中...
|
||||
</>
|
||||
) : (
|
||||
'开启天命编撰'
|
||||
)}
|
||||
</GlassButton>
|
||||
</GlassCard>
|
||||
|
||||
{/* 历史卷轴列表 */}
|
||||
<div className="space-y-4">
|
||||
<h5 className="text-[10px] text-white/20 uppercase tracking-widest font-bold px-2">
|
||||
历史卷轴
|
||||
</h5>
|
||||
<div className="space-y-2 max-h-[25vh] overflow-y-auto custom-scrollbar">
|
||||
{scripts.length > 0 ? (
|
||||
scripts.map((script) => (
|
||||
<div
|
||||
key={script.id}
|
||||
onClick={() => setSelectedScriptId(script.id)}
|
||||
className={`
|
||||
p-3 glass-card text-left cursor-pointer hover:bg-white/5 border-white/5 transition-all
|
||||
${script.id === selectedScriptId ? 'border-orange-200/30 bg-orange-200/5' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="text-[11px] text-white/80 truncate">{script.theme}</div>
|
||||
<div className="text-[9px] text-white/30 flex justify-between mt-1">
|
||||
<span>{script.style} | {script.length}</span>
|
||||
<span>{script.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-center text-xs text-white/10 py-4 italic">暂无卷轴</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧剧本展示区 */}
|
||||
<div className="lg:col-span-8">
|
||||
<div className="h-full">
|
||||
{selectedScript ? (
|
||||
<GlassCard className="h-full overflow-y-auto custom-scrollbar border-orange-200/20 shadow-2xl animate-fade-in" padding="lg">
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<div className="flex justify-between items-center mb-8 pb-4 border-b border-white/5">
|
||||
<div>
|
||||
<h4 className="text-2xl font-serif text-orange-200">{selectedScript.theme}</h4>
|
||||
<p className="text-[10px] text-white/30 mt-1 uppercase tracking-widest">
|
||||
{selectedScript.style}篇 · {selectedScript.length}卷
|
||||
</p>
|
||||
</div>
|
||||
<BookOpen className="text-white/20" />
|
||||
</div>
|
||||
<div
|
||||
className="text-white/70 leading-loose whitespace-pre-wrap space-y-6 text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: formatScriptContent(selectedScript.content) }}
|
||||
/>
|
||||
</div>
|
||||
</GlassCard>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center opacity-20 py-32">
|
||||
<Sparkles className="w-20 h-20 mb-6" />
|
||||
<p className="text-xl font-serif">请在左侧设定需求,开启你的天命爽文</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScriptView;
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Wind, Sparkles } from 'lucide-react';
|
||||
import { GlassCard, GlassButton, GlassInput, GlassTextarea } from '../components/ui';
|
||||
import Modal from '../components/Modal';
|
||||
import useStore from '../store/useStore';
|
||||
import { analyzeLifeEvent } from '../services/ai';
|
||||
|
||||
/**
|
||||
* TimelineView 组件
|
||||
* 生命长河视图,显示和管理生命事件
|
||||
*/
|
||||
const TimelineView = () => {
|
||||
const { lifeEvents, addLifeEvent, loadLifeEvents } = useStore();
|
||||
|
||||
// 加载生命事件
|
||||
useEffect(() => {
|
||||
loadLifeEvents().catch(() => {
|
||||
// 后端不可用时忽略错误
|
||||
});
|
||||
}, [loadLifeEvents]);
|
||||
|
||||
// 模态框状态
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 表单状态
|
||||
const [eventForm, setEventForm] = useState({
|
||||
title: '',
|
||||
time: '',
|
||||
content: ''
|
||||
});
|
||||
|
||||
/**
|
||||
* 处理表单提交
|
||||
*/
|
||||
const handleSubmit = async () => {
|
||||
if (!eventForm.title || !eventForm.time || !eventForm.content) {
|
||||
alert('请完整填写记录。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 调用 AI 分析
|
||||
const aiFeedback = await analyzeLifeEvent(eventForm);
|
||||
|
||||
// 添加事件
|
||||
addLifeEvent({
|
||||
...eventForm,
|
||||
aiFeedback
|
||||
});
|
||||
|
||||
// 重置表单并关闭模态框
|
||||
setEventForm({ title: '', time: '', content: '' });
|
||||
setIsModalOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to analyze event:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 按时间倒序排列事件
|
||||
*/
|
||||
const sortedEvents = [...lifeEvents].sort(
|
||||
(a, b) => new Date(b.time) - new Date(a.time)
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 标题区域 */}
|
||||
<div className="flex justify-between items-end mb-12">
|
||||
<div>
|
||||
<h3 className="text-4xl font-serif text-white/90">生命长河</h3>
|
||||
<p className="text-sm text-white/30 mt-2">塑造你的每一刻,都被星辰见证。</p>
|
||||
</div>
|
||||
<GlassButton
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="px-6 py-3 rounded-full text-sm font-bold flex items-center gap-2 bg-orange-200/5 text-orange-200 border-orange-200/20 shadow-lg"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> 记录足迹
|
||||
</GlassButton>
|
||||
</div>
|
||||
|
||||
{/* 时间线容器 */}
|
||||
<div className="relative pl-8">
|
||||
{sortedEvents.length > 0 && <div className="timeline-line" />}
|
||||
|
||||
<div className="space-y-10">
|
||||
{sortedEvents.length > 0 ? (
|
||||
sortedEvents.map((event) => (
|
||||
<div key={event.id} className="relative group">
|
||||
{/* 时间线点 */}
|
||||
<div className="timeline-dot absolute left-[-39px] top-6 z-10" />
|
||||
|
||||
{/* 事件卡片 */}
|
||||
<GlassCard className="border-white/5 hover:border-orange-200/20 transition-all duration-700">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h4 className="text-xl font-medium text-white/80">{event.title}</h4>
|
||||
<span className="text-[10px] font-mono tracking-widest text-white/30 uppercase">
|
||||
{event.time}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-white/60 leading-relaxed mb-6">
|
||||
{event.content}
|
||||
</p>
|
||||
|
||||
{/* AI 反馈区域 */}
|
||||
<div className="ai-glow-card p-5 rounded-2xl bg-orange-200/[0.02] border border-orange-200/5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Sparkles className="w-3 h-3 text-orange-200" />
|
||||
<span className="text-[9px] uppercase tracking-[0.2em] text-orange-200/60 font-bold">
|
||||
引路人洞察
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs italic text-white/50 leading-loose">
|
||||
{event.aiFeedback}
|
||||
</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
/* 空状态 */
|
||||
<div className="flex flex-col items-center justify-center py-32 text-center opacity-30">
|
||||
<Wind className="w-12 h-12 mb-4" />
|
||||
<p className="font-serif italic text-lg">此间尚无回响,等待你执笔...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 添加事件模态框 */}
|
||||
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} title="记录足迹">
|
||||
<div className="space-y-6">
|
||||
<GlassInput
|
||||
label="事件标题"
|
||||
placeholder="给这段经历起个名字"
|
||||
value={eventForm.title}
|
||||
onChange={(v) => setEventForm(prev => ({ ...prev, title: v }))}
|
||||
/>
|
||||
<GlassInput
|
||||
label="发生时间"
|
||||
type="date"
|
||||
value={eventForm.time}
|
||||
onChange={(v) => setEventForm(prev => ({ ...prev, time: v }))}
|
||||
/>
|
||||
<GlassTextarea
|
||||
label="经历详情"
|
||||
placeholder="当时发生了什么?你的感受如何?"
|
||||
value={eventForm.content}
|
||||
onChange={(v) => setEventForm(prev => ({ ...prev, content: v }))}
|
||||
rows={5}
|
||||
/>
|
||||
<GlassButton
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
loading={isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? '正在共鸣生命轨迹...' : '开启 AI 疗愈'}
|
||||
</GlassButton>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineView;
|
||||
Reference in New Issue
Block a user