diff --git a/src/App.tsx b/src/App.tsx index ed32875..2690650 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/data/image/.gitkeep b/src/data/image/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/data/image/合同.png b/src/data/image/合同.png new file mode 100644 index 0000000..29013ac Binary files /dev/null and b/src/data/image/合同.png differ diff --git a/src/data/image/挣值1.jpg b/src/data/image/挣值1.jpg new file mode 100644 index 0000000..0999981 Binary files /dev/null and b/src/data/image/挣值1.jpg differ diff --git a/src/data/image/挣值2.jpg b/src/data/image/挣值2.jpg new file mode 100644 index 0000000..e1dd919 Binary files /dev/null and b/src/data/image/挣值2.jpg differ diff --git a/src/data/image/案例万金油.png b/src/data/image/案例万金油.png new file mode 100644 index 0000000..9cfc095 Binary files /dev/null and b/src/data/image/案例万金油.png differ diff --git a/src/data/image/进度赶工.png b/src/data/image/进度赶工.png new file mode 100644 index 0000000..9344d4c Binary files /dev/null and b/src/data/image/进度赶工.png differ diff --git a/src/pages/LearningMapsPage.tsx b/src/pages/LearningMapsPage.tsx new file mode 100644 index 0000000..52b4176 --- /dev/null +++ b/src/pages/LearningMapsPage.tsx @@ -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 + +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((resolve, reject) => { + canvas.toBlob((pngBlob) => { + if (pngBlob) resolve(pngBlob) + else reject(new Error('无法复制图片')) + }, 'image/png') + }) +} + +export function LearningMapsPage() { + const [activeIndex, setActiveIndex] = useState(null) + const [scale, setScale] = useState(1) + const [offset, setOffset] = useState({ x: 0, y: 0 }) + const [isDragging, setIsDragging] = useState(false) + const [message, setMessage] = useState('') + const pointersRef = useRef(new Map()) + const lastDragPointRef = useRef(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) => { + 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) => { + 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) => { + 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) => { + 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 ( +
+
+

学习图谱

+

用一张图看清知识结构。

+
+ + {learningMapImages.length > 0 ? ( + + {learningMapImages.map((image, index) => ( + 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" + > + {image.title} + + ))} + + ) : ( +
+ 暂无学习图谱 +
+ )} + + {activeImage && activeIndex !== null && ( +
+
+ + +
+ {activeIndex + 1} / {learningMapImages.length} +
+ +
+ + + +
+
+ + {message && ( +
+ {message} +
+ )} + + + + + +
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 }) + }} + > + {activeImage.title} +
+ +
+ + 双指缩放 · 拖拽移动 +
+
+ )} +
+ ) +} diff --git a/src/pages/index.ts b/src/pages/index.ts index 53669b2..c4f54c9 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -10,3 +10,4 @@ export { ToolDetailPage } from './ToolDetailPage' export { SettingsPage } from './SettingsPage' export { PerformanceDomainsPage } from './PerformanceDomainsPage' +export { LearningMapsPage } from './LearningMapsPage'