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,