diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index ddc60ac..733d5cb 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -110,6 +110,13 @@ modules: progress: 100 position: [980, 1080] + - id: pdf-report + name: PDF 报告导出 + area: rn-app + status: concept + progress: 0 + position: [980, 1280] + # ── 前端应用(Web,预留)────────────────────────────────────── - id: app-scaffold name: 应用脚手架模板 diff --git a/.blueprint/modules/pdf-report.md b/.blueprint/modules/pdf-report.md new file mode 100644 index 0000000..4113a79 --- /dev/null +++ b/.blueprint/modules/pdf-report.md @@ -0,0 +1,71 @@ +--- +id: pdf-report +name: PDF 报告导出 +area: rn-app +status: concept +--- + +## 概述 + +在查看模式下,将当前楼层的侧边栏物品清单 + 地图标记点信息导出为 PDF 报告。 +报告完整呈现所有数据(不依赖屏幕截图),支持 20-30+ 标记点场景。 + +## 需求背景 + +- 不动产摸底完成后,需要输出一份可存档/分享的书面报告 +- 报告内容 = 左侧标记点清单(物品名、数量、确认状态)+ 右侧带编号图钉的楼层平面图 +- 需支持多个楼层(每层生成一页或分组) + +## 技术调研结论 + +### 选定方案:客户端 HTML → PDF(expo-print) + +| 维度 | 说明 | +|------|------| +| 方案 | 构建 HTML 字符串 → `expo-print` 本地 WebView 渲染 → 导出 PDF | +| 离线 | ✅ 完全离线,无需服务端 | +| 地图渲染 | `imageUri`(已有 base64 JPEG)作 `` 底图,标记点用 `position:absolute` 圆圈叠加 | +| SVG 矢量层 | ⚠️ `floorMaps.generated.ts` SVG 体积极大(百万字符),不内嵌,用 JPEG 底图代替 | +| 标记点定位 | marker.x / marker.y 为归一化坐标(0-1),乘以图片宽高即可精确定位 | +| 物品清单 | 数据库直接查,不依赖屏幕状态,支持任意数量 | +| 分页 | HTML `page-break-inside: avoid` 自动处理 | + +### 排除的方案 + +- **截图方案**:ScrollView 只截可见区,30 个标记点数据不完整 ❌ +- **SVG 直接嵌入**:文件体积导致 WebView 崩溃风险 ❌ +- **服务端生成**:需要后端,与离线架构冲突 ❌ + +## 报告结构设想(横版 A4) + +``` +┌─────────────────────────────────────────────────────────┐ +│ 项目名称 1F 楼层报告 导出日期 │ +├──────────────┬──────────────────────────────────────────┤ +│ 标记点清单 │ │ +│ │ 楼层平面图(JPEG 底图) │ +│ ① 红 电梯 │ + 彩色圆圈标记点(含编号) │ +│ ☑ 电梯×1 │ │ +│ ② 紫 办公区 │ │ +│ ☑ 电脑×2 │ │ +│ ☑ 打印机×1│ │ +│ ③ ... │ │ +└──────────────┴──────────────────────────────────────────┘ +(标记点多时自动换页,地图区域保持在首页) +``` + +## 依赖 + +- `expo-print`(需新增安装,Expo 官方,无需 rebuild) +- `expo-sharing`(已安装,用于分享 PDF 文件) +- `expo-media-library`(已安装,可选保存到本地) + +## 待确认 + +- [ ] 报告是否需要封面页(项目名称、地址、日期等信息从哪里来) +- [ ] 是否需要导出所有楼层 or 仅当前楼层 +- [ ] PDF 分享方式:微信/邮件分享 or 保存到本地文件 + +## 任务卡 + + diff --git a/.blueprint/modules/photo-capture.md b/.blueprint/modules/photo-capture.md index 8ceb8f2..7a7f8c6 100644 --- a/.blueprint/modules/photo-capture.md +++ b/.blueprint/modules/photo-capture.md @@ -38,6 +38,13 @@ status: done - depends: photo-capture - acceptance: 查看模式下右侧面板;按物品分组展示照片缩略图(4 列网格 + 物品名标题);点击缩略图全屏 Modal 预览;Modal 为非透明全屏黑底(解决 Android transparent Modal 仅覆盖父容器的问题);单张显示 + onTouchStart/End 手势滑动切换(≥50px 触发)+ 左右箭头按钮;计数器实时同步 +### ✅ 全屏预览捏合缩放 +- status: done +- complexity: M +- files: apps/property-survey/src/features/photo/MarkerPhotoPanel.tsx +- depends: photo-capture +- acceptance: 全屏预览 Modal 内支持双指捏合缩放(基于 react-native-gesture-handler PinchGestureHandler + react-native-reanimated);缩放后可双指拖动查看细节;缩放比例 1x 时滑动手势正常切换照片,缩放比例 > 1x 时禁用切换手势;双击照片在 1x 和 2x 之间切换 + ## 决策记录 - **照片预览放弃 ScrollView/FlatList pagingEnabled**:在 Android 上 `transparent` Modal 的渲染范围被限制在父组件(右侧 260px 面板)内而非全屏,导致 `pagingEnabled` 页宽与实际视口不匹配,两张照片始终并排显示。尝试多种宽度测量方案(`SCREEN_W` 静态值、`onLayout` 动态测量、`useWindowDimensions`)均无效。最终改为单张显示 + 原生 Touch 事件手势,彻底绕开分页宽度问题。 diff --git a/apps/property-survey/.expo/devices.json b/apps/property-survey/.expo/devices.json index 0cb37a4..30c6aa6 100644 --- a/apps/property-survey/.expo/devices.json +++ b/apps/property-survey/.expo/devices.json @@ -2,7 +2,7 @@ "devices": [ { "installationId": "626cc0bf-3d4a-47d1-9fd9-50b31e88db04", - "lastUsed": 1775754747519 + "lastUsed": 1775756191145 } ] } diff --git a/apps/property-survey/app/_layout.tsx b/apps/property-survey/app/_layout.tsx index 7cddb9f..7f80911 100644 --- a/apps/property-survey/app/_layout.tsx +++ b/apps/property-survey/app/_layout.tsx @@ -2,6 +2,7 @@ import { Tabs } from 'expo-router' import { useEffect, useState } from 'react' import { StatusBar } from 'expo-status-bar' import { ActivityIndicator, View } from 'react-native' +import { GestureHandlerRootView } from 'react-native-gesture-handler' import { SafeAreaProvider } from 'react-native-safe-area-context' import { initDatabase } from '../src/db/database' import { CategoriesProvider } from '../src/features/settings/CategoriesContext' @@ -27,39 +28,41 @@ export default function RootLayout() { } return ( - - - - - + + + + - - - - - + > + + + + + + + ) } diff --git a/apps/property-survey/src/features/photo/MarkerPhotoPanel.tsx b/apps/property-survey/src/features/photo/MarkerPhotoPanel.tsx index 094f22f..add1b4c 100644 --- a/apps/property-survey/src/features/photo/MarkerPhotoPanel.tsx +++ b/apps/property-survey/src/features/photo/MarkerPhotoPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { Image, Modal, @@ -8,6 +8,13 @@ import { TouchableOpacity, View, } from 'react-native' +import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated' import { Ionicons } from '@expo/vector-icons' import type { Marker, Photo } from '@repo/types' import type { ItemWithPhotoCount } from '@repo/types' @@ -34,7 +41,25 @@ export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) { const [groups, setGroups] = useState([]) const [allPhotos, setAllPhotos] = useState([]) const [previewIndex, setPreviewIndex] = useState(null) - const touchStartX = useRef(0) + const [zoomMode, setZoomMode] = useState(false) + + // ─── 所有 hooks 在任何早返回之前 ────────────────────────────────── + const isZoomMode = useSharedValue(false) // worklet 内读取,避免重建 gesture + const scale = useSharedValue(1) + const savedScale = useSharedValue(1) + const offsetX = useSharedValue(0) + const offsetY = useSharedValue(0) + const panStartX = useSharedValue(0) + const panStartY = useSharedValue(0) + + const imageAnimStyle = useAnimatedStyle(() => ({ + transform: [ + { translateX: offsetX.value }, + { translateY: offsetY.value }, + { scale: scale.value }, + ], + })) + // ────────────────────────────────────────────────────────────────── const load = useCallback(async () => { if (!marker) { setGroups([]); setAllPhotos([]); return } @@ -56,17 +81,102 @@ export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) { useEffect(() => { load() }, [load]) - if (!marker) return null + // 切换照片时退出放大模式并重置缩放 + useEffect(() => { + setZoomMode(false) + isZoomMode.value = false + scale.value = withSpring(1, { damping: 20 }) + savedScale.value = 1 + offsetX.value = withSpring(0, { damping: 20 }) + offsetY.value = withSpring(0, { damping: 20 }) + panStartX.value = 0 + panStartY.value = 0 + }, [previewIndex]) const totalPhotos = allPhotos.length - const current = previewIndex !== null ? allPhotos[previewIndex] : null - function goPrev() { + const goPrev = useCallback(() => { setPreviewIndex((i) => (i !== null && i > 0 ? i - 1 : i)) - } - function goNext() { + }, []) + + const goNext = useCallback(() => { setPreviewIndex((i) => (i !== null && i < totalPhotos - 1 ? i + 1 : i)) - } + }, [totalPhotos]) + + const toggleZoomMode = useCallback(() => { + setZoomMode((prev) => { + const next = !prev + isZoomMode.value = next + if (!next) { + // 退出放大模式:缩放归位 + scale.value = withSpring(1, { damping: 20 }) + savedScale.value = 1 + offsetX.value = withSpring(0, { damping: 20 }) + offsetY.value = withSpring(0, { damping: 20 }) + panStartX.value = 0 + panStartY.value = 0 + } + return next + }) + }, []) + + // gesture 一次创建,通过 isZoomMode SharedValue 切换行为 + const photoGesture = useMemo(() => { + const pinch = Gesture.Pinch() + .onBegin(() => { + 'worklet' + if (isZoomMode.value) savedScale.value = scale.value + }) + .onUpdate((e) => { + 'worklet' + if (!isZoomMode.value) return + scale.value = Math.min(Math.max(savedScale.value * e.scale, 1), 5) + }) + .onEnd(() => { + 'worklet' + if (!isZoomMode.value) return + savedScale.value = scale.value + if (scale.value < 1.1) { + scale.value = withSpring(1, { damping: 20 }) + savedScale.value = 1 + offsetX.value = withSpring(0, { damping: 20 }) + offsetY.value = withSpring(0, { damping: 20 }) + } + }) + + const pan = Gesture.Pan() + .minPointers(1) + .maxPointers(1) + .onBegin(() => { + 'worklet' + panStartX.value = offsetX.value + panStartY.value = offsetY.value + }) + .onUpdate((e) => { + 'worklet' + if (isZoomMode.value && scale.value > 1) { + offsetX.value = panStartX.value + e.translationX + offsetY.value = panStartY.value + e.translationY + } + }) + .onEnd((e) => { + 'worklet' + if (isZoomMode.value) return // 放大模式不切图 + const isHorizontal = Math.abs(e.translationX) > Math.abs(e.translationY) * 1.5 + if (isHorizontal && e.translationX < -50) { + runOnJS(goNext)() + } else if (isHorizontal && e.translationX > 50) { + runOnJS(goPrev)() + } + }) + + return Gesture.Simultaneous(pinch, pan) + }, [goPrev, goNext]) + + // ─── 早返回(在所有 hooks 之后)───────────────────────────────── + if (!marker) return null + + const current = previewIndex !== null ? allPhotos[previewIndex] : null return ( @@ -132,55 +242,71 @@ export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) { statusBarTranslucent onRequestClose={() => setPreviewIndex(null)} > - { touchStartX.current = e.nativeEvent.pageX }} - onTouchEnd={(e) => { - const dx = e.nativeEvent.pageX - touchStartX.current - if (dx < -50) goNext() - else if (dx > 50) goPrev() - }} - > - {/* 顶部信息栏 */} + + + {/* 顶部:仅保留关闭按钮(避免被状态栏遮挡的其他内容) */} {current?.itemName ?? ''} - - {(previewIndex ?? 0) + 1} / {totalPhotos} - setPreviewIndex(null)} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} + style={styles.closeBtn} > - + - {/* 单张照片 */} + {/* 照片区域(手势区) */} - {current && ( - - )} + + + {current && ( + + )} + + - {/* 左箭头 */} - {previewIndex !== null && previewIndex > 0 && ( + {/* 左右箭头(放大模式下隐藏) */} + {!zoomMode && previewIndex !== null && previewIndex > 0 && ( )} - {/* 右箭头 */} - {previewIndex !== null && previewIndex < totalPhotos - 1 && ( + {!zoomMode && previewIndex !== null && previewIndex < totalPhotos - 1 && ( )} + + {/* 底部工具栏:计数器 + 放大镜按钮 */} + + + {(previewIndex ?? 0) + 1} / {totalPhotos} + + + + + {zoomMode ? '退出放大' : '放大'} + + + + ) @@ -241,16 +367,39 @@ const styles = StyleSheet.create({ previewTopBar: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', paddingHorizontal: 16, paddingTop: 52, - paddingBottom: 12, + paddingBottom: 8, }, - previewItemName: { flex: 1, color: '#fff', fontSize: 15, fontWeight: '600' }, - previewCounter: { color: 'rgba(255,255,255,0.6)', fontSize: 13, marginHorizontal: 12 }, + previewItemName: { flex: 1, color: 'rgba(255,255,255,0.85)', fontSize: 14, fontWeight: '600' }, + closeBtn: { padding: 4 }, + + bottomBar: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 24, + paddingVertical: 16, + paddingBottom: 32, + }, + previewCounter: { color: 'rgba(255,255,255,0.6)', fontSize: 15 }, + zoomBtn: { + alignItems: 'center', + gap: 3, + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 20, + backgroundColor: 'rgba(255,255,255,0.12)', + }, + zoomBtnActive: { + backgroundColor: 'rgba(16, 185, 129, 0.25)', + }, + zoomBtnLabel: { color: 'rgba(255,255,255,0.7)', fontSize: 11 }, + zoomBtnLabelActive: { color: '#10B981' }, photoArea: { flex: 1, + overflow: 'hidden', }, arrowLeft: { position: 'absolute', left: 8, top: '50%', padding: 12 }, diff --git a/apps/property-survey/src/features/photo/PhotoGrid.tsx b/apps/property-survey/src/features/photo/PhotoGrid.tsx index 2a7bfd5..0d48833 100644 --- a/apps/property-survey/src/features/photo/PhotoGrid.tsx +++ b/apps/property-survey/src/features/photo/PhotoGrid.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Alert, FlatList, @@ -11,6 +11,12 @@ import { useWindowDimensions, View, } from 'react-native' +import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated' import { Ionicons } from '@expo/vector-icons' import * as MediaLibrary from 'expo-media-library' import type { Photo } from '@repo/types' @@ -25,9 +31,28 @@ export function PhotoGrid({ itemId }: PhotoGridProps) { const [photos, setPhotos] = useState([]) const [previewIndex, setPreviewIndex] = useState(null) const [saving, setSaving] = useState(false) + const [zoomMode, setZoomMode] = useState(false) const { width: screenWidth } = useWindowDimensions() const flatListRef = useRef>(null) + // ─── 缩放动画值(hooks 必须无条件调用)───────────────────────── + const isZoomMode = useSharedValue(false) + const scale = useSharedValue(1) + const savedScale = useSharedValue(1) + const offsetX = useSharedValue(0) + const offsetY = useSharedValue(0) + const panStartX = useSharedValue(0) + const panStartY = useSharedValue(0) + + const imageAnimStyle = useAnimatedStyle(() => ({ + transform: [ + { translateX: offsetX.value }, + { translateY: offsetY.value }, + { scale: scale.value }, + ], + })) + // ────────────────────────────────────────────────────────────────── + const load = useCallback(async () => { try { const rows = await getPhotosByItem(itemId) @@ -37,9 +62,74 @@ export function PhotoGrid({ itemId }: PhotoGridProps) { } }, [itemId]) + useEffect(() => { load() }, [load]) + + // 切换照片时退出缩放模式并重置 useEffect(() => { - load() - }, [load]) + setZoomMode(false) + isZoomMode.value = false + scale.value = 1 + savedScale.value = 1 + offsetX.value = 0 + offsetY.value = 0 + }, [previewIndex]) + + const toggleZoomMode = useCallback(() => { + setZoomMode((prev) => { + const next = !prev + isZoomMode.value = next + if (!next) { + scale.value = withSpring(1, { damping: 20 }) + savedScale.value = 1 + offsetX.value = withSpring(0, { damping: 20 }) + offsetY.value = withSpring(0, { damping: 20 }) + } + return next + }) + }, []) + + // 捏合 + 单指拖动(仅在缩放模式下生效) + const photoGesture = useMemo(() => { + const pinch = Gesture.Pinch() + .onBegin(() => { + 'worklet' + if (isZoomMode.value) savedScale.value = scale.value + }) + .onUpdate((e) => { + 'worklet' + if (!isZoomMode.value) return + scale.value = Math.min(Math.max(savedScale.value * e.scale, 1), 5) + }) + .onEnd(() => { + 'worklet' + if (!isZoomMode.value) return + savedScale.value = scale.value + if (scale.value < 1.1) { + scale.value = withSpring(1, { damping: 20 }) + savedScale.value = 1 + offsetX.value = withSpring(0, { damping: 20 }) + offsetY.value = withSpring(0, { damping: 20 }) + } + }) + + const pan = Gesture.Pan() + .minPointers(1) + .maxPointers(1) + .onBegin(() => { + 'worklet' + panStartX.value = offsetX.value + panStartY.value = offsetY.value + }) + .onUpdate((e) => { + 'worklet' + if (isZoomMode.value && scale.value > 1) { + offsetX.value = panStartX.value + e.translationX + offsetY.value = panStartY.value + e.translationY + } + }) + + return Gesture.Simultaneous(pinch, pan) + }, []) function handleLongPress(photo: Photo) { Alert.alert('删除照片', '确认删除此张照片?', [ @@ -62,7 +152,6 @@ export function PhotoGrid({ itemId }: PhotoGridProps) { const handleSaveToAlbum = useCallback(async () => { const currentPhoto = previewIndex !== null ? photos[previewIndex] : null if (!currentPhoto) return - setSaving(true) try { const { status } = await MediaLibrary.requestPermissionsAsync() @@ -106,52 +195,87 @@ export function PhotoGrid({ itemId }: PhotoGridProps) { animationType="fade" onRequestClose={() => setPreviewIndex(null)} > + - {/* 关闭按钮 */} - setPreviewIndex(null)}> - - - {/* 保存到相册按钮 */} - - - + {/* 缩放模式:单张照片 + 手势 */} + {zoomMode ? ( + + + {currentPhoto && ( + + )} + + + ) : ( + /* 普通模式:FlatList 横向翻页 */ + String(p.id)} + initialScrollIndex={previewIndex ?? 0} + getItemLayout={(_, index) => ({ + length: screenWidth, + offset: screenWidth * index, + index, + })} + onMomentumScrollEnd={(e) => { + const idx = Math.round(e.nativeEvent.contentOffset.x / screenWidth) + setPreviewIndex(idx) + }} + renderItem={({ item: photo }) => ( + + + + )} + /> + )} - String(p.id)} - initialScrollIndex={previewIndex ?? 0} - getItemLayout={(_, index) => ({ - length: screenWidth, - offset: screenWidth * index, - index, - })} - onMomentumScrollEnd={(e) => { - const idx = Math.round(e.nativeEvent.contentOffset.x / screenWidth) - setPreviewIndex(idx) - }} - renderItem={({ item: photo }) => ( - - - - )} - /> + {/* 顶部工具栏:保存 + 放大镜 + 关闭(同一排) */} + + + + + + + + + + + + setPreviewIndex(null)}> + + + + + {/* 计数器 */} {(previewIndex ?? 0) + 1} / {photos.length} + ) @@ -169,33 +293,46 @@ const styles = StyleSheet.create({ backgroundColor: '#E5E7EB', }, thumbImg: { width: '100%', height: '100%' }, + overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', justifyContent: 'center', }, - closeBtn: { - position: 'absolute', - top: 48, - right: 16, - zIndex: 10, - padding: 4, + + // 缩放模式照片区域 + zoomArea: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', }, - saveBtn: { - position: 'absolute', - top: 48, - left: 16, - zIndex: 10, - padding: 4, - }, - saveBtnDisabled: { opacity: 0.4 }, + + // 普通模式 FlatList 每页 previewPage: { justifyContent: 'center', alignItems: 'center', }, - previewImg: { - height: 500, + previewImg: { height: 500 }, + + // 顶部工具栏 + topBar: { + position: 'absolute', + top: 48, + left: 0, + right: 0, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + gap: 4, }, + topBtn: { + padding: 8, + borderRadius: 8, + }, + topBtnDisabled: { opacity: 0.4 }, + topBtnActive: { backgroundColor: 'rgba(16, 185, 129, 0.25)' }, + topBarSpacer: { flex: 1 }, + counter: { position: 'absolute', bottom: 48,