Files
ittoview/src/pages/ProcessDetailPage.tsx
ittoview 0a5788e52c feat(过程详情): 新增主要作用字段替换5W1H显示
- types/itto.ts: Process 新增 purpose 可选字段
- processes.json: P8.7 监督风险添加 purpose 示例数据
- ProcessDetailPage: 隐藏5W1H,改为显示主要作用卡片
- CLAUDE.md: 记录三类日常学习内容更新操作指南

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-23 06:32:09 +00:00

404 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useParams, Link, useLocation, useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ArrowLeft, ArrowRight, FileText, Wrench, FileOutput, LayoutGrid, Workflow, Eye, EyeOff, Info } from 'lucide-react'
import { getProcessDetail, processes } from '@/data'
import { useState, useEffect } from 'react'
type IttoSection = 'inputs' | 'tools' | 'outputs'
const STORAGE_KEY = 'ittoview:process-detail:itto-visibility'
export function ProcessDetailPage() {
const { id } = useParams()
const location = useLocation()
const navigate = useNavigate()
const processDetail = id ? getProcessDetail(id) : null
// ITTO 显示/隐藏状态管理
const [visible, setVisible] = useState<Record<IttoSection, boolean>>(() => {
const defaultVisible = { inputs: true, tools: true, outputs: true }
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return defaultVisible
const parsed = JSON.parse(raw)
// 数据校验:确保是对象且包含正确的键
if (typeof parsed !== 'object' || parsed === null) return defaultVisible
return {
inputs: typeof parsed.inputs === 'boolean' ? parsed.inputs : true,
tools: typeof parsed.tools === 'boolean' ? parsed.tools : true,
outputs: typeof parsed.outputs === 'boolean' ? parsed.outputs : true,
}
} catch {
return defaultVisible
}
})
// 持久化状态
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(visible))
} catch (error) {
// 在隐私模式或受限环境下localStorage 可能不可用
console.warn('无法保存 ITTO 显示状态:', error)
}
}, [visible])
const toggleSection = (key: IttoSection) => {
setVisible(prev => ({ ...prev, [key]: !prev[key] }))
}
const allVisible = Object.values(visible).every(Boolean)
const toggleAll = () => {
const newState = !allVisible
setVisible({ inputs: newState, tools: newState, outputs: newState })
}
const fromMatrix = location.state?.from === 'matrix'
const fromRoadmap = location.state?.from === 'roadmap'
if (!processDetail) {
return (
<div className="flex flex-col items-center justify-center py-20">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4"></h2>
<Link to="/knowledge-areas" className="text-indigo-600 dark:text-indigo-400 hover:underline">
</Link>
</div>
)
}
const ka = processDetail.knowledgeArea
const pg = processDetail.processGroup
const purpose = (processDetail as any).purpose
const currentIndex = processes.findIndex(p => p.id === id)
const prevProcess = currentIndex > 0 ? processes[currentIndex - 1] : null
const nextProcess = currentIndex < processes.length - 1 ? processes[currentIndex + 1] : null
return (
<div className="space-y-4">
{/* 返回按钮 + 面包屑 */}
<div className="flex items-center gap-3 text-sm">
{fromMatrix && (
<button
onClick={() => navigate('/process-matrix')}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-colors font-medium"
>
<LayoutGrid size={14} />
</button>
)}
{fromRoadmap && (
<button
onClick={() => navigate('/process-roadmap')}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/50 transition-colors font-medium"
>
<Workflow size={14} />
</button>
)}
<nav className="flex items-center gap-1.5 text-gray-500 dark:text-gray-400">
<Link to="/knowledge-areas" className="hover:text-indigo-600 dark:hover:text-indigo-400"></Link>
<span>/</span>
{ka && (
<>
<Link to={`/knowledge-areas/${ka.id}`} className="hover:text-indigo-600 dark:hover:text-indigo-400">{ka.name}</Link>
<span>/</span>
</>
)}
<span className="text-gray-900 dark:text-white">{processDetail.name}</span>
</nav>
</div>
{/* 过程标题 - 更紧凑 */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-xl p-4 bg-white dark:bg-gray-800 shadow-sm border border-gray-100 dark:border-gray-700"
>
<div className="flex items-center gap-3">
<div
className="flex h-12 w-12 items-center justify-center rounded-lg text-white font-bold text-lg shrink-0"
style={{ backgroundColor: ka?.color || '#6366F1' }}
>
{processDetail.code}
</div>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-bold text-gray-900 dark:text-white truncate">{processDetail.name}</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">{processDetail.nameEn}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{ka && (
<span className="px-2.5 py-1 rounded-full text-xs font-medium text-white" style={{ backgroundColor: ka.color }}>
{ka.name}
</span>
)}
{pg && (
<span className="px-2.5 py-1 rounded-full text-xs font-medium text-white" style={{ backgroundColor: pg.color }}>
{pg.name}
</span>
)}
</div>
</div>
</motion.div>
{/* 主要作用 */}
{purpose && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
className="bg-indigo-50 dark:bg-indigo-900/20 rounded-xl p-4 border border-indigo-100 dark:border-indigo-800"
>
<div className="flex items-center gap-2 mb-2">
<Info size={16} className="text-indigo-600 dark:text-indigo-400 shrink-0" />
<h3 className="text-sm font-semibold text-indigo-900 dark:text-indigo-100"></h3>
</div>
<p className="text-sm text-indigo-800 dark:text-indigo-200 leading-relaxed">{purpose}</p>
</motion.div>
)}
{/* 全局控制按钮 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.08 }}
className="flex justify-end"
>
<button
type="button"
onClick={toggleAll}
aria-label={allVisible ? '隐藏所有 ITTO 内容' : '显示所有 ITTO 内容'}
aria-pressed={allVisible}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-colors text-sm font-medium"
>
{allVisible ? <EyeOff size={16} /> : <Eye size={16} />}
<span>{allVisible ? '全部隐藏' : '全部显示'}</span>
</button>
</motion.div>
{/* ITTO表格 - 更紧凑 */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="grid lg:grid-cols-3 gap-4"
>
{/* 输入 */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-blue-50 dark:bg-blue-900/30 border-b border-blue-100 dark:border-blue-800">
<div className="flex items-center gap-2">
<FileText size={16} className="text-blue-600 dark:text-blue-400" />
<h3 className="font-semibold text-blue-900 dark:text-blue-100 text-sm"> ({processDetail.inputs.length})</h3>
</div>
<button
type="button"
onClick={() => toggleSection('inputs')}
aria-label={visible.inputs ? '隐藏输入' : '显示输入'}
aria-pressed={visible.inputs}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors"
>
{visible.inputs ? <EyeOff size={14} /> : <Eye size={14} />}
<span>{visible.inputs ? '隐藏' : '显示'}</span>
</button>
</div>
<motion.div
animate={{
maxHeight: visible.inputs ? 2000 : 0,
opacity: visible.inputs ? 1 : 0
}}
initial={false}
transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
{visible.inputs ? (
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
{processDetail.inputDetails?.map((inputDetail: any) => {
const hasDetail = inputDetail.detail && inputDetail.detail.length > 0
return (
<li key={inputDetail.id} className="px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div className="font-medium text-gray-900 dark:text-white text-sm">{inputDetail.name || inputDetail.id}</div>
{inputDetail.nameEn && <div className="text-xs text-gray-500 dark:text-gray-400">{inputDetail.nameEn}</div>}
{hasDetail && (
<div className="mt-1.5 text-xs text-gray-600 dark:text-gray-400">
{inputDetail.detail.map((item: any, idx: number) => (
<span key={item.id || idx}>
{item.label}
{idx < inputDetail.detail.length - 1 && '、'}
</span>
))}
</div>
)}
{inputDetail.note && (
<div className="mt-1.5 text-xs text-gray-500 dark:text-gray-400 italic">
💡 {inputDetail.note}
</div>
)}
</li>
)
})}
</ul>
) : (
<div className="h-0" />
)}
</motion.div>
</div>
{/* 工具与技术 */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-amber-50 dark:bg-amber-900/30 border-b border-amber-100 dark:border-amber-800">
<div className="flex items-center gap-2">
<Wrench size={16} className="text-amber-600 dark:text-amber-400" />
<h3 className="font-semibold text-amber-900 dark:text-amber-100 text-sm"> ({processDetail.tools.length})</h3>
</div>
<button
type="button"
onClick={() => toggleSection('tools')}
aria-label={visible.tools ? '隐藏工具与技术' : '显示工具与技术'}
aria-pressed={visible.tools}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/50 transition-colors"
>
{visible.tools ? <EyeOff size={14} /> : <Eye size={14} />}
<span>{visible.tools ? '隐藏' : '显示'}</span>
</button>
</div>
<motion.div
animate={{
maxHeight: visible.tools ? 2000 : 0,
opacity: visible.tools ? 1 : 0
}}
initial={false}
transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
{visible.tools ? (
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
{processDetail.toolDetails?.map((toolDetail: any) => {
const hasDetail = toolDetail.detail && toolDetail.detail.length > 0
return (
<li key={toolDetail.id} className="px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div className="font-medium text-gray-900 dark:text-white text-sm">{toolDetail.name || toolDetail.id}</div>
{toolDetail.nameEn && <div className="text-xs text-gray-500 dark:text-gray-400">{toolDetail.nameEn}</div>}
{hasDetail && (
<div className="mt-1.5 text-xs text-gray-600 dark:text-gray-400">
{toolDetail.detail.map((item: any, idx: number) => (
<span key={item.id || idx}>
{item.label}
{idx < toolDetail.detail.length - 1 && '、'}
</span>
))}
</div>
)}
{toolDetail.note && (
<div className="mt-1.5 text-xs text-gray-500 dark:text-gray-400 italic">
💡 {toolDetail.note}
</div>
)}
</li>
)
})}
</ul>
) : (
<div className="h-0" />
)}
</motion.div>
</div>
{/* 输出 */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-emerald-50 dark:bg-emerald-900/30 border-b border-emerald-100 dark:border-emerald-800">
<div className="flex items-center gap-2">
<FileOutput size={16} className="text-emerald-600 dark:text-emerald-400" />
<h3 className="font-semibold text-emerald-900 dark:text-emerald-100 text-sm"> ({processDetail.outputs.length})</h3>
</div>
<button
type="button"
onClick={() => toggleSection('outputs')}
aria-label={visible.outputs ? '隐藏输出' : '显示输出'}
aria-pressed={visible.outputs}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium text-emerald-700 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-900/50 transition-colors"
>
{visible.outputs ? <EyeOff size={14} /> : <Eye size={14} />}
<span>{visible.outputs ? '隐藏' : '显示'}</span>
</button>
</div>
<motion.div
animate={{
maxHeight: visible.outputs ? 2000 : 0,
opacity: visible.outputs ? 1 : 0
}}
initial={false}
transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
{visible.outputs ? (
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
{processDetail.outputDetails?.map((outputDetail: any) => {
const hasDetail = outputDetail.detail && outputDetail.detail.length > 0
return (
<li key={outputDetail.id} className="px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div className="font-medium text-gray-900 dark:text-white text-sm">{outputDetail.name || outputDetail.id}</div>
{outputDetail.nameEn && <div className="text-xs text-gray-500 dark:text-gray-400">{outputDetail.nameEn}</div>}
{hasDetail && (
<div className="mt-1.5 text-xs text-gray-600 dark:text-gray-400">
{outputDetail.detail.map((item: any, idx: number) => (
<span key={item.id || idx}>
{item.label}
{idx < outputDetail.detail.length - 1 && '、'}
</span>
))}
</div>
)}
{outputDetail.note && (
<div className="mt-1.5 text-xs text-gray-500 dark:text-gray-400 italic">
💡 {outputDetail.note}
</div>
)}
</li>
)
})}
</ul>
) : (
<div className="h-0" />
)}
</motion.div>
</div>
</motion.div>
{/* 前后导航 - 更紧凑 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15 }}
className="flex items-center justify-between pt-3 border-t border-gray-200 dark:border-gray-700"
>
{prevProcess ? (
<Link
to={`/process/${prevProcess.id}`}
state={location.state}
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors text-sm"
>
<ArrowLeft size={16} />
<div>
<div className="text-xs text-gray-400"></div>
<div className="font-medium">{prevProcess.code} {prevProcess.name}</div>
</div>
</Link>
) : <div />}
{nextProcess ? (
<Link
to={`/process/${nextProcess.id}`}
state={location.state}
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors text-right text-sm"
>
<div>
<div className="text-xs text-gray-400"></div>
<div className="font-medium">{nextProcess.code} {nextProcess.name}</div>
</div>
<ArrowRight size={16} />
</Link>
) : <div />}
</motion.div>
</div>
)
}