feat: add ITTO text highlight filter
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { FileOutput, FileText, Wrench } from 'lucide-react'
|
||||
import { FileOutput, FileText, Wrench, X } from 'lucide-react'
|
||||
import {
|
||||
artifactMap,
|
||||
knowledgeAreas,
|
||||
@@ -12,12 +12,17 @@ import type { Process, ProcessRef } from '@/types/itto'
|
||||
|
||||
type ViewKey = 'inputs' | 'tools' | 'outputs'
|
||||
|
||||
type CollectionItem = {
|
||||
label: string
|
||||
details: string[]
|
||||
}
|
||||
|
||||
type CollectionRow = {
|
||||
processId: string
|
||||
processCode: string
|
||||
processName: string
|
||||
processNameEn: string
|
||||
items: string[]
|
||||
items: CollectionItem[]
|
||||
}
|
||||
|
||||
type CollectionArea = {
|
||||
@@ -45,14 +50,13 @@ const itemVariants = {
|
||||
visible: { opacity: 1, y: 0 },
|
||||
}
|
||||
|
||||
function formatRef(ref: ProcessRef, viewKey: ViewKey) {
|
||||
function formatRef(ref: ProcessRef, viewKey: ViewKey): CollectionItem {
|
||||
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) ?? []
|
||||
const label = entity?.name ?? normalized.id
|
||||
const details = normalized.detail?.map((item) => item.label).filter(Boolean) ?? []
|
||||
|
||||
if (!detail.length) return name
|
||||
return `${name}(${detail.join('、')})`
|
||||
return { label, details }
|
||||
}
|
||||
|
||||
function getRefsByView(process: Process, viewKey: ViewKey) {
|
||||
@@ -61,8 +65,67 @@ function getRefsByView(process: Process, viewKey: ViewKey) {
|
||||
return process.outputs
|
||||
}
|
||||
|
||||
function uniqueItems(items: string[]) {
|
||||
return Array.from(new Set(items))
|
||||
function uniqueItems(items: CollectionItem[]) {
|
||||
const seen = new Set<string>()
|
||||
return items.filter((item) => {
|
||||
const key = `${item.label}__${item.details.join('、')}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function itemMatchesSelected(item: CollectionItem, selectedText: string | null) {
|
||||
if (!selectedText) return false
|
||||
return item.label === selectedText || item.details.includes(selectedText)
|
||||
}
|
||||
|
||||
function renderSelectableText(
|
||||
text: string,
|
||||
selectedText: string | null,
|
||||
onSelect: (text: string) => void
|
||||
) {
|
||||
const isSelected = selectedText === text
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => onSelect(text)}
|
||||
className={`mx-[-2px] rounded px-0.5 text-left transition-colors hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-900/40 dark:hover:text-indigo-300 ${
|
||||
isSelected
|
||||
? 'bg-yellow-200 text-yellow-950 ring-1 ring-yellow-300 dark:bg-yellow-500/30 dark:text-yellow-100 dark:ring-yellow-500/40'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function renderCollectionItems(
|
||||
items: CollectionItem[],
|
||||
selectedText: string | null,
|
||||
onSelect: (text: string) => void
|
||||
) {
|
||||
return items.map((item, itemIndex) => (
|
||||
<span key={`${item.label}-${item.details.join('-')}-${itemIndex}`}>
|
||||
{itemIndex > 0 && <span className="text-gray-400 dark:text-gray-500">;</span>}
|
||||
{renderSelectableText(item.label, selectedText, onSelect)}
|
||||
{item.details.length > 0 && (
|
||||
<span>
|
||||
<span className="text-gray-500 dark:text-gray-400">(</span>
|
||||
{item.details.map((detail, detailIndex) => (
|
||||
<span key={`${item.label}-${detail}-${detailIndex}`}>
|
||||
{detailIndex > 0 && <span className="text-gray-400 dark:text-gray-500">、</span>}
|
||||
{renderSelectableText(detail, selectedText, onSelect)}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-gray-500 dark:text-gray-400">)</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
|
||||
function buildCollection(viewKey: ViewKey): CollectionArea[] {
|
||||
@@ -91,6 +154,7 @@ function buildCollection(viewKey: ViewKey): CollectionArea[] {
|
||||
|
||||
export function IttoCollectionsPage() {
|
||||
const [activeTab, setActiveTab] = useState<ViewKey>('inputs')
|
||||
const [selectedText, setSelectedText] = useState<string | null>(null)
|
||||
const collection = useMemo(() => buildCollection(activeTab), [activeTab])
|
||||
const activeLabel = tabs.find((tab) => tab.key === activeTab)?.label
|
||||
|
||||
@@ -124,6 +188,20 @@ export function IttoCollectionsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedText && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl bg-yellow-50 px-4 py-3 text-sm text-yellow-900 ring-1 ring-yellow-200 dark:bg-yellow-500/10 dark:text-yellow-100 dark:ring-yellow-500/30">
|
||||
<span className="font-medium">标签:{selectedText}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedText(null)}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-full text-yellow-700 transition-colors hover:bg-yellow-100 hover:text-yellow-900 dark:text-yellow-200 dark:hover:bg-yellow-500/20"
|
||||
aria-label="清除标签"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
variants={containerVariants}
|
||||
@@ -166,27 +244,35 @@ export function IttoCollectionsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{area.rows.map((row) => (
|
||||
<tr key={row.processId} className="align-top">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span
|
||||
className="mt-0.5 inline-flex shrink-0 rounded-md px-2 py-0.5 text-xs font-semibold text-white"
|
||||
style={{ backgroundColor: area.color }}
|
||||
>
|
||||
{row.processCode}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{row.processName}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{row.processNameEn}</div>
|
||||
{area.rows.map((row) => {
|
||||
const rowMatched = row.items.some((item) => itemMatchesSelected(item, selectedText))
|
||||
return (
|
||||
<tr
|
||||
key={row.processId}
|
||||
className={`align-top transition-colors ${
|
||||
rowMatched ? 'bg-yellow-50/60 dark:bg-yellow-500/5' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span
|
||||
className="mt-0.5 inline-flex shrink-0 rounded-md px-2 py-0.5 text-xs font-semibold text-white"
|
||||
style={{ backgroundColor: area.color }}
|
||||
>
|
||||
{row.processCode}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{row.processName}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{row.processNameEn}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm leading-6 text-gray-700 dark:text-gray-300">
|
||||
{row.items.join(';')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm leading-6 text-gray-700 dark:text-gray-300">
|
||||
{renderCollectionItems(row.items, selectedText, setSelectedText)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user