359 lines
14 KiB
React
359 lines
14 KiB
React
import React, { useState, useEffect } from 'react';
|
||
import { useStoreData } from '../../hooks/useStoreData';
|
||
import { Store } from '../../utils/store';
|
||
import { AI } from '../../utils/aiLogic';
|
||
import {
|
||
Map,
|
||
Ghost,
|
||
Wand2,
|
||
RefreshCw,
|
||
MapPin,
|
||
ChevronDown,
|
||
Target,
|
||
Activity,
|
||
Package,
|
||
Repeat,
|
||
Edit3,
|
||
Trash2,
|
||
Check,
|
||
ArrowRight
|
||
} from 'lucide-react';
|
||
import { Button } from '../ui/Button';
|
||
import { GlassCard } from '../ui/GlassCard';
|
||
import { Select } from '../ui/Input';
|
||
|
||
export function PathView({ onSwitchToScript }) {
|
||
const data = useStoreData();
|
||
const [selectedScriptId, setSelectedScriptId] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const [isEditing, setIsEditing] = useState(false);
|
||
const [expandedStep, setExpandedStep] = useState(null);
|
||
const [editedPath, setEditedPath] = useState(null);
|
||
|
||
// Initialize selectedScriptId when scripts are available
|
||
useEffect(() => {
|
||
if (data.generatedScripts.length > 0 && !selectedScriptId) {
|
||
setSelectedScriptId(data.generatedScripts[0].id);
|
||
}
|
||
}, [data.generatedScripts, selectedScriptId]);
|
||
|
||
// Find current path based on selected script
|
||
const currentPath = data.paths?.find(p => p.scriptId === selectedScriptId);
|
||
|
||
// Update local edited state when path changes
|
||
useEffect(() => {
|
||
if (currentPath) {
|
||
setEditedPath(JSON.parse(JSON.stringify(currentPath))); // Deep copy
|
||
} else {
|
||
setEditedPath(null);
|
||
}
|
||
}, [currentPath]);
|
||
|
||
const handleGenerate = async () => {
|
||
if (currentPath && !window.confirm('重新生成将覆盖现有路径规划,确定吗?')) return;
|
||
|
||
const script = data.generatedScripts.find(s => s.id === selectedScriptId);
|
||
if (!script) return;
|
||
|
||
setLoading(true);
|
||
try {
|
||
const newPath = await AI.generatePath(script, data.userProfile);
|
||
Store.addPath(newPath);
|
||
setIsEditing(false);
|
||
} catch (e) {
|
||
console.error(e);
|
||
alert('规划失败,请重试');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
if (window.confirm('确定要删除这个路径规划吗?')) {
|
||
Store.deletePath(currentPath.id);
|
||
|
||
// Sync to backend
|
||
try {
|
||
const allPaths = Store.get().paths;
|
||
const profileRes = await userApi.getCurrentUser();
|
||
if (profileRes.data) {
|
||
await userApi.updateUserProfile({
|
||
id: profileRes.data.id,
|
||
paths: JSON.stringify(allPaths)
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to sync path deletion to backend", err);
|
||
}
|
||
|
||
setIsEditing(false);
|
||
}
|
||
};
|
||
|
||
const handleSaveEdit = async () => {
|
||
if (editedPath) {
|
||
Store.updatePath(editedPath.id, { steps: editedPath.steps });
|
||
|
||
// Sync to backend
|
||
try {
|
||
const allPaths = Store.get().paths;
|
||
const profileRes = await userApi.getCurrentUser();
|
||
if (profileRes.data) {
|
||
await userApi.updateUserProfile({
|
||
id: profileRes.data.id,
|
||
paths: JSON.stringify(allPaths)
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to sync path update to backend", err);
|
||
}
|
||
|
||
setIsEditing(false);
|
||
}
|
||
};
|
||
|
||
const handleStepEdit = (stepIndex, field, value) => {
|
||
if (!editedPath) return;
|
||
const newSteps = [...editedPath.steps];
|
||
newSteps[stepIndex] = { ...newSteps[stepIndex], [field]: value };
|
||
setEditedPath({ ...editedPath, steps: newSteps });
|
||
};
|
||
|
||
if (data.generatedScripts.length === 0) {
|
||
return (
|
||
<div className="max-w-4xl mx-auto md:p-12 pb-24">
|
||
<header className="mb-8">
|
||
<h2 className="text-3xl font-bold text-white mb-2 flex items-center gap-2">
|
||
<span className="bg-green-100/10 p-2 rounded-lg text-aurora-green">
|
||
<Map className="w-6 h-6 icon-glow" />
|
||
</span>
|
||
实现路径
|
||
</h2>
|
||
<p className="text-gray-400">将幻想落地为行动,AI为你定制专属计划。</p>
|
||
</header>
|
||
|
||
<div className="p-16 text-center text-gray-400 glass-card rounded-2xl border-dashed border-2 border-white/10">
|
||
<Ghost className="w-16 h-16 mx-auto mb-4 opacity-30" />
|
||
<p className="mb-4">你需要先有一个剧本,才能生成通往它的路径。</p>
|
||
<button
|
||
className="text-primary font-bold hover:underline flex items-center justify-center gap-1 mx-auto"
|
||
onClick={onSwitchToScript}
|
||
>
|
||
去生成剧本 <ArrowRight className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-4xl mx-auto md:p-12 pb-24">
|
||
<header className="mb-8">
|
||
<h2 className="text-3xl font-bold text-white mb-2 flex items-center gap-2">
|
||
<span className="bg-green-100/10 p-2 rounded-lg text-aurora-green">
|
||
<Map className="w-6 h-6 icon-glow" />
|
||
</span>
|
||
实现路径
|
||
</h2>
|
||
<p className="text-gray-400">将幻想落地为行动,AI为你定制专属计划。</p>
|
||
</header>
|
||
|
||
<GlassCard className="mb-8 p-6 rounded-2xl border-l-4 border-accent flex flex-col md:flex-row items-center gap-4 shadow-sm">
|
||
<div className="flex-1 w-full">
|
||
<label className="block text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">
|
||
选择目标剧本
|
||
</label>
|
||
<div className="relative">
|
||
<Select
|
||
value={selectedScriptId}
|
||
onChange={(e) => setSelectedScriptId(e.target.value)}
|
||
className="w-full"
|
||
>
|
||
{data.generatedScripts.map(s => (
|
||
<option key={s.id} value={s.id}>{s.title}</option>
|
||
))}
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
onClick={handleGenerate}
|
||
isLoading={loading}
|
||
className="w-full md:w-auto mt-4 md:mt-0"
|
||
>
|
||
{currentPath ? (
|
||
<>
|
||
<RefreshCw className="w-4 h-4 mr-2" /> 重新生成
|
||
</>
|
||
) : (
|
||
<>
|
||
<Wand2 className="w-4 h-4 mr-2" /> 生成路径
|
||
</>
|
||
)}
|
||
</Button>
|
||
</GlassCard>
|
||
|
||
<div className="transition-all duration-300 min-h-[300px]">
|
||
{!currentPath ? (
|
||
<div className="text-center py-20 opacity-50">
|
||
<div className="w-20 h-20 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4 border border-white/10">
|
||
<MapPin className="w-8 h-8 text-gray-400" />
|
||
</div>
|
||
<p className="text-gray-400">尚未生成路径,点击上方按钮开始规划。</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="flex justify-between items-center mb-6 animate-fade-in">
|
||
<div className="text-sm text-gray-400">
|
||
规划生成于 {new Date(currentPath.createdAt).toLocaleDateString()}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={isEditing ? handleSaveEdit : () => setIsEditing(true)}
|
||
className={`py-1.5 px-4 text-xs flex items-center gap-2 rounded-lg border transition-colors ${
|
||
isEditing
|
||
? 'bg-primary text-white border-primary hover:bg-primary/90'
|
||
: 'border-white/20 text-gray-400 hover:text-primary hover:border-primary bg-transparent'
|
||
}`}
|
||
>
|
||
{isEditing ? (
|
||
<><Check className="w-3 h-3" /> 保存</>
|
||
) : (
|
||
<><Edit3 className="w-3 h-3" /> 编辑</>
|
||
)}
|
||
</button>
|
||
<button
|
||
onClick={handleDelete}
|
||
className="py-1.5 px-4 text-xs flex items-center gap-2 rounded-lg border border-red-500/30 text-red-400 hover:bg-red-500/10 hover:border-red-400 bg-transparent"
|
||
>
|
||
<Trash2 className="w-3 h-3" /> 删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-6 pb-12">
|
||
{(isEditing ? editedPath.steps : currentPath.steps).map((step, idx) => (
|
||
<GlassCard
|
||
key={idx}
|
||
className="relative p-0 overflow-hidden group hover:shadow-lg hover:-translate-y-1 transition-all duration-300"
|
||
>
|
||
{/* Progress Line */}
|
||
<div className="absolute left-8 top-0 bottom-0 w-0.5 bg-white/5 z-0"></div>
|
||
|
||
{/* Header */}
|
||
<div
|
||
className="relative z-10 p-6 cursor-pointer flex items-center justify-between"
|
||
onClick={() => setExpandedStep(expandedStep === idx ? null : idx)}
|
||
>
|
||
<div className="flex items-center gap-5">
|
||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-primary to-green-400 text-white flex items-center justify-center font-bold text-lg shadow-lg shadow-primary/30 shrink-0">
|
||
{idx + 1}
|
||
</div>
|
||
<div>
|
||
<h4 className="font-bold text-lg text-white">{step.phase}</h4>
|
||
<span className="text-xs text-primary font-medium bg-primary/10 px-2 py-0.5 rounded-full border border-primary/20">
|
||
{step.time}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<ChevronDown
|
||
className={`w-5 h-5 text-gray-400 transition-transform duration-300 ${
|
||
expandedStep === idx ? 'rotate-180' : ''
|
||
}`}
|
||
/>
|
||
</div>
|
||
|
||
{/* Details (Collapsible) */}
|
||
<div
|
||
className={`relative z-10 px-6 pb-6 pt-0 border-t border-white/5 pl-[4.5rem] transition-all duration-300 overflow-hidden ${
|
||
expandedStep === idx ? 'max-h-[800px] opacity-100' : 'max-h-0 opacity-0 py-0'
|
||
}`}
|
||
>
|
||
<div className="space-y-5 pt-4">
|
||
{/* Core Content */}
|
||
<div>
|
||
<h5 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2 flex items-center gap-1">
|
||
<Target className="w-3 h-3" /> 核心策略
|
||
</h5>
|
||
<div
|
||
contentEditable={isEditing}
|
||
onBlur={(e) => handleStepEdit(idx, 'content', e.currentTarget.textContent)}
|
||
className={`p-3 rounded-lg text-sm text-gray-300 leading-relaxed border transition-colors ${
|
||
isEditing
|
||
? 'bg-white/10 border-primary/50 ring-2 ring-primary/20'
|
||
: 'bg-white/5 border-white/5'
|
||
}`}
|
||
>
|
||
{step.content}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div>
|
||
<h5 className="text-xs font-bold text-blue-400 uppercase tracking-wider mb-2 flex items-center gap-1">
|
||
<Activity className="w-3 h-3" /> 关键行动
|
||
</h5>
|
||
<div
|
||
contentEditable={isEditing}
|
||
onBlur={(e) => handleStepEdit(idx, 'action', e.currentTarget.textContent)}
|
||
className={`p-3 rounded-lg text-sm text-gray-300 transition-colors ${
|
||
isEditing
|
||
? 'bg-white/10 border border-primary/50 ring-2 ring-primary/20'
|
||
: 'bg-blue-500/10 border border-blue-500/20'
|
||
}`}
|
||
>
|
||
{step.action}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{/* Resources */}
|
||
<div>
|
||
<h5 className="text-xs font-bold text-amber-400 uppercase tracking-wider mb-2 flex items-center gap-1">
|
||
<Package className="w-3 h-3" /> 所需资源
|
||
</h5>
|
||
<div
|
||
contentEditable={isEditing}
|
||
onBlur={(e) => handleStepEdit(idx, 'resources', e.currentTarget.textContent)}
|
||
className={`p-3 rounded-lg text-xs text-gray-400 leading-relaxed transition-colors ${
|
||
isEditing
|
||
? 'bg-white/10 border border-primary/50 ring-2 ring-primary/20'
|
||
: 'bg-amber-500/10 border border-amber-500/20'
|
||
}`}
|
||
>
|
||
{step.resources}
|
||
</div>
|
||
</div>
|
||
{/* Habits */}
|
||
<div>
|
||
<h5 className="text-xs font-bold text-green-400 uppercase tracking-wider mb-2 flex items-center gap-1">
|
||
<Repeat className="w-3 h-3" /> 养成习惯
|
||
</h5>
|
||
<div
|
||
contentEditable={isEditing}
|
||
onBlur={(e) => handleStepEdit(idx, 'habit', e.currentTarget.textContent)}
|
||
className={`p-3 rounded-lg text-xs text-gray-400 leading-relaxed transition-colors ${
|
||
isEditing
|
||
? 'bg-white/10 border border-primary/50 ring-2 ring-primary/20'
|
||
: 'bg-green-500/10 border border-green-500/20'
|
||
}`}
|
||
>
|
||
{step.habit}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</GlassCard>
|
||
))}
|
||
</div>
|
||
|
||
<div className="text-center text-gray-500 text-xs italic mt-8">
|
||
"路虽远,行则将至。"
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|