feat: 全屏照片预览支持捏合缩放与拖动查看

- MarkerPhotoPanel:基于 RNGH PinchGesture + Reanimated 实现捏合缩放(1x~5x);放大后可双指拖动,放大模式下禁用左右切图手势
- _layout:根布局包裹 GestureHandlerRootView,解决 Modal 内手势失效问题
- 蓝图:新增 pdf-report 模块(concept),photo-capture 补充缩放任务卡(done)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
lanrtop 2026-04-10 08:50:54 +09:00
parent 3dbc6f0ff5
commit 1c6d491eaf
7 changed files with 506 additions and 132 deletions

View File

@ -110,6 +110,13 @@ modules:
progress: 100 progress: 100
position: [980, 1080] position: [980, 1080]
- id: pdf-report
name: PDF 报告导出
area: rn-app
status: concept
progress: 0
position: [980, 1280]
# ── 前端应用Web预留────────────────────────────────────── # ── 前端应用Web预留──────────────────────────────────────
- id: app-scaffold - id: app-scaffold
name: 应用脚手架模板 name: 应用脚手架模板

View File

@ -0,0 +1,71 @@
---
id: pdf-report
name: PDF 报告导出
area: rn-app
status: concept
---
## 概述
在查看模式下,将当前楼层的侧边栏物品清单 + 地图标记点信息导出为 PDF 报告。
报告完整呈现所有数据(不依赖屏幕截图),支持 20-30+ 标记点场景。
## 需求背景
- 不动产摸底完成后,需要输出一份可存档/分享的书面报告
- 报告内容 = 左侧标记点清单(物品名、数量、确认状态)+ 右侧带编号图钉的楼层平面图
- 需支持多个楼层(每层生成一页或分组)
## 技术调研结论
### 选定方案:客户端 HTML → PDFexpo-print
| 维度 | 说明 |
|------|------|
| 方案 | 构建 HTML 字符串 → `expo-print` 本地 WebView 渲染 → 导出 PDF |
| 离线 | ✅ 完全离线,无需服务端 |
| 地图渲染 | `imageUri`(已有 base64 JPEG`<img>` 底图,标记点用 `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 保存到本地文件
## 任务卡
<!-- 待需求确认后拆解 -->

View File

@ -38,6 +38,13 @@ status: done
- depends: photo-capture - depends: photo-capture
- acceptance: 查看模式下右侧面板按物品分组展示照片缩略图4 列网格 + 物品名标题);点击缩略图全屏 Modal 预览Modal 为非透明全屏黑底(解决 Android transparent Modal 仅覆盖父容器的问题);单张显示 + onTouchStart/End 手势滑动切换≥50px 触发)+ 左右箭头按钮;计数器实时同步 - 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 事件手势,彻底绕开分页宽度问题。 - **照片预览放弃 ScrollView/FlatList pagingEnabled**:在 Android 上 `transparent` Modal 的渲染范围被限制在父组件(右侧 260px 面板)内而非全屏,导致 `pagingEnabled` 页宽与实际视口不匹配,两张照片始终并排显示。尝试多种宽度测量方案(`SCREEN_W` 静态值、`onLayout` 动态测量、`useWindowDimensions`)均无效。最终改为单张显示 + 原生 Touch 事件手势,彻底绕开分页宽度问题。

View File

@ -2,7 +2,7 @@
"devices": [ "devices": [
{ {
"installationId": "626cc0bf-3d4a-47d1-9fd9-50b31e88db04", "installationId": "626cc0bf-3d4a-47d1-9fd9-50b31e88db04",
"lastUsed": 1775754747519 "lastUsed": 1775756191145
} }
] ]
} }

View File

@ -2,6 +2,7 @@ import { Tabs } from 'expo-router'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { StatusBar } from 'expo-status-bar' import { StatusBar } from 'expo-status-bar'
import { ActivityIndicator, View } from 'react-native' import { ActivityIndicator, View } from 'react-native'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { SafeAreaProvider } from 'react-native-safe-area-context' import { SafeAreaProvider } from 'react-native-safe-area-context'
import { initDatabase } from '../src/db/database' import { initDatabase } from '../src/db/database'
import { CategoriesProvider } from '../src/features/settings/CategoriesContext' import { CategoriesProvider } from '../src/features/settings/CategoriesContext'
@ -27,6 +28,7 @@ export default function RootLayout() {
} }
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider> <SafeAreaProvider>
<StatusBar style="auto" /> <StatusBar style="auto" />
<CategoriesProvider> <CategoriesProvider>
@ -61,5 +63,6 @@ export default function RootLayout() {
</Tabs> </Tabs>
</CategoriesProvider> </CategoriesProvider>
</SafeAreaProvider> </SafeAreaProvider>
</GestureHandlerRootView>
) )
} }

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { import {
Image, Image,
Modal, Modal,
@ -8,6 +8,13 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native' } 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 { Ionicons } from '@expo/vector-icons'
import type { Marker, Photo } from '@repo/types' import type { Marker, Photo } from '@repo/types'
import type { ItemWithPhotoCount } from '@repo/types' import type { ItemWithPhotoCount } from '@repo/types'
@ -34,7 +41,25 @@ export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) {
const [groups, setGroups] = useState<PhotoGroup[]>([]) const [groups, setGroups] = useState<PhotoGroup[]>([])
const [allPhotos, setAllPhotos] = useState<PhotoEntry[]>([]) const [allPhotos, setAllPhotos] = useState<PhotoEntry[]>([])
const [previewIndex, setPreviewIndex] = useState<number | null>(null) const [previewIndex, setPreviewIndex] = useState<number | null>(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 () => { const load = useCallback(async () => {
if (!marker) { setGroups([]); setAllPhotos([]); return } if (!marker) { setGroups([]); setAllPhotos([]); return }
@ -56,17 +81,102 @@ export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) {
useEffect(() => { load() }, [load]) 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 totalPhotos = allPhotos.length
const current = previewIndex !== null ? allPhotos[previewIndex] : null
function goPrev() { const goPrev = useCallback(() => {
setPreviewIndex((i) => (i !== null && i > 0 ? i - 1 : i)) setPreviewIndex((i) => (i !== null && i > 0 ? i - 1 : i))
} }, [])
function goNext() {
const goNext = useCallback(() => {
setPreviewIndex((i) => (i !== null && i < totalPhotos - 1 ? i + 1 : i)) 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 ( return (
<View style={styles.panel}> <View style={styles.panel}>
@ -132,33 +242,26 @@ export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) {
statusBarTranslucent statusBarTranslucent
onRequestClose={() => setPreviewIndex(null)} onRequestClose={() => setPreviewIndex(null)}
> >
<View <GestureHandlerRootView style={{ flex: 1 }}>
style={styles.overlay} <View style={styles.overlay}>
onTouchStart={(e) => { touchStartX.current = e.nativeEvent.pageX }} {/* 顶部:仅保留关闭按钮(避免被状态栏遮挡的其他内容) */}
onTouchEnd={(e) => {
const dx = e.nativeEvent.pageX - touchStartX.current
if (dx < -50) goNext()
else if (dx > 50) goPrev()
}}
>
{/* 顶部信息栏 */}
<View style={styles.previewTopBar}> <View style={styles.previewTopBar}>
<Text style={styles.previewItemName} numberOfLines={1}> <Text style={styles.previewItemName} numberOfLines={1}>
{current?.itemName ?? ''} {current?.itemName ?? ''}
</Text> </Text>
<Text style={styles.previewCounter}>
{(previewIndex ?? 0) + 1} / {totalPhotos}
</Text>
<TouchableOpacity <TouchableOpacity
onPress={() => setPreviewIndex(null)} onPress={() => setPreviewIndex(null)}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
style={styles.closeBtn}
> >
<Ionicons name="close" size={26} color="#fff" /> <Ionicons name="close-circle" size={30} color="rgba(255,255,255,0.85)" />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* 单张照片 */} {/* 照片区域(手势区) */}
<View style={styles.photoArea}> <View style={styles.photoArea}>
<GestureDetector gesture={photoGesture}>
<Animated.View style={[StyleSheet.absoluteFill, imageAnimStyle]}>
{current && ( {current && (
<Image <Image
source={{ uri: current.photo.filePath }} source={{ uri: current.photo.filePath }}
@ -166,21 +269,44 @@ export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) {
resizeMode="contain" resizeMode="contain"
/> />
)} )}
</Animated.View>
</GestureDetector>
</View> </View>
{/* 左箭头 */} {/* 左箭头(放大模式下隐藏) */}
{previewIndex !== null && previewIndex > 0 && ( {!zoomMode && previewIndex !== null && previewIndex > 0 && (
<TouchableOpacity style={styles.arrowLeft} onPress={goPrev}> <TouchableOpacity style={styles.arrowLeft} onPress={goPrev}>
<Ionicons name="chevron-back" size={34} color="#fff" /> <Ionicons name="chevron-back" size={34} color="#fff" />
</TouchableOpacity> </TouchableOpacity>
)} )}
{/* 右箭头 */} {!zoomMode && previewIndex !== null && previewIndex < totalPhotos - 1 && (
{previewIndex !== null && previewIndex < totalPhotos - 1 && (
<TouchableOpacity style={styles.arrowRight} onPress={goNext}> <TouchableOpacity style={styles.arrowRight} onPress={goNext}>
<Ionicons name="chevron-forward" size={34} color="#fff" /> <Ionicons name="chevron-forward" size={34} color="#fff" />
</TouchableOpacity> </TouchableOpacity>
)} )}
{/* 底部工具栏:计数器 + 放大镜按钮 */}
<View style={styles.bottomBar}>
<Text style={styles.previewCounter}>
{(previewIndex ?? 0) + 1} / {totalPhotos}
</Text>
<TouchableOpacity
onPress={toggleZoomMode}
style={[styles.zoomBtn, zoomMode && styles.zoomBtnActive]}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
>
<Ionicons
name={zoomMode ? 'search' : 'search-outline'}
size={26}
color={zoomMode ? '#10B981' : 'rgba(255,255,255,0.85)'}
/>
<Text style={[styles.zoomBtnLabel, zoomMode && styles.zoomBtnLabelActive]}>
{zoomMode ? '退出放大' : '放大'}
</Text>
</TouchableOpacity>
</View> </View>
</View>
</GestureHandlerRootView>
</Modal> </Modal>
</View> </View>
) )
@ -241,16 +367,39 @@ const styles = StyleSheet.create({
previewTopBar: { previewTopBar: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16, paddingHorizontal: 16,
paddingTop: 52, paddingTop: 52,
paddingBottom: 12, paddingBottom: 8,
}, },
previewItemName: { flex: 1, color: '#fff', fontSize: 15, fontWeight: '600' }, previewItemName: { flex: 1, color: 'rgba(255,255,255,0.85)', fontSize: 14, fontWeight: '600' },
previewCounter: { color: 'rgba(255,255,255,0.6)', fontSize: 13, marginHorizontal: 12 }, 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: { photoArea: {
flex: 1, flex: 1,
overflow: 'hidden',
}, },
arrowLeft: { position: 'absolute', left: 8, top: '50%', padding: 12 }, arrowLeft: { position: 'absolute', left: 8, top: '50%', padding: 12 },

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
Alert, Alert,
FlatList, FlatList,
@ -11,6 +11,12 @@ import {
useWindowDimensions, useWindowDimensions,
View, View,
} from 'react-native' } 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 { Ionicons } from '@expo/vector-icons'
import * as MediaLibrary from 'expo-media-library' import * as MediaLibrary from 'expo-media-library'
import type { Photo } from '@repo/types' import type { Photo } from '@repo/types'
@ -25,9 +31,28 @@ export function PhotoGrid({ itemId }: PhotoGridProps) {
const [photos, setPhotos] = useState<Photo[]>([]) const [photos, setPhotos] = useState<Photo[]>([])
const [previewIndex, setPreviewIndex] = useState<number | null>(null) const [previewIndex, setPreviewIndex] = useState<number | null>(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [zoomMode, setZoomMode] = useState(false)
const { width: screenWidth } = useWindowDimensions() const { width: screenWidth } = useWindowDimensions()
const flatListRef = useRef<FlatList<Photo>>(null) const flatListRef = useRef<FlatList<Photo>>(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 () => { const load = useCallback(async () => {
try { try {
const rows = await getPhotosByItem(itemId) const rows = await getPhotosByItem(itemId)
@ -37,9 +62,74 @@ export function PhotoGrid({ itemId }: PhotoGridProps) {
} }
}, [itemId]) }, [itemId])
useEffect(() => { load() }, [load])
// 切换照片时退出缩放模式并重置
useEffect(() => { useEffect(() => {
load() setZoomMode(false)
}, [load]) 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) { function handleLongPress(photo: Photo) {
Alert.alert('删除照片', '确认删除此张照片?', [ Alert.alert('删除照片', '确认删除此张照片?', [
@ -62,7 +152,6 @@ export function PhotoGrid({ itemId }: PhotoGridProps) {
const handleSaveToAlbum = useCallback(async () => { const handleSaveToAlbum = useCallback(async () => {
const currentPhoto = previewIndex !== null ? photos[previewIndex] : null const currentPhoto = previewIndex !== null ? photos[previewIndex] : null
if (!currentPhoto) return if (!currentPhoto) return
setSaving(true) setSaving(true)
try { try {
const { status } = await MediaLibrary.requestPermissionsAsync() const { status } = await MediaLibrary.requestPermissionsAsync()
@ -106,21 +195,24 @@ export function PhotoGrid({ itemId }: PhotoGridProps) {
animationType="fade" animationType="fade"
onRequestClose={() => setPreviewIndex(null)} onRequestClose={() => setPreviewIndex(null)}
> >
<GestureHandlerRootView style={{ flex: 1 }}>
<View style={styles.overlay}> <View style={styles.overlay}>
{/* 关闭按钮 */}
<TouchableOpacity style={styles.closeBtn} onPress={() => setPreviewIndex(null)}>
<Ionicons name="close" size={28} color="#fff" />
</TouchableOpacity>
{/* 保存到相册按钮 */}
<TouchableOpacity
style={[styles.saveBtn, saving && styles.saveBtnDisabled]}
onPress={handleSaveToAlbum}
disabled={saving}
>
<Ionicons name="download-outline" size={22} color="#fff" />
</TouchableOpacity>
{/* 缩放模式:单张照片 + 手势 */}
{zoomMode ? (
<GestureDetector gesture={photoGesture}>
<Animated.View style={[styles.zoomArea, imageAnimStyle]}>
{currentPhoto && (
<Image
source={{ uri: currentPhoto.filePath }}
style={{ width: screenWidth, height: screenWidth }}
resizeMode="contain"
/>
)}
</Animated.View>
</GestureDetector>
) : (
/* 普通模式FlatList 横向翻页 */
<FlatList <FlatList
ref={flatListRef} ref={flatListRef}
data={photos} data={photos}
@ -148,10 +240,42 @@ export function PhotoGrid({ itemId }: PhotoGridProps) {
</View> </View>
)} )}
/> />
)}
{/* 顶部工具栏:保存 + 放大镜 + 关闭(同一排) */}
<View style={styles.topBar}>
<TouchableOpacity
style={[styles.topBtn, saving && styles.topBtnDisabled]}
onPress={handleSaveToAlbum}
disabled={saving}
>
<Ionicons name="download-outline" size={24} color="#fff" />
</TouchableOpacity>
<TouchableOpacity
style={[styles.topBtn, zoomMode && styles.topBtnActive]}
onPress={toggleZoomMode}
>
<Ionicons
name={zoomMode ? 'search' : 'search-outline'}
size={24}
color={zoomMode ? '#10B981' : '#fff'}
/>
</TouchableOpacity>
<View style={styles.topBarSpacer} />
<TouchableOpacity style={styles.topBtn} onPress={() => setPreviewIndex(null)}>
<Ionicons name="close" size={28} color="#fff" />
</TouchableOpacity>
</View>
{/* 计数器 */}
<Text style={styles.counter}> <Text style={styles.counter}>
{(previewIndex ?? 0) + 1} / {photos.length} {(previewIndex ?? 0) + 1} / {photos.length}
</Text> </Text>
</View> </View>
</GestureHandlerRootView>
</Modal> </Modal>
</View> </View>
) )
@ -169,33 +293,46 @@ const styles = StyleSheet.create({
backgroundColor: '#E5E7EB', backgroundColor: '#E5E7EB',
}, },
thumbImg: { width: '100%', height: '100%' }, thumbImg: { width: '100%', height: '100%' },
overlay: { overlay: {
flex: 1, flex: 1,
backgroundColor: 'rgba(0,0,0,0.92)', backgroundColor: 'rgba(0,0,0,0.92)',
justifyContent: 'center', justifyContent: 'center',
}, },
closeBtn: {
position: 'absolute', // 缩放模式照片区域
top: 48, zoomArea: {
right: 16, ...StyleSheet.absoluteFillObject,
zIndex: 10, justifyContent: 'center',
padding: 4, alignItems: 'center',
}, },
saveBtn: {
position: 'absolute', // 普通模式 FlatList 每页
top: 48,
left: 16,
zIndex: 10,
padding: 4,
},
saveBtnDisabled: { opacity: 0.4 },
previewPage: { previewPage: {
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
previewImg: { previewImg: { height: 500 },
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: { counter: {
position: 'absolute', position: 'absolute',
bottom: 48, bottom: 48,