feat: 表单保存优化、照片保存相册及布局性能改进
- 物品表单底部新增冻结保存按钮,保存成功显示 Toast 提示 - 照片全屏预览时支持一键保存到本地相册(expo-media-library ~16.0.0) - 左侧标记点列表侧栏宽度从 120 调整为 160 - FoldableLayout 改用 LayoutAnimation 替代 JS 线程宽度动画,修复 Android 地图渲染空白及卡顿问题 - 修复 ImagePicker 废弃 API MediaTypeOptions.Images → MediaType.Images Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
eab08d8c96
commit
cea2a5d208
@ -11,28 +11,28 @@ status: done
|
||||
|
||||
## 任务卡
|
||||
|
||||
### 📋 平面图占位容器与坐标系
|
||||
### 🔵 平面图占位容器与坐标系
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: apps/property-survey/src/features/map/FloorMap.tsx
|
||||
- depends: survey-app-shell
|
||||
- acceptance: 渲染一个带灰色背景的矩形占位区域作为平面图,使用 onLayout 获取实际像素尺寸并转换相对坐标(0~1),容器宽高比固定为 4:3,适配折叠/展开两种宽度
|
||||
|
||||
### 📋 长按手势添加标记点
|
||||
### 🔵 长按手势添加标记点
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: apps/property-survey/src/features/map/useMarkerGesture.ts
|
||||
- depends: floor-map
|
||||
- acceptance: 在平面图区域长按 500ms 后触发,将触点位置转换为相对坐标并调用 insertMarker 写入数据库,新标记点立即在地图上显示,不误触短按事件
|
||||
|
||||
### 📋 标记点图标组件
|
||||
### 🔵 标记点图标组件
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: apps/property-survey/src/features/map/MapMarker.tsx
|
||||
- depends: floor-map
|
||||
- acceptance: 在平面图上按相对坐标绝对定位渲染图钉图标,已有物品的标记点显示蓝色图钉 + 物品数量角标,空标记点显示灰色图钉,点击触发 onPress 回调
|
||||
|
||||
### 📋 当前楼层标记点数据加载
|
||||
### 🔵 当前楼层标记点数据加载
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: apps/property-survey/src/features/map/MarkerList.tsx
|
||||
|
||||
@ -11,21 +11,21 @@ status: done
|
||||
|
||||
## 任务卡
|
||||
|
||||
### 📋 汇总数据查询 Hook
|
||||
### 🔵 汇总数据查询 Hook
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: apps/property-survey/src/features/summary/useSummaryData.ts
|
||||
- depends: local-data-layer
|
||||
- acceptance: 查询所有 items JOIN markers JOIN photos,返回带楼层、坐标、物品名、数量、单价、备注、照片数的扁平列表;支持按 floor(1/2/3/全部)和 name 关键字过滤;数据写入后调用 refresh() 触发重新查询
|
||||
|
||||
### 📋 汇总列表与筛选栏
|
||||
### 🔵 汇总列表与筛选栏
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: apps/property-survey/src/features/summary/SummaryList.tsx, apps/property-survey/src/features/summary/FilterBar.tsx
|
||||
- depends: inventory-summary
|
||||
- acceptance: 列表每行显示楼层标签、物品名、数量、单价、备注1;FilterBar 提供楼层选择器(1F/2F/3F/全部)和物品名搜索框;滚动性能流畅(FlatList),空数据时显示引导文案
|
||||
|
||||
### 📋 统计汇总卡片
|
||||
### 🔵 统计汇总卡片
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: apps/property-survey/src/features/summary/SummaryStats.tsx
|
||||
|
||||
@ -11,21 +11,21 @@ status: done
|
||||
|
||||
## 任务卡
|
||||
|
||||
### 📋 底部抽屉/右栏容器
|
||||
### 🔵 底部抽屉/右栏容器
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: apps/property-survey/src/features/item/ItemFormSheet.tsx
|
||||
- depends: survey-app-shell, floor-map
|
||||
- acceptance: 折叠时从屏幕底部滑出(BottomSheet),展开时在 FoldableLayout 右栏内渲染;接受 markerId 和 onClose 两个 prop;顶部显示标记点位置名称和所属楼层
|
||||
|
||||
### 📋 物品字段表单
|
||||
### 🔵 物品字段表单
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: apps/property-survey/src/features/item/ItemFields.tsx, apps/property-survey/src/features/item/useItemForm.ts
|
||||
- depends: item-form
|
||||
- acceptance: 包含物品名称(必填文本)、数量(数字键盘)、单价(数字键盘,可选)、备注1(多行文本)、备注2(多行文本)五个字段;保存时校验物品名称非空;useItemForm 管理字段状态和 insertItem / updateItem 调用
|
||||
|
||||
### 📋 标记点下物品列表
|
||||
### 🔵 标记点下物品列表
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: apps/property-survey/src/features/item/ItemList.tsx
|
||||
|
||||
@ -11,27 +11,27 @@ status: done
|
||||
|
||||
## 任务卡
|
||||
|
||||
### 📋 SQLite 初始化与 Schema 建表
|
||||
### 🔵 SQLite 初始化与 Schema 建表
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: apps/property-survey/src/db/database.ts, apps/property-survey/src/db/schema.ts
|
||||
- acceptance: App 启动时自动执行 CREATE TABLE IF NOT EXISTS,建立 markers / items / photos 三张表,重复启动不报错,字段与设计文档一致
|
||||
|
||||
### 📋 标记点 CRUD 操作
|
||||
### 🔵 标记点 CRUD 操作
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: apps/property-survey/src/db/markers.ts
|
||||
- depends: local-data-layer
|
||||
- acceptance: 提供 insertMarker / getMarkersByFloor / updateMarker / deleteMarker 四个函数,deleteMarker 级联删除关联 items 和 photos,所有函数返回类型安全的 Promise
|
||||
|
||||
### 📋 物品记录 CRUD 操作
|
||||
### 🔵 物品记录 CRUD 操作
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: apps/property-survey/src/db/items.ts
|
||||
- depends: local-data-layer
|
||||
- acceptance: 提供 insertItem / getItemsByMarker / updateItem / deleteItem 四个函数,支持批量查询(传入 marker_id 数组),返回带关联照片数量的扩展类型
|
||||
|
||||
### 📋 照片路径 CRUD 操作
|
||||
### 🔵 照片路径 CRUD 操作
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: apps/property-survey/src/db/photos.ts
|
||||
|
||||
@ -11,14 +11,14 @@ status: done
|
||||
|
||||
## 任务卡
|
||||
|
||||
### 📋 摄像头调用与图片压缩
|
||||
### 🔵 摄像头调用与图片压缩
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: apps/property-survey/src/features/photo/usePhotoCapture.ts
|
||||
- depends: local-data-layer
|
||||
- acceptance: 使用 expo-image-picker 提供「拍照」和「从相册选择」两种入口;拍摄/选择后自动压缩至最大宽度 1080px、JPEG quality 0.75;将压缩后的文件复制到 expo-file-system 的 App 专属目录;调用 insertPhoto 写入数据库;全流程有权限检查,拒绝权限时给出提示
|
||||
|
||||
### 📋 照片网格预览与删除
|
||||
### 🔵 照片网格预览与删除
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: apps/property-survey/src/features/photo/PhotoGrid.tsx, apps/property-survey/src/features/photo/PhotoPicker.tsx
|
||||
|
||||
@ -11,27 +11,27 @@ React Native (Expo SDK 52 + Expo Router) 应用骨架,实现 Z Fold 6 折叠/
|
||||
|
||||
## 任务卡
|
||||
|
||||
### 📋 Expo 应用骨架初始化
|
||||
### 🔵 Expo 应用骨架初始化
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: apps/property-survey/app.json, apps/property-survey/package.json, apps/property-survey/app/_layout.tsx, apps/property-survey/app/index.tsx, apps/property-survey/app/summary.tsx
|
||||
- depends: local-data-layer
|
||||
- acceptance: `pnpm --filter property-survey start` 能在 Expo Go 或开发构建中启动,底部导航可在「登记」和「汇总」两个页面间切换,SQLite 数据库在启动时完成初始化
|
||||
|
||||
### 📋 折叠屏状态感知 Hook
|
||||
### 🔵 折叠屏状态感知 Hook
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: apps/property-survey/src/layout/useFoldState.ts
|
||||
- acceptance: useFoldState 返回 `{ isUnfolded: boolean, windowWidth: number }`,以 720px 为断点区分折叠/展开状态,useWindowDimensions 变化时自动更新,在模拟器宽度调整时可观察到状态切换
|
||||
|
||||
### 📋 双栏/单栏自适应布局容器
|
||||
### 🔵 双栏/单栏自适应布局容器
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: apps/property-survey/src/layout/FoldableLayout.tsx
|
||||
- depends: survey-app-shell
|
||||
- acceptance: 展开时渲染左右两栏(左侧 flexGrow:1 地图区,右侧 380px 详情区),折叠时只渲染左侧(地图全屏),接受 `left` 和 `right` 两个 ReactNode 插槽
|
||||
|
||||
### 📋 楼层切换标签组件
|
||||
### 🔵 楼层切换标签组件
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: apps/property-survey/src/layout/FloorTabs.tsx
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"devices": [
|
||||
{
|
||||
"installationId": "626cc0bf-3d4a-47d1-9fd9-50b31e88db04",
|
||||
"lastUsed": 1775741394159
|
||||
"lastUsed": 1775747314027
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
6
apps/property-survey/.expo/types/router.d.ts
vendored
6
apps/property-survey/.expo/types/router.d.ts
vendored
@ -6,9 +6,9 @@ export * from 'expo-router';
|
||||
declare module 'expo-router' {
|
||||
export namespace ExpoRouter {
|
||||
export interface __routes<T extends string | object = string> {
|
||||
hrefInputParams: { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/`; params?: Router.UnknownInputParams; } | { pathname: `/settings`; params?: Router.UnknownInputParams; } | { pathname: `/summary`; params?: Router.UnknownInputParams; } | { pathname: `/../src/features/item/ItemFormSheet`; params?: Router.UnknownInputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; };
|
||||
hrefOutputParams: { pathname: Router.RelativePathString, params?: Router.UnknownOutputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownOutputParams } | { pathname: `/`; params?: Router.UnknownOutputParams; } | { pathname: `/settings`; params?: Router.UnknownOutputParams; } | { pathname: `/summary`; params?: Router.UnknownOutputParams; } | { pathname: `/../src/features/item/ItemFormSheet`; params?: Router.UnknownOutputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownOutputParams; };
|
||||
href: Router.RelativePathString | Router.ExternalPathString | `/${`?${string}` | `#${string}` | ''}` | `/settings${`?${string}` | `#${string}` | ''}` | `/summary${`?${string}` | `#${string}` | ''}` | `/../src/features/item/ItemFormSheet${`?${string}` | `#${string}` | ''}` | `/_sitemap${`?${string}` | `#${string}` | ''}` | { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/`; params?: Router.UnknownInputParams; } | { pathname: `/settings`; params?: Router.UnknownInputParams; } | { pathname: `/summary`; params?: Router.UnknownInputParams; } | { pathname: `/../src/features/item/ItemFormSheet`; params?: Router.UnknownInputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; };
|
||||
hrefInputParams: { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/`; params?: Router.UnknownInputParams; } | { pathname: `/settings`; params?: Router.UnknownInputParams; } | { pathname: `/summary`; params?: Router.UnknownInputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; };
|
||||
hrefOutputParams: { pathname: Router.RelativePathString, params?: Router.UnknownOutputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownOutputParams } | { pathname: `/`; params?: Router.UnknownOutputParams; } | { pathname: `/settings`; params?: Router.UnknownOutputParams; } | { pathname: `/summary`; params?: Router.UnknownOutputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownOutputParams; };
|
||||
href: Router.RelativePathString | Router.ExternalPathString | `/${`?${string}` | `#${string}` | ''}` | `/settings${`?${string}` | `#${string}` | ''}` | `/summary${`?${string}` | `#${string}` | ''}` | `/_sitemap${`?${string}` | `#${string}` | ''}` | { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/`; params?: Router.UnknownInputParams; } | { pathname: `/settings`; params?: Router.UnknownInputParams; } | { pathname: `/summary`; params?: Router.UnknownInputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,6 +25,14 @@
|
||||
"package": "com.example.propertysurvey"
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
"expo-media-library",
|
||||
{
|
||||
"photosPermission": "允许访问相册以保存照片",
|
||||
"savePhotosPermission": "允许保存照片到相册",
|
||||
"isAccessMediaLocationEnabled": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
"expo-file-system": "~18.0.0",
|
||||
"expo-image-picker": "~16.0.0",
|
||||
"expo-linking": "^7.0.5",
|
||||
"expo-media-library": "~16.0.0",
|
||||
"expo-router": "~4.0.0",
|
||||
"expo-sharing": "~12.0.0",
|
||||
"expo-sqlite": "~15.1.0",
|
||||
|
||||
@ -20,7 +20,7 @@ interface ItemFieldsProps {
|
||||
}
|
||||
|
||||
export function ItemFields({ hook }: ItemFieldsProps) {
|
||||
const { form, setField, nameError, photoItemId } = hook
|
||||
const { form, setField, nameError, photoItemId, save } = hook
|
||||
const { categories } = useCategories()
|
||||
|
||||
return (
|
||||
@ -147,6 +147,13 @@ export function ItemFields({ hook }: ItemFieldsProps) {
|
||||
|
||||
<View style={styles.bottomSpacer} />
|
||||
</ScrollView>
|
||||
|
||||
{/* 冻结在底部的保存按钮 */}
|
||||
<View style={styles.saveBar}>
|
||||
<TouchableOpacity style={styles.saveBtn} onPress={save} activeOpacity={0.8}>
|
||||
<Text style={styles.saveBtnText}>保存</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* 分隔线 */}
|
||||
@ -226,7 +233,22 @@ const styles = StyleSheet.create({
|
||||
|
||||
errorText: { fontSize: 12, color: '#EF4444', marginLeft: 4 },
|
||||
|
||||
bottomSpacer: { height: 24 },
|
||||
bottomSpacer: { height: 8 },
|
||||
|
||||
saveBar: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: '#E5E7EB',
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
saveBtn: {
|
||||
backgroundColor: '#10B981',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
saveBtnText: { color: '#fff', fontSize: 14, fontWeight: '700' },
|
||||
|
||||
// 分隔线
|
||||
colDivider: { width: StyleSheet.hairlineWidth, backgroundColor: '#E5E7EB' },
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Alert, Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import type { Marker } from '@repo/types'
|
||||
import { copyMarker, deleteMarker, updateMarker } from '../../db/markers'
|
||||
@ -20,7 +20,22 @@ export function ItemFormSheet({ marker, onClose, onMarkerUpdated }: ItemFormShee
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const [currentColor, setCurrentColor] = useState(marker?.color ?? '#3B82F6')
|
||||
|
||||
const handleSaved = useCallback(() => setRefreshKey((k) => k + 1), [])
|
||||
const toastOpacity = useRef(new Animated.Value(0)).current
|
||||
|
||||
const showToast = useCallback(() => {
|
||||
toastOpacity.setValue(0)
|
||||
Animated.sequence([
|
||||
Animated.timing(toastOpacity, { toValue: 1, duration: 150, useNativeDriver: true }),
|
||||
Animated.delay(1400),
|
||||
Animated.timing(toastOpacity, { toValue: 0, duration: 300, useNativeDriver: true }),
|
||||
]).start()
|
||||
}, [toastOpacity])
|
||||
|
||||
const handleSaved = useCallback(() => {
|
||||
setRefreshKey((k) => k + 1)
|
||||
onMarkerUpdated?.()
|
||||
showToast()
|
||||
}, [onMarkerUpdated, showToast])
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!marker) return
|
||||
@ -128,6 +143,11 @@ export function ItemFormSheet({ marker, onClose, onMarkerUpdated }: ItemFormShee
|
||||
<View style={styles.formPane}>
|
||||
<ItemFields hook={hook} />
|
||||
</View>
|
||||
|
||||
<Animated.View style={[styles.toast, { opacity: toastOpacity }]} pointerEvents="none">
|
||||
<Ionicons name="checkmark-circle" size={16} color="#fff" />
|
||||
<Text style={styles.toastText}>已保存</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@ -163,4 +183,18 @@ const styles = StyleSheet.create({
|
||||
colorDot: { width: 24, height: 24, borderRadius: 12 },
|
||||
colorDotSelected: { borderWidth: 3, borderColor: '#111827' },
|
||||
formPane: { flex: 1 },
|
||||
|
||||
toast: {
|
||||
position: 'absolute',
|
||||
bottom: 64,
|
||||
alignSelf: 'center',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
backgroundColor: '#111827',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
},
|
||||
toastText: { color: '#fff', fontSize: 13, fontWeight: '600' },
|
||||
})
|
||||
|
||||
@ -29,15 +29,18 @@ export function useItemForm(markerId: number, onSaved: () => void) {
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [photoItemId, setPhotoItemId] = useState<number | null>(null)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [nameError, setNameError] = useState('')
|
||||
|
||||
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
setIsDirty(true)
|
||||
if (key === 'name') setNameError('')
|
||||
}
|
||||
|
||||
function startEdit(item: Item) {
|
||||
setEditingId(item.id)
|
||||
setPhotoItemId(item.id)
|
||||
setNameError('')
|
||||
setForm({
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
@ -56,6 +59,7 @@ export function useItemForm(markerId: number, onSaved: () => void) {
|
||||
setPhotoItemId(null)
|
||||
setForm(DEFAULT_FORM)
|
||||
setIsDirty(false)
|
||||
setNameError('')
|
||||
}
|
||||
|
||||
// 自动保存:有名称且有改动时才执行,不弹错误
|
||||
@ -89,5 +93,37 @@ export function useItemForm(markerId: number, onSaved: () => void) {
|
||||
}
|
||||
}, [form, editingId, isDirty, markerId, onSaved])
|
||||
|
||||
return { form, setField, isDirty, editingId, photoItemId, startEdit, resetForm, autoSave }
|
||||
// 手动保存:有名称校验,无论 isDirty 均执行
|
||||
const save = useCallback(async () => {
|
||||
if (!form.name.trim()) {
|
||||
setNameError('物品名称不能为空')
|
||||
return
|
||||
}
|
||||
const params: InsertItemParams = {
|
||||
markerId,
|
||||
name: form.name.trim(),
|
||||
category: form.category,
|
||||
quantity: Math.max(1, parseInt(form.quantity, 10) || 1),
|
||||
unit: form.unit.trim(),
|
||||
spec: form.spec.trim(),
|
||||
estimatedValue: parseFloat(form.estimatedValue) || 0,
|
||||
referencePrice: parseFloat(form.referencePrice) || 0,
|
||||
remark: form.remark.trim(),
|
||||
}
|
||||
try {
|
||||
if (editingId !== null) {
|
||||
await updateItem(editingId, params)
|
||||
} else {
|
||||
const saved = await insertItem(params)
|
||||
setEditingId(saved.id)
|
||||
setPhotoItemId(saved.id)
|
||||
}
|
||||
setIsDirty(false)
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
console.error('保存物品失败:', err)
|
||||
}
|
||||
}, [form, editingId, markerId, onSaved])
|
||||
|
||||
return { form, setField, nameError, isDirty, editingId, photoItemId, startEdit, resetForm, autoSave, save }
|
||||
}
|
||||
|
||||
@ -146,7 +146,7 @@ const styles = StyleSheet.create({
|
||||
|
||||
// ── 侧栏 ──────────────────────────────────────────
|
||||
sidebar: {
|
||||
width: 120,
|
||||
width: 160,
|
||||
borderRightWidth: StyleSheet.hairlineWidth,
|
||||
borderRightColor: '#E5E7EB',
|
||||
backgroundColor: '#FAFAFA',
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
View,
|
||||
} from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import * as MediaLibrary from 'expo-media-library'
|
||||
import type { Photo } from '@repo/types'
|
||||
import { deletePhoto, getPhotosByItem } from '../../db/photos'
|
||||
import { PhotoPicker } from './PhotoPicker'
|
||||
@ -21,6 +22,7 @@ interface PhotoGridProps {
|
||||
export function PhotoGrid({ itemId }: PhotoGridProps) {
|
||||
const [photos, setPhotos] = useState<Photo[]>([])
|
||||
const [previewIndex, setPreviewIndex] = useState<number | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@ -53,6 +55,27 @@ 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()
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('权限不足', '请在系统设置中允许访问相册')
|
||||
return
|
||||
}
|
||||
await MediaLibrary.saveToLibraryAsync(currentPhoto.filePath)
|
||||
Alert.alert('已保存', '照片已成功保存到本地相册')
|
||||
} catch (err) {
|
||||
console.error('保存到相册失败:', err)
|
||||
Alert.alert('保存失败', '无法保存照片,请重试')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [previewIndex, photos])
|
||||
|
||||
const currentPhoto = previewIndex !== null ? photos[previewIndex] : null
|
||||
|
||||
return (
|
||||
@ -80,9 +103,20 @@ export function PhotoGrid({ itemId }: PhotoGridProps) {
|
||||
onRequestClose={() => setPreviewIndex(null)}
|
||||
>
|
||||
<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>
|
||||
|
||||
<ScrollView
|
||||
horizontal
|
||||
pagingEnabled
|
||||
@ -132,6 +166,14 @@ const styles = StyleSheet.create({
|
||||
zIndex: 10,
|
||||
padding: 4,
|
||||
},
|
||||
saveBtn: {
|
||||
position: 'absolute',
|
||||
top: 48,
|
||||
left: 16,
|
||||
zIndex: 10,
|
||||
padding: 4,
|
||||
},
|
||||
saveBtnDisabled: { opacity: 0.4 },
|
||||
previewPage: {
|
||||
width: 400,
|
||||
justifyContent: 'center',
|
||||
|
||||
@ -43,7 +43,7 @@ export function usePhotoCapture(itemId: number, onAdded: () => void) {
|
||||
}
|
||||
|
||||
const pickOptions: ImagePicker.ImagePickerOptions = {
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
mediaTypes: ImagePicker.MediaType.Images,
|
||||
allowsEditing: false,
|
||||
quality: JPEG_QUALITY,
|
||||
exif: false,
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { type ReactNode, useEffect, useRef } from 'react'
|
||||
import { Animated, StyleSheet, View } from 'react-native'
|
||||
import { type ReactNode, useRef } from 'react'
|
||||
import { LayoutAnimation, Platform, StyleSheet, UIManager, View } from 'react-native'
|
||||
import { useFoldState } from './useFoldState'
|
||||
|
||||
// Android 需要手动开启 LayoutAnimation 支持
|
||||
if (Platform.OS === 'android') {
|
||||
UIManager.setLayoutAnimationEnabledExperimental?.(true)
|
||||
}
|
||||
|
||||
const DETAIL_PANEL_WIDTH = 260
|
||||
|
||||
interface FoldableLayoutProps {
|
||||
@ -12,15 +17,13 @@ interface FoldableLayoutProps {
|
||||
|
||||
export function FoldableLayout({ left, right, showDetail = false }: FoldableLayoutProps) {
|
||||
const { isUnfolded } = useFoldState()
|
||||
const panelWidth = useRef(new Animated.Value(showDetail ? DETAIL_PANEL_WIDTH : 0)).current
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(panelWidth, {
|
||||
toValue: showDetail ? DETAIL_PANEL_WIDTH : 0,
|
||||
duration: 220,
|
||||
useNativeDriver: false,
|
||||
}).start()
|
||||
}, [showDetail])
|
||||
// 在 props 变化引起的 re-render 之前触发原生布局动画
|
||||
const prevShowDetail = useRef(showDetail)
|
||||
if (prevShowDetail.current !== showDetail) {
|
||||
prevShowDetail.current = showDetail
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
}
|
||||
|
||||
if (!isUnfolded) {
|
||||
return <View style={styles.single}>{left}</View>
|
||||
@ -29,25 +32,16 @@ export function FoldableLayout({ left, right, showDetail = false }: FoldableLayo
|
||||
return (
|
||||
<View style={styles.dual}>
|
||||
<View style={styles.mapPane}>{left}</View>
|
||||
<Animated.View style={[styles.detailPane, { width: panelWidth }]}>
|
||||
{right}
|
||||
</Animated.View>
|
||||
{showDetail && (
|
||||
<View style={styles.detailPane}>{right}</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
single: {
|
||||
flex: 1,
|
||||
},
|
||||
dual: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
mapPane: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
detailPane: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
single: { flex: 1 },
|
||||
dual: { flex: 1, flexDirection: 'row' },
|
||||
mapPane: { flexGrow: 1 },
|
||||
detailPane: { width: DETAIL_PANEL_WIDTH },
|
||||
})
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@ -40,6 +40,9 @@ importers:
|
||||
expo-linking:
|
||||
specifier: ^7.0.5
|
||||
version: 7.0.5(expo@52.0.49(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@expo/metro-runtime@4.0.1(react-native@0.76.3(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1)))(react-native@0.76.3(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1))(react@18.3.1))(react-native@0.76.3(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)
|
||||
expo-media-library:
|
||||
specifier: ~16.0.0
|
||||
version: 16.0.5(expo@52.0.49(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@expo/metro-runtime@4.0.1(react-native@0.76.3(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1)))(react-native@0.76.3(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1))(react@18.3.1))
|
||||
expo-router:
|
||||
specifier: ~4.0.0
|
||||
version: 4.0.22(2up5cuuap7dudmj6aangd7ypjy)
|
||||
@ -2146,6 +2149,11 @@ packages:
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
|
||||
expo-media-library@16.0.5:
|
||||
resolution: {integrity: sha512-O9RUqBWgJVRF0mO6EiLSBFyfb5wR1/ZqovbT43V0TAo5sgcjrHRs+0NID/U6BWDRuiFeX2AU516JgNDutNUFSw==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
|
||||
expo-modules-autolinking@2.0.8:
|
||||
resolution: {integrity: sha512-DezgnEYFQYic8hKGhkbztBA3QUmSftjaNDIKNAtS2iGJmzCcNIkatjN2slFDSWjSTNo8gOvPQyMKfyHWFvLpOQ==}
|
||||
hasBin: true
|
||||
@ -6787,6 +6795,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
expo-media-library@16.0.5(expo@52.0.49(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@expo/metro-runtime@4.0.1(react-native@0.76.3(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1)))(react-native@0.76.3(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)):
|
||||
dependencies:
|
||||
expo: 52.0.49(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@expo/metro-runtime@4.0.1(react-native@0.76.3(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1)))(react-native@0.76.3(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)
|
||||
|
||||
expo-modules-autolinking@2.0.8:
|
||||
dependencies:
|
||||
'@expo/spawn-async': 1.7.2
|
||||
|
||||
Loading…
Reference in New Issue
Block a user