feat: add ITTO collections view

This commit is contained in:
ittoview
2026-05-18 16:12:41 +01:00
parent 35c0146a96
commit f9cb27b14e
5 changed files with 203 additions and 3 deletions

View File

@@ -17,6 +17,7 @@ import PerformanceDomainPracticePage from './pages/PerformanceDomainPracticePage
import KnowledgeAreasTailoringPage from './pages/KnowledgeAreasTailoringPage'
import { LearningMapsPage } from './pages/LearningMapsPage'
import { ApiDocPage } from './pages/ApiDocPage'
import { IttoCollectionsPage } from './pages/IttoCollectionsPage'
function App() {
return (
@@ -30,6 +31,7 @@ function App() {
<Route path="/process-groups/:id" element={<ProcessGroupsPage />} />
<Route path="/process/:id" element={<ProcessDetailPage />} />
<Route path="/process-matrix" element={<ProcessMatrixPage />} />
<Route path="/itto-collections" element={<IttoCollectionsPage />} />
<Route path="/process-graph" element={<ProcessGraphPage />} />
<Route path="/process-practice" element={<ProcessPracticePage />} />
<Route path="/process-purpose-practice" element={<ProcessPurposePracticePage />} />

View File

@@ -0,0 +1,197 @@
import { useMemo, useState } from 'react'
import { motion } from 'framer-motion'
import { FileOutput, FileText, Wrench } from 'lucide-react'
import {
artifactMap,
knowledgeAreas,
processGroups,
processes,
toolMap,
normalizeProcessRef,
} from '@/data'
import type { Process, ProcessRef } from '@/types/itto'
type ViewKey = 'inputs' | 'tools' | 'outputs'
type CollectionRow = {
processGroupId: string
processGroupName: string
color: string
items: string[]
}
type CollectionArea = {
id: string
order: number
name: string
nameEn: string
color: string
rows: CollectionRow[]
}
const tabs: Array<{ key: ViewKey; label: string; icon: typeof FileText }> = [
{ key: 'inputs', label: '输入', icon: FileText },
{ key: 'tools', label: '工具', icon: Wrench },
{ key: 'outputs', label: '输出', icon: FileOutput },
]
const containerVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { staggerChildren: 0.03 } },
}
const itemVariants = {
hidden: { opacity: 0, y: 10 },
visible: { opacity: 1, y: 0 },
}
function formatRef(ref: ProcessRef, viewKey: ViewKey) {
const normalized = normalizeProcessRef(ref)
const entity = viewKey === 'tools' ? toolMap.get(normalized.id) : artifactMap.get(normalized.id)
const name = entity?.name ?? normalized.id
const detail = normalized.detail?.map((item) => item.label).filter(Boolean) ?? []
if (!detail.length) return name
return `${name}${detail.join('、')}`
}
function getRefsByView(process: Process, viewKey: ViewKey) {
if (viewKey === 'inputs') return process.inputs
if (viewKey === 'tools') return process.tools
return process.outputs
}
function uniqueItems(items: string[]) {
return Array.from(new Set(items))
}
function buildCollection(viewKey: ViewKey): CollectionArea[] {
return knowledgeAreas.map((area) => {
const areaProcesses = processes.filter((process) => process.knowledgeAreaId === area.id)
const rows = processGroups.map((group) => {
const groupProcesses = areaProcesses.filter((process) => process.processGroupId === group.id)
const items = uniqueItems(
groupProcesses.flatMap((process) =>
getRefsByView(process, viewKey).map((ref) => formatRef(ref, viewKey))
)
)
return {
processGroupId: group.id,
processGroupName: group.name,
color: group.color,
items,
}
}).filter((row) => row.items.length > 0)
return {
id: area.id,
order: area.order,
name: area.name,
nameEn: area.nameEn,
color: area.color,
rows,
}
})
}
export function IttoCollectionsPage() {
const [activeTab, setActiveTab] = useState<ViewKey>('inputs')
const collection = useMemo(() => buildCollection(activeTab), [activeTab])
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white"> · · </h1>
<p className="text-sm text-gray-500 dark:text-gray-400"></p>
</div>
<div className="inline-flex rounded-xl bg-white p-1 shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700">
{tabs.map((tab) => {
const Icon = tab.icon
const isActive = activeTab === tab.key
return (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={`inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
isActive
? 'bg-indigo-600 text-white shadow-sm'
: 'text-gray-600 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
<Icon size={16} />
{tab.label}
</button>
)
})}
</div>
</div>
<motion.div
key={activeTab}
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-4"
>
{collection.map((area) => (
<motion.section
key={area.id}
variants={itemVariants}
className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700"
>
<div
className="flex items-center gap-3 border-b border-gray-100 p-4 dark:border-gray-700"
style={{ backgroundColor: `${area.color}10` }}
>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-sm font-bold text-white"
style={{ backgroundColor: area.color }}
>
{area.order}
</div>
<div className="min-w-0">
<h2 className="text-base font-semibold text-gray-900 dark:text-white">{area.name}</h2>
<p className="truncate text-xs text-gray-500 dark:text-gray-400">{area.nameEn}</p>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-100 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900/40">
<tr>
<th className="w-36 px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400">
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400">
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{area.rows.map((row) => (
<tr key={row.processGroupId} className="align-top">
<td className="px-4 py-3">
<span
className="inline-flex rounded-full px-2.5 py-1 text-xs font-medium text-white"
style={{ backgroundColor: row.color }}
>
{row.processGroupName}
</span>
</td>
<td className="px-4 py-3 text-sm leading-6 text-gray-700 dark:text-gray-300">
{row.items.join('')}
</td>
</tr>
))}
</tbody>
</table>
</div>
</motion.section>
))}
</motion.div>
</div>
)
}

View File

@@ -795,7 +795,7 @@ export function ProcessDetailPage() {
</div>
{showAnswer && practiceMode === 'proficient' && (
<div className="text-indigo-600 dark:text-indigo-300">
{SECTION_LABELS[currentSection]}{currentSectionRemainingItems.length}
{SECTION_LABELS[currentSection]}{currentSectionRemainingItems.length}
</div>
)}
{showAnswer && practiceMode === 'standard' && currentPracticeItem && (
@@ -1021,7 +1021,7 @@ function PracticeList({
{'_'.repeat(item.name.length)}
</span>
{mode === 'proficient' && isCurrentSection && (
<span className="text-[11px] text-indigo-300 dark:text-indigo-500"></span>
<span className="text-[11px] text-indigo-300 dark:text-indigo-500"></span>
)}
</div>
)}

View File

@@ -116,7 +116,7 @@ export function ToolDetailPage() {
{usedByProcesses.length === 0 && (
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-sm border border-gray-100 dark:border-gray-700 text-center text-gray-500 dark:text-gray-400">
使
</div>
)}
</div>

View File

@@ -11,3 +11,4 @@ export { SettingsPage } from './SettingsPage'
export { PerformanceDomainsPage } from './PerformanceDomainsPage'
export { LearningMapsPage } from './LearningMapsPage'
export { IttoCollectionsPage } from './IttoCollectionsPage'