feat: add learning maps viewer
This commit is contained in:
@@ -13,6 +13,7 @@ import ProcessPracticePage from './pages/ProcessPracticePage'
|
||||
import PrinciplesPage from './pages/PrinciplesPage'
|
||||
import { PerformanceDomainsPage } from './pages/PerformanceDomainsPage'
|
||||
import KnowledgeAreasTailoringPage from './pages/KnowledgeAreasTailoringPage'
|
||||
import { LearningMapsPage } from './pages/LearningMapsPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -31,6 +32,7 @@ function App() {
|
||||
<Route path="/principles" element={<PrinciplesPage />} />
|
||||
<Route path="/performance-domains" element={<PerformanceDomainsPage />} />
|
||||
<Route path="/performance-domains/:id" element={<PerformanceDomainsPage />} />
|
||||
<Route path="/learning-maps" element={<LearningMapsPage />} />
|
||||
<Route path="/artifact/:id" element={<ArtifactDetailPage />} />
|
||||
<Route path="/tool/:id" element={<ToolDetailPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
|
||||
0
src/data/image/.gitkeep
Normal file
0
src/data/image/.gitkeep
Normal file
BIN
src/data/image/合同.png
Normal file
BIN
src/data/image/合同.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
src/data/image/挣值1.jpg
Normal file
BIN
src/data/image/挣值1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 503 KiB |
BIN
src/data/image/挣值2.jpg
Normal file
BIN
src/data/image/挣值2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 453 KiB |
BIN
src/data/image/案例万金油.png
Normal file
BIN
src/data/image/案例万金油.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
src/data/image/进度赶工.png
Normal file
BIN
src/data/image/进度赶工.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
382
src/pages/LearningMapsPage.tsx
Normal file
382
src/pages/LearningMapsPage.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { PointerEvent, WheelEvent } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ChevronLeft, ChevronRight, Clipboard, Download, RotateCcw, X, ZoomIn } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
type LearningMapImage = {
|
||||
src: string
|
||||
fileName: string
|
||||
title: string
|
||||
}
|
||||
|
||||
type Point = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const imageModules = import.meta.glob('../data/image/*.{png,jpg,jpeg,webp,avif,gif}', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
query: '?url',
|
||||
}) as Record<string, string>
|
||||
|
||||
const learningMapImages: LearningMapImage[] = Object.entries(imageModules)
|
||||
.map(([path, src]) => {
|
||||
const fileName = decodeURIComponent(path.split('/').pop() || 'learning-map')
|
||||
const title = fileName.replace(/\.[^.]+$/, '')
|
||||
return { src, fileName, title }
|
||||
})
|
||||
.sort((a, b) => a.fileName.localeCompare(b.fileName, 'zh-CN', { numeric: true }))
|
||||
|
||||
function getDistance(a: Point, b: Point) {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y)
|
||||
}
|
||||
|
||||
function getMidpoint(a: Point, b: Point) {
|
||||
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }
|
||||
}
|
||||
|
||||
function clampScale(value: number) {
|
||||
return Math.min(5, Math.max(1, value))
|
||||
}
|
||||
|
||||
async function blobToPngBlob(blob: Blob) {
|
||||
const bitmap = await createImageBitmap(blob)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = bitmap.width
|
||||
canvas.height = bitmap.height
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error('无法读取图片')
|
||||
|
||||
context.drawImage(bitmap, 0, 0)
|
||||
bitmap.close()
|
||||
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((pngBlob) => {
|
||||
if (pngBlob) resolve(pngBlob)
|
||||
else reject(new Error('无法复制图片'))
|
||||
}, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
export function LearningMapsPage() {
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
||||
const [scale, setScale] = useState(1)
|
||||
const [offset, setOffset] = useState<Point>({ x: 0, y: 0 })
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const pointersRef = useRef(new Map<number, Point>())
|
||||
const lastDragPointRef = useRef<Point | null>(null)
|
||||
const pinchRef = useRef<{ distance: number; scale: number; midpoint: Point } | null>(null)
|
||||
|
||||
const activeImage = activeIndex === null ? null : learningMapImages[activeIndex]
|
||||
const hasPrevious = activeIndex !== null && activeIndex > 0
|
||||
const hasNext = activeIndex !== null && activeIndex < learningMapImages.length - 1
|
||||
|
||||
const viewerStyle = useMemo(
|
||||
() => ({ transform: `translate3d(${offset.x}px, ${offset.y}px, 0) scale(${scale})` }),
|
||||
[offset.x, offset.y, scale]
|
||||
)
|
||||
|
||||
const showMessage = (text: string) => {
|
||||
setMessage(text)
|
||||
window.setTimeout(() => setMessage(''), 1800)
|
||||
}
|
||||
|
||||
const resetView = () => {
|
||||
setScale(1)
|
||||
setOffset({ x: 0, y: 0 })
|
||||
pointersRef.current.clear()
|
||||
lastDragPointRef.current = null
|
||||
pinchRef.current = null
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const openViewer = (index: number) => {
|
||||
setActiveIndex(index)
|
||||
resetView()
|
||||
}
|
||||
|
||||
const closeViewer = () => {
|
||||
setActiveIndex(null)
|
||||
resetView()
|
||||
}
|
||||
|
||||
const goPrevious = () => {
|
||||
setActiveIndex((current) => (current === null || current <= 0 ? current : current - 1))
|
||||
resetView()
|
||||
}
|
||||
|
||||
const goNext = () => {
|
||||
setActiveIndex((current) => (current === null || current >= learningMapImages.length - 1 ? current : current + 1))
|
||||
resetView()
|
||||
}
|
||||
|
||||
const handleWheel = (event: WheelEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
const delta = event.deltaY > 0 ? -0.18 : 0.18
|
||||
setScale((current) => {
|
||||
const nextScale = clampScale(Number((current + delta).toFixed(2)))
|
||||
if (nextScale === 1) setOffset({ x: 0, y: 0 })
|
||||
return nextScale
|
||||
})
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
const point = { x: event.clientX, y: event.clientY }
|
||||
pointersRef.current.set(event.pointerId, point)
|
||||
|
||||
if (pointersRef.current.size === 1) {
|
||||
lastDragPointRef.current = point
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
if (pointersRef.current.size === 2) {
|
||||
const [first, second] = Array.from(pointersRef.current.values())
|
||||
pinchRef.current = { distance: getDistance(first, second), scale, midpoint: getMidpoint(first, second) }
|
||||
setIsDragging(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!pointersRef.current.has(event.pointerId)) return
|
||||
|
||||
const point = { x: event.clientX, y: event.clientY }
|
||||
pointersRef.current.set(event.pointerId, point)
|
||||
|
||||
if (pointersRef.current.size === 2 && pinchRef.current) {
|
||||
const [first, second] = Array.from(pointersRef.current.values())
|
||||
const distance = getDistance(first, second)
|
||||
const midpoint = getMidpoint(first, second)
|
||||
const nextScale = clampScale(pinchRef.current.scale * (distance / pinchRef.current.distance))
|
||||
|
||||
setScale(nextScale)
|
||||
setOffset((current) => ({
|
||||
x: current.x + (midpoint.x - pinchRef.current!.midpoint.x) * 0.8,
|
||||
y: current.y + (midpoint.y - pinchRef.current!.midpoint.y) * 0.8,
|
||||
}))
|
||||
pinchRef.current.midpoint = midpoint
|
||||
return
|
||||
}
|
||||
|
||||
if (pointersRef.current.size === 1 && lastDragPointRef.current && scale > 1) {
|
||||
const previous = lastDragPointRef.current
|
||||
setOffset((current) => ({ x: current.x + point.x - previous.x, y: current.y + point.y - previous.y }))
|
||||
lastDragPointRef.current = point
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerEnd = (event: PointerEvent<HTMLDivElement>) => {
|
||||
pointersRef.current.delete(event.pointerId)
|
||||
pinchRef.current = null
|
||||
|
||||
if (pointersRef.current.size === 1) {
|
||||
lastDragPointRef.current = Array.from(pointersRef.current.values())[0]
|
||||
setIsDragging(true)
|
||||
} else {
|
||||
lastDragPointRef.current = null
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
if (scale <= 1) {
|
||||
setScale(1)
|
||||
setOffset({ x: 0, y: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
const downloadImage = () => {
|
||||
if (!activeImage) return
|
||||
const link = document.createElement('a')
|
||||
link.href = activeImage.src
|
||||
link.download = activeImage.fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
}
|
||||
|
||||
const copyImage = async () => {
|
||||
if (!activeImage) return
|
||||
|
||||
try {
|
||||
if (!navigator.clipboard || !window.ClipboardItem) {
|
||||
showMessage('可下载后保存使用')
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(activeImage.src)
|
||||
const blob = await response.blob()
|
||||
const pngBlob = blob.type === 'image/png' ? blob : await blobToPngBlob(blob)
|
||||
await navigator.clipboard.write([new ClipboardItem({ [pngBlob.type]: pngBlob })])
|
||||
showMessage('图片已复制')
|
||||
} catch {
|
||||
showMessage('可下载后保存使用')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex === null) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') closeViewer()
|
||||
if (event.key === 'ArrowLeft') goPrevious()
|
||||
if (event.key === 'ArrowRight') goNext()
|
||||
}
|
||||
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [activeIndex])
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">学习图谱</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">用一张图看清知识结构。</p>
|
||||
</div>
|
||||
|
||||
{learningMapImages.length > 0 ? (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{ hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.04 } } }}
|
||||
className="columns-1 gap-4 sm:columns-2 xl:columns-3"
|
||||
>
|
||||
{learningMapImages.map((image, index) => (
|
||||
<motion.button
|
||||
key={image.fileName}
|
||||
type="button"
|
||||
variants={{ hidden: { opacity: 0, y: 12 }, visible: { opacity: 1, y: 0 } }}
|
||||
onClick={() => openViewer(index)}
|
||||
className="group mb-4 block w-full break-inside-avoid overflow-hidden rounded-2xl bg-white text-left shadow-sm ring-1 ring-gray-200 transition duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-indigo-200 dark:bg-gray-800 dark:ring-gray-700 dark:hover:ring-indigo-500/50"
|
||||
>
|
||||
<img
|
||||
src={image.src}
|
||||
alt={image.title}
|
||||
loading="lazy"
|
||||
className="w-full bg-gray-100 object-contain transition duration-300 group-hover:scale-[1.01] dark:bg-gray-900"
|
||||
/>
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="rounded-2xl bg-white p-8 text-center text-sm text-gray-500 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700">
|
||||
暂无学习图谱
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeImage && activeIndex !== null && (
|
||||
<div className="fixed inset-0 z-50 bg-gray-950/95 text-white">
|
||||
<div className="absolute left-0 right-0 top-0 z-10 flex items-center justify-between gap-3 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeViewer}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur transition hover:bg-white/20"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={22} />
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1 text-center text-sm text-white/80">
|
||||
{activeIndex + 1} / {learningMapImages.length}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetView}
|
||||
className="hidden h-10 items-center gap-2 rounded-full bg-white/10 px-3 text-sm text-white backdrop-blur transition hover:bg-white/20 sm:flex"
|
||||
>
|
||||
<RotateCcw size={17} />
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyImage}
|
||||
className="flex h-10 items-center gap-2 rounded-full bg-white/10 px-3 text-sm text-white backdrop-blur transition hover:bg-white/20"
|
||||
>
|
||||
<Clipboard size={17} />
|
||||
<span className="hidden sm:inline">复制</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadImage}
|
||||
className="flex h-10 items-center gap-2 rounded-full bg-white/10 px-3 text-sm text-white backdrop-blur transition hover:bg-white/20"
|
||||
>
|
||||
<Download size={17} />
|
||||
<span className="hidden sm:inline">下载</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className="absolute left-1/2 top-16 z-20 -translate-x-1/2 rounded-full bg-white px-4 py-2 text-sm font-medium text-gray-900 shadow-xl">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrevious}
|
||||
disabled={!hasPrevious}
|
||||
className={clsx(
|
||||
'absolute left-3 top-1/2 z-10 hidden h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur transition hover:bg-white/20 md:flex',
|
||||
!hasPrevious && 'cursor-not-allowed opacity-30'
|
||||
)}
|
||||
aria-label="上一张"
|
||||
>
|
||||
<ChevronLeft size={30} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
disabled={!hasNext}
|
||||
className={clsx(
|
||||
'absolute right-3 top-1/2 z-10 hidden h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur transition hover:bg-white/20 md:flex',
|
||||
!hasNext && 'cursor-not-allowed opacity-30'
|
||||
)}
|
||||
aria-label="下一张"
|
||||
>
|
||||
<ChevronRight size={30} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-full w-full items-center justify-center overflow-hidden px-3 pb-20 pt-20 touch-none',
|
||||
scale > 1 && isDragging ? 'cursor-grabbing' : scale > 1 ? 'cursor-grab' : 'cursor-zoom-in'
|
||||
)}
|
||||
onWheel={handleWheel}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onDoubleClick={() => {
|
||||
setScale((current) => (current === 1 ? 2.4 : 1))
|
||||
if (scale !== 1) setOffset({ x: 0, y: 0 })
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={activeImage.src}
|
||||
alt={activeImage.title}
|
||||
draggable={false}
|
||||
style={viewerStyle}
|
||||
className="max-h-full max-w-full select-none object-contain transition-transform duration-75"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-full bg-white/10 px-3 py-2 text-xs text-white/75 backdrop-blur">
|
||||
<ZoomIn size={15} />
|
||||
<span>双指缩放 · 拖拽移动</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,3 +10,4 @@ export { ToolDetailPage } from './ToolDetailPage'
|
||||
export { SettingsPage } from './SettingsPage'
|
||||
|
||||
export { PerformanceDomainsPage } from './PerformanceDomainsPage'
|
||||
export { LearningMapsPage } from './LearningMapsPage'
|
||||
|
||||
Reference in New Issue
Block a user