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