refactor: 标记点序号显示、布局叠加方式及照片选择器重构
- 标记点角标从物品数量改为按楼层顺序编号,角标背景色跟随标记颜色 - FoldableLayout 改为绝对定位叠加布局,移除 LayoutAnimation(解决 Android 地图渲染空白及卡顿) - PhotoPicker 拆分为拍照/选图两个独立按钮;选图改用 expo-document-picker 绕过三星 Photo Picker 崩溃问题 - AndroidManifest 移除手动 FileProvider 声明(改由 Expo 插件自动注入) - 同步更新蓝图文档:新增 category-management 模块、补全各模块已完成任务卡 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cb7c5ad7f4
commit
3dbc6f0ff5
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
version: 1.4.2
|
version: 1.4.3
|
||||||
updated: 2026-04-08
|
updated: 2026-04-10
|
||||||
source: dev-manager-tauri
|
source: dev-manager-tauri
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
version: 1
|
version: 1
|
||||||
name: react-phone-apps
|
name: react-phone-apps
|
||||||
iteration: 2
|
iteration: 2
|
||||||
updated: 2026-04-09
|
updated: 2026-04-10
|
||||||
|
|
||||||
|
|
||||||
areas:
|
areas:
|
||||||
@ -103,6 +103,13 @@ modules:
|
|||||||
progress: 100
|
progress: 100
|
||||||
position: [980, 880]
|
position: [980, 880]
|
||||||
|
|
||||||
|
- id: category-management
|
||||||
|
name: 物品分类管理
|
||||||
|
area: rn-app
|
||||||
|
status: done
|
||||||
|
progress: 100
|
||||||
|
position: [980, 1080]
|
||||||
|
|
||||||
# ── 前端应用(Web,预留)──────────────────────────────────────
|
# ── 前端应用(Web,预留)──────────────────────────────────────
|
||||||
- id: app-scaffold
|
- id: app-scaffold
|
||||||
name: 应用脚手架模板
|
name: 应用脚手架模板
|
||||||
@ -202,6 +209,14 @@ edges:
|
|||||||
to: photo-capture
|
to: photo-capture
|
||||||
type: dependency
|
type: dependency
|
||||||
|
|
||||||
|
# 分类管理
|
||||||
|
- from: local-data-layer
|
||||||
|
to: category-management
|
||||||
|
type: dependency
|
||||||
|
- from: category-management
|
||||||
|
to: item-form
|
||||||
|
type: dependency
|
||||||
|
|
||||||
# Web 前端(预留)
|
# Web 前端(预留)
|
||||||
- from: monorepo-setup
|
- from: monorepo-setup
|
||||||
to: app-scaffold
|
to: app-scaffold
|
||||||
|
|||||||
31
.blueprint/modules/category-management.md
Normal file
31
.blueprint/modules/category-management.md
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
id: category-management
|
||||||
|
name: 物品分类管理
|
||||||
|
area: rn-app
|
||||||
|
status: done
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
独立的「设置」Tab,允许用户自定义物品分类标签(增删改)。分类数据存储在 SQLite categories 表,通过全局 CategoriesContext 供物品表单实时使用,替代原有硬编码枚举。
|
||||||
|
|
||||||
|
## 任务卡
|
||||||
|
|
||||||
|
### ✅ 分类数据库表与 CRUD
|
||||||
|
- status: done
|
||||||
|
- complexity: S
|
||||||
|
- files: apps/property-survey/src/db/categories.ts, apps/property-survey/src/db/schema.ts, apps/property-survey/src/db/database.ts
|
||||||
|
- acceptance: categories 表含 id / name / sort_order 三列;提供 getCategories / insertCategory / updateCategory / deleteCategory 四个函数;App 首次启动时写入「家具、家电、电子、车辆、其他」五个默认分类
|
||||||
|
|
||||||
|
### ✅ 全局分类 Context
|
||||||
|
- status: done
|
||||||
|
- complexity: S
|
||||||
|
- files: apps/property-survey/src/features/settings/CategoriesContext.tsx, apps/property-survey/app/_layout.tsx
|
||||||
|
- acceptance: CategoriesProvider 在根布局包裹全树;useCategories() 返回 `{ categories, reload }`;categories 在分类增删改后自动刷新,物品表单的分类 chip 同步更新
|
||||||
|
|
||||||
|
### ✅ 设置页 UI(分类增删改)
|
||||||
|
- status: done
|
||||||
|
- complexity: M
|
||||||
|
- files: apps/property-survey/app/settings.tsx
|
||||||
|
- depends: category-management
|
||||||
|
- acceptance: 底部 Tab 新增「设置」入口;列表展示当前所有分类,支持点击铅笔图标内联重命名(TextInput + 确认/取消);底部固定新增输入框 + 按钮;删除时弹确认框,说明已登记物品的分类标签不受影响
|
||||||
@ -7,34 +7,50 @@ status: done
|
|||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
以 SVG/Image 占位图为底图,支持长按添加标记点、点击标记点进入编辑的交互逻辑。标记点坐标以相对比例(0~1)存储,替换真实楼层图时无需修改数据。
|
以真实 SVG 楼层平面图为底图,支持捏合缩放、单指平移、长按添加标记点、拖拽移动标记点。支持 1F/2F/3F/楼顶四个楼层。SVG 内嵌图片通过构建脚本提取,分层以原生 Image 渲染,保证清晰度和旋转精度。
|
||||||
|
|
||||||
## 任务卡
|
## 任务卡
|
||||||
|
|
||||||
### 🔵 平面图占位容器与坐标系
|
### ✅ SVG 地图渲染(多层架构)
|
||||||
|
- status: done
|
||||||
|
- complexity: M
|
||||||
|
- files: apps/property-survey/src/features/map/FloorMap.tsx, apps/property-survey/src/features/map/floorMaps.generated.ts, apps/property-survey/scripts/generate-maps.js
|
||||||
|
- acceptance: 三层渲染:① 底图 JPEG(原生 Image,from imageUri)② SVG 矢量路径(SvgXml,overdraw 预渲染提升缩放清晰度,动态计算安全 overdraw 倍数避免 Android Canvas OOM)③ 用户嵌入图片(原生 Image,从 SVG 提取并按 SVG matrix/rotate 变换还原坐标和旋转角)
|
||||||
|
|
||||||
|
### ✅ 捏合缩放 + 单指平移手势
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: M
|
- complexity: M
|
||||||
- files: apps/property-survey/src/features/map/FloorMap.tsx
|
- files: apps/property-survey/src/features/map/FloorMap.tsx
|
||||||
- depends: survey-app-shell
|
- acceptance: 使用 react-native-gesture-handler Gesture API;Pinch 手势控制缩放(MIN_SCALE=1, MAX_SCALE=5);Pan 手势(minPointers:1, maxPointers:1)控制平移;双指和单指通过 Gesture.Race 互斥;使用 react-native-reanimated SharedValue 实现原生线程动画
|
||||||
- acceptance: 渲染一个带灰色背景的矩形占位区域作为平面图,使用 onLayout 获取实际像素尺寸并转换相对坐标(0~1),容器宽高比固定为 4:3,适配折叠/展开两种宽度
|
|
||||||
|
|
||||||
### 🔵 长按手势添加标记点
|
### ✅ 长按添加标记点
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: M
|
- complexity: M
|
||||||
- files: apps/property-survey/src/features/map/useMarkerGesture.ts
|
- files: apps/property-survey/src/features/map/FloorMap.tsx
|
||||||
- depends: floor-map
|
- acceptance: LongPress 手势(minDuration:500ms);触点坐标反算缩放/平移变换得到地图相对坐标(0~1);调用 insertMarker 写入数据库;与双指手势通过 Gesture.Race 互斥
|
||||||
- acceptance: 在平面图区域长按 500ms 后触发,将触点位置转换为相对坐标并调用 insertMarker 写入数据库,新标记点立即在地图上显示,不误触短按事件
|
|
||||||
|
|
||||||
### 🔵 标记点图标组件
|
### ✅ 标记点图标组件
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: M
|
- complexity: M
|
||||||
- files: apps/property-survey/src/features/map/MapMarker.tsx
|
- files: apps/property-survey/src/features/map/MapMarker.tsx
|
||||||
- depends: floor-map
|
- depends: floor-map
|
||||||
- acceptance: 在平面图上按相对坐标绝对定位渲染图钉图标,已有物品的标记点显示蓝色图钉 + 物品数量角标,空标记点显示灰色图钉,点击触发 onPress 回调
|
- acceptance: 按相对坐标绝对定位;已有物品的标记点显示彩色图钉 + 数量角标(颜色来自 marker.color);选中状态有缩放高亮;支持拖拽移动(Pan 手势,拖拽结束调用 onDragEnd)
|
||||||
|
|
||||||
### 🔵 当前楼层标记点数据加载
|
### ✅ 楼层标记点数据加载与标记列表
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: S
|
- complexity: S
|
||||||
- files: apps/property-survey/src/features/map/MarkerList.tsx
|
- files: apps/property-survey/src/features/map/MarkerList.tsx
|
||||||
- depends: floor-map
|
- depends: floor-map
|
||||||
- acceptance: 楼层切换时调用 getMarkersByFloor 重新查询,渲染该楼层所有 MapMarker,删除标记点时弹出确认对话框(含级联删除提示)
|
- acceptance: 楼层切换或 refreshKey 变化时重新查询;左侧边栏展示当前楼层所有标记点卡片(标签、物品列表含确认状态图标);删除标记点时弹确认对话框含级联删除提示
|
||||||
|
|
||||||
|
### ✅ SVG 地图生成脚本
|
||||||
|
- status: done
|
||||||
|
- complexity: M
|
||||||
|
- files: scripts/generate-maps.js, apps/property-survey/img/(1101-1104.svg)
|
||||||
|
- acceptance: 读取 4 个 SVG 文件;提取背景 JPEG(imageUri)+ 用户嵌入图片(embeddedImages,含 cx/cy/width/height/angleDeg/opacity);从 svgXml 中去除所有 image 元素及 Inkscape 私有命名空间;生成 floorMaps.generated.ts TypeScript 文件
|
||||||
|
|
||||||
|
## 决策记录
|
||||||
|
|
||||||
|
- **SVG 三层渲染架构**:`SvgXml` 无法可靠渲染带 `rotate`/`matrix` transform 的 `<image>` 元素(坐标偏移、旋转不准)。改为构建时提取:① 背景 JPEG 用原生 `Image` 渲染(性能好);② 矢量路径用 `SvgXml` 渲染(保留矢量清晰度);③ 用户嵌入图片用原生 `Image` 渲染(精确还原旋转和位置)。
|
||||||
|
- **SVG overdraw 预渲染**:`SvgXml` 在 RN 中被光栅化为固定分辨率纹理,缩放时模糊。通过以 N 倍宽高渲染再 `scale(1/N)` 缩回,在 Android Canvas 安全内存上限内最大化预渲染分辨率。N 值由 `calcSafeOverdraw()` 根据 `PixelRatio.get()` 动态计算,避免高 DPI 设备 OOM。
|
||||||
|
- **嵌入图片坐标还原**:SVG `matrix(a,b,c,d,e,f)` 变换的图片中心点计算公式:`cx = a*(ox+W/2) + c*(oy+H/2) + e`,旋转角 `angleDeg = atan2(b,a) * 180/π`。RN 中 Image 的 `transform: rotate` 以元素自身中心为锚点,与 SVG transform 语义一致,无需额外偏移修正。
|
||||||
|
|||||||
@ -7,27 +7,34 @@ status: done
|
|||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
点击地图标记点后弹出的物品编辑面板。折叠时以底部抽屉形式展现,展开时在右侧栏内联显示。支持单个标记点下的多条物品记录增删改。
|
点击地图标记点后弹出的物品编辑面板(右侧栏内联)。支持单个标记点下的多条物品记录增删改,物品字段含名称、动态分类、数量、单位、规格、单价、参考价、备注,以及确认状态标记。
|
||||||
|
|
||||||
## 任务卡
|
## 任务卡
|
||||||
|
|
||||||
### 🔵 底部抽屉/右栏容器
|
### ✅ 底部抽屉/右栏容器
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: M
|
- complexity: M
|
||||||
- files: apps/property-survey/src/features/item/ItemFormSheet.tsx
|
- files: apps/property-survey/src/features/item/ItemFormSheet.tsx
|
||||||
- depends: survey-app-shell, floor-map
|
- depends: survey-app-shell, floor-map
|
||||||
- acceptance: 折叠时从屏幕底部滑出(BottomSheet),展开时在 FoldableLayout 右栏内渲染;接受 markerId 和 onClose 两个 prop;顶部显示标记点位置名称和所属楼层
|
- acceptance: 右栏内渲染;顶部显示标记点标签 + 楼层 + 颜色选择器;头部操作区含复制标记点、删除标记点、关闭按钮
|
||||||
|
|
||||||
### 🔵 物品字段表单
|
### ✅ 物品字段表单
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: M
|
- complexity: M
|
||||||
- files: apps/property-survey/src/features/item/ItemFields.tsx, apps/property-survey/src/features/item/useItemForm.ts
|
- files: apps/property-survey/src/features/item/ItemFields.tsx, apps/property-survey/src/features/item/useItemForm.ts
|
||||||
- depends: item-form
|
- depends: item-form, category-management
|
||||||
- acceptance: 包含物品名称(必填文本)、数量(数字键盘)、单价(数字键盘,可选)、备注1(多行文本)、备注2(多行文本)五个字段;保存时校验物品名称非空;useItemForm 管理字段状态和 insertItem / updateItem 调用
|
- acceptance: 字段:物品名称(必填)、动态分类 chip(来自 CategoriesContext)、数量、单位、单价、参考价、规格(多行)、备注(多行);底部固定保存按钮 + 确认状态勾选框(显示确认时间或「待确认」);保存时校验物品名称非空;保存成功后显示 Toast 提示(「已保存」)
|
||||||
|
|
||||||
### 🔵 标记点下物品列表
|
### ✅ 标记点下物品列表
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: M
|
- complexity: M
|
||||||
- files: apps/property-survey/src/features/item/ItemList.tsx
|
- files: apps/property-survey/src/features/item/ItemList.tsx
|
||||||
- depends: item-form
|
- depends: item-form
|
||||||
- acceptance: 在表单上方列出当前标记点的所有物品(物品名 + 数量 + 单价),点击条目进入编辑,左滑显示删除按钮,点击「+新增物品」清空表单进入新增模式
|
- acceptance: 横向滚动列表展示当前标记点所有物品;点击条目进入编辑;含新增物品入口;物品卡片显示名称、数量、照片数
|
||||||
|
|
||||||
|
### ✅ 物品确认状态
|
||||||
|
- status: done
|
||||||
|
- complexity: S
|
||||||
|
- files: apps/property-survey/src/features/item/ItemFields.tsx, apps/property-survey/src/features/item/useItemForm.ts, apps/property-survey/src/db/items.ts
|
||||||
|
- depends: item-form
|
||||||
|
- acceptance: 表单底部显示勾选框;未确认显示「待确认」灰色文字,已确认显示绿色确认时间(MM-DD HH:mm);点击切换状态,保存时写入 confirmed_at 字段;标记点列表侧边栏同步显示每个物品的确认状态图标
|
||||||
|
|||||||
@ -7,33 +7,46 @@ status: done
|
|||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
基于 Expo SQLite 的本地数据库层,管理标记点、物品记录和照片路径三张核心表。所有写操作使用事务保证原子性,供上层功能模块调用。
|
基于 Expo SQLite 的本地数据库层,管理标记点、物品记录、照片路径、物品分类四张核心表。所有写操作使用事务保证原子性,供上层功能模块调用。支持在线迁移:每次启动检测旧库结构并补列/重建表,无需用户操作。
|
||||||
|
|
||||||
## 任务卡
|
## 任务卡
|
||||||
|
|
||||||
### 🔵 SQLite 初始化与 Schema 建表
|
### ✅ SQLite 初始化与 Schema 建表
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: M
|
- complexity: M
|
||||||
- files: apps/property-survey/src/db/database.ts, apps/property-survey/src/db/schema.ts
|
- 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 三张表,重复启动不报错,字段与设计文档一致
|
- acceptance: App 启动时自动执行 CREATE TABLE IF NOT EXISTS,建立 markers / items / photos / categories 四张表,重复启动不报错;首次启动写入默认分类;字段与设计文档一致
|
||||||
|
|
||||||
### 🔵 标记点 CRUD 操作
|
### ✅ 在线迁移脚本
|
||||||
|
- status: done
|
||||||
|
- complexity: M
|
||||||
|
- files: apps/property-survey/src/db/database.ts
|
||||||
|
- acceptance: 启动时检测旧库并执行:①为 items 添加 reference_price / spec / unit / confirmed_at 列;②重建 markers 表以扩展 floor CHECK 约束支持第4楼(楼顶);③为旧库初始化 categories 默认数据;所有迁移幂等,多次执行不报错
|
||||||
|
|
||||||
|
### ✅ 标记点 CRUD 操作
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: M
|
- complexity: M
|
||||||
- files: apps/property-survey/src/db/markers.ts
|
- files: apps/property-survey/src/db/markers.ts
|
||||||
- depends: local-data-layer
|
- depends: local-data-layer
|
||||||
- acceptance: 提供 insertMarker / getMarkersByFloor / updateMarker / deleteMarker 四个函数,deleteMarker 级联删除关联 items 和 photos,所有函数返回类型安全的 Promise
|
- acceptance: 提供 insertMarker / getMarkersByFloor / updateMarker / deleteMarker 四个函数,deleteMarker 级联删除关联 items 和 photos,所有函数返回类型安全的 Promise;floor 支持 1/2/3/4
|
||||||
|
|
||||||
### 🔵 物品记录 CRUD 操作
|
### ✅ 物品记录 CRUD 操作
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: M
|
- complexity: M
|
||||||
- files: apps/property-survey/src/db/items.ts
|
- files: apps/property-survey/src/db/items.ts
|
||||||
- depends: local-data-layer
|
- depends: local-data-layer
|
||||||
- acceptance: 提供 insertItem / getItemsByMarker / updateItem / deleteItem 四个函数,支持批量查询(传入 marker_id 数组),返回带关联照片数量的扩展类型
|
- acceptance: 提供 insertItem / getItemsByMarker / updateItem / deleteItem 四个函数;字段含 name / category / quantity / unit / spec / estimatedValue / referencePrice / remark / confirmedAt;返回带关联照片数量的扩展类型
|
||||||
|
|
||||||
### 🔵 照片路径 CRUD 操作
|
### ✅ 照片路径 CRUD 操作
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: S
|
- complexity: S
|
||||||
- files: apps/property-survey/src/db/photos.ts
|
- files: apps/property-survey/src/db/photos.ts
|
||||||
- depends: local-data-layer
|
- depends: local-data-layer
|
||||||
- acceptance: 提供 insertPhoto / getPhotosByItem / deletePhoto / reorderPhotos 四个函数,deletePhoto 同步删除设备文件系统中的对应图片文件
|
- acceptance: 提供 insertPhoto / getPhotosByItem / deletePhoto / reorderPhotos 四个函数,deletePhoto 同步删除设备文件系统中的对应图片文件
|
||||||
|
|
||||||
|
### ✅ 分类 CRUD 操作
|
||||||
|
- status: done
|
||||||
|
- complexity: S
|
||||||
|
- files: apps/property-survey/src/db/categories.ts
|
||||||
|
- depends: local-data-layer
|
||||||
|
- acceptance: 提供 getCategories / insertCategory / updateCategory / deleteCategory 四个函数;新增时自动计算 sort_order(追加到末尾);name 字段有 UNIQUE 约束
|
||||||
|
|||||||
@ -7,20 +7,38 @@ status: done
|
|||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
调用设备摄像头或相册为物品条目添加多张照片,照片压缩后存储到 App 专属目录,路径写入 SQLite photos 表。
|
调用设备摄像头或相册为物品条目添加多张照片,照片存储到 App 专属目录,路径写入 SQLite photos 表。支持 Android 13+ 权限模型(CAMERA + READ_MEDIA_IMAGES + FileProvider)。
|
||||||
|
|
||||||
## 任务卡
|
## 任务卡
|
||||||
|
|
||||||
### 🔵 摄像头调用与图片压缩
|
### ✅ 摄像头调用与照片存储
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: M
|
- complexity: M
|
||||||
- files: apps/property-survey/src/features/photo/usePhotoCapture.ts
|
- files: apps/property-survey/src/features/photo/usePhotoCapture.ts, apps/property-survey/src/features/photo/PhotoPicker.tsx
|
||||||
- depends: local-data-layer
|
- depends: local-data-layer
|
||||||
- acceptance: 使用 expo-image-picker 提供「拍照」和「从相册选择」两种入口;拍摄/选择后自动压缩至最大宽度 1080px、JPEG quality 0.75;将压缩后的文件复制到 expo-file-system 的 App 专属目录;调用 insertPhoto 写入数据库;全流程有权限检查,拒绝权限时给出提示
|
- acceptance: 使用 expo-image-picker(MediaType.Images,已从废弃的 MediaTypeOptions 迁移);「拍照」和「从相册选择」两种入口;Alert 关闭后延迟 300ms 启动,避免与系统权限弹窗冲突;照片复制到 App 专属目录(expo-file-system);调用 insertPhoto 写入数据库;有权限检查
|
||||||
|
|
||||||
### 🔵 照片网格预览与删除
|
### ✅ Android 权限与 FileProvider 配置
|
||||||
|
- status: done
|
||||||
|
- complexity: S
|
||||||
|
- files: apps/property-survey/android/app/src/main/AndroidManifest.xml, apps/property-survey/android/app/src/main/res/xml/file_paths.xml, apps/property-survey/app.json
|
||||||
|
- acceptance: AndroidManifest 声明 CAMERA + READ_MEDIA_IMAGES 权限(兼容 Android 13+);配置 FileProvider(android:authorities="${applicationId}.provider")支持相机临时文件 URI;app.json 中 expo-image-picker 插件配置中文权限说明
|
||||||
|
|
||||||
|
### ✅ 照片网格预览与删除(物品表单内)
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: M
|
- complexity: M
|
||||||
- files: apps/property-survey/src/features/photo/PhotoGrid.tsx, apps/property-survey/src/features/photo/PhotoPicker.tsx
|
- files: apps/property-survey/src/features/photo/PhotoGrid.tsx
|
||||||
- depends: photo-capture
|
- depends: photo-capture
|
||||||
- acceptance: 以 3 列网格展示已添加的照片缩略图;点击照片全屏预览(支持左右滑动切换);长按照片显示「删除」确认弹窗,删除时同步调用 deletePhoto(含文件系统删除);末尾显示「+」按钮触发 PhotoPicker 添加更多
|
- acceptance: 3 列网格展示已添加的照片缩略图;点击照片全屏预览(FlatList + pagingEnabled + getItemLayout,使用 useWindowDimensions 实时宽度计算每页偏移);全屏预览顶部有「保存到相册」按钮(expo-media-library,保存后显示"已保存"提示);长按照片显示「删除」确认弹窗,删除时同步调用 deletePhoto(含文件系统删除);末尾显示「+」按钮触发 PhotoPicker
|
||||||
|
|
||||||
|
### ✅ 查看模式照片面板(MarkerPhotoPanel)
|
||||||
|
- status: done
|
||||||
|
- complexity: M
|
||||||
|
- files: apps/property-survey/src/features/photo/MarkerPhotoPanel.tsx
|
||||||
|
- depends: photo-capture
|
||||||
|
- acceptance: 查看模式下右侧面板;按物品分组展示照片缩略图(4 列网格 + 物品名标题);点击缩略图全屏 Modal 预览;Modal 为非透明全屏黑底(解决 Android transparent Modal 仅覆盖父容器的问题);单张显示 + onTouchStart/End 手势滑动切换(≥50px 触发)+ 左右箭头按钮;计数器实时同步
|
||||||
|
|
||||||
|
## 决策记录
|
||||||
|
|
||||||
|
- **照片预览放弃 ScrollView/FlatList pagingEnabled**:在 Android 上 `transparent` Modal 的渲染范围被限制在父组件(右侧 260px 面板)内而非全屏,导致 `pagingEnabled` 页宽与实际视口不匹配,两张照片始终并排显示。尝试多种宽度测量方案(`SCREEN_W` 静态值、`onLayout` 动态测量、`useWindowDimensions`)均无效。最终改为单张显示 + 原生 Touch 事件手势,彻底绕开分页宽度问题。
|
||||||
|
- **Modal 去掉 `transparent` prop**:`transparent={true}` 在 Android 上使 Modal 继承父容器的渲染上下文(右侧面板),只遮盖面板区域而非全屏。改为非透明 Modal + 黑色背景,强制创建独立原生全屏窗口。
|
||||||
|
|||||||
@ -17,11 +17,11 @@ status: done
|
|||||||
- files: packages/types/package.json, packages/types/src/index.ts
|
- files: packages/types/package.json, packages/types/src/index.ts
|
||||||
- acceptance: packages/types 仅包含 .d.ts 或 type-only TS 文件,被引入时不产生任何运行时代码
|
- acceptance: packages/types 仅包含 .d.ts 或 type-only TS 文件,被引入时不产生任何运行时代码
|
||||||
|
|
||||||
### ✅ 通用业务类型
|
### ✅ 核心业务类型
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: S
|
- complexity: S
|
||||||
- files: packages/types/src/common.ts, packages/types/src/survey.ts
|
- files: packages/types/src/survey.ts
|
||||||
- acceptance: 定义 ApiResponse<T>、PaginatedResult<T>、Nullable<T> 等通用泛型类型,以及 Marker/Item/Photo 业务类型
|
- acceptance: 定义 FloorNumber(1|2|3|4)、Marker(含 color)、Item(含 unit / spec / confirmedAt / referencePrice)、ItemWithPhotoCount、Photo 业务类型;所有字段与 SQLite schema 保持一致
|
||||||
|
|
||||||
### 💭 环境与配置类型
|
### 💭 环境与配置类型
|
||||||
- status: concept
|
- status: concept
|
||||||
|
|||||||
@ -7,47 +7,47 @@ status: done
|
|||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
React Native (Expo SDK 52 + Expo Router) 应用骨架,实现 Z Fold 6 折叠/展开双形态布局切换。折叠时单栏显示,展开时左侧地图 + 右侧表单双栏并排。
|
React Native (Expo SDK 52 + Expo Router) 应用骨架,实现 Z Fold 6 折叠/展开双形态布局切换。折叠时单栏显示,展开时左侧地图 + 右侧表单双栏并排。底部导航含「登记」「汇总」「设置」三个 Tab。
|
||||||
|
|
||||||
## 任务卡
|
## 任务卡
|
||||||
|
|
||||||
### 🔵 Expo 应用骨架初始化
|
### ✅ Expo 应用骨架初始化
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: M
|
- 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
|
- 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, apps/property-survey/app/settings.tsx
|
||||||
- depends: local-data-layer
|
- depends: local-data-layer
|
||||||
- acceptance: `pnpm --filter property-survey start` 能在 Expo Go 或开发构建中启动,底部导航可在「登记」和「汇总」两个页面间切换,SQLite 数据库在启动时完成初始化
|
- acceptance: `pnpm --filter property-survey start` 能启动;底部导航在「登记」「汇总」「设置」三个页面间切换;根布局包裹 CategoriesProvider;SQLite 数据库在启动时完成初始化
|
||||||
|
|
||||||
### 🔵 折叠屏状态感知 Hook
|
### ✅ 折叠屏状态感知 Hook
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: S
|
- complexity: S
|
||||||
- files: apps/property-survey/src/layout/useFoldState.ts
|
- files: apps/property-survey/src/layout/useFoldState.ts
|
||||||
- acceptance: useFoldState 返回 `{ isUnfolded: boolean, windowWidth: number }`,以 720px 为断点区分折叠/展开状态,useWindowDimensions 变化时自动更新,在模拟器宽度调整时可观察到状态切换
|
- acceptance: useFoldState 返回 `{ isUnfolded: boolean, windowWidth: number }`,以 720px 为断点区分折叠/展开状态,useWindowDimensions 变化时自动更新
|
||||||
|
|
||||||
### 🔵 双栏/单栏自适应布局容器
|
### ✅ 双栏/单栏自适应布局容器
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: M
|
- complexity: M
|
||||||
- files: apps/property-survey/src/layout/FoldableLayout.tsx
|
- files: apps/property-survey/src/layout/FoldableLayout.tsx
|
||||||
- depends: survey-app-shell
|
- depends: survey-app-shell
|
||||||
- acceptance: 展开时渲染左右两栏(左侧 flexGrow:1 地图区,右侧 380px 详情区),折叠时只渲染左侧(地图全屏),接受 `left` 和 `right` 两个 ReactNode 插槽
|
- acceptance: 展开时渲染左右两栏(左侧 flexGrow:1 地图区,右侧 260px 详情区动画展开);接受 `left` `right` `showDetail` 三个 prop;`showDetail` 控制右侧面板以 `LayoutAnimation.configureNext` 动画展开/收起(替代 JS 线程 Animated.timing,解决 Android 地图渲染空白及卡顿);折叠时只渲染左侧
|
||||||
|
|
||||||
### 🔵 楼层切换标签组件
|
### ✅ 楼层切换标签组件
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: S
|
- complexity: S
|
||||||
- files: apps/property-survey/src/layout/FloorTabs.tsx
|
- files: apps/property-survey/src/layout/FloorTabs.tsx
|
||||||
- depends: survey-app-shell
|
- depends: survey-app-shell
|
||||||
- acceptance: 显示 1F / 2F / 3F 三个标签,选中项高亮,onChange 回调传出当前楼层数字(1/2/3),组件宽度自适应容器
|
- acceptance: 显示 1F / 2F / 3F / 楼顶 四个标签;选中项高亮;onChange 回调传出当前楼层数字(1/2/3/4)
|
||||||
|
|
||||||
### ✅ 查看/编辑模式切换
|
### ✅ 查看/编辑模式切换
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: S
|
- complexity: S
|
||||||
- files: apps/property-survey/app/index.tsx
|
- files: apps/property-survey/app/index.tsx
|
||||||
- depends: survey-app-shell
|
- depends: survey-app-shell
|
||||||
- acceptance: 顶栏左侧显示「查看 | 编辑」分段控件;查看模式下右侧面板渲染 MarkerPhotoPanel,编辑模式下渲染 ItemFormSheet;折叠屏底部抽屉同步随模式切换;默认为编辑模式
|
- acceptance: 顶栏左侧显示「查看 | 编辑」分段控件;查看模式下右侧面板渲染 MarkerPhotoPanel,编辑模式下渲染 ItemFormSheet;无选中标记时右侧面板为空(showDetail=false,动画收起);默认为编辑模式
|
||||||
|
|
||||||
### ✅ 查看模式照片面板
|
### ✅ 查看模式照片面板集成
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: M
|
- complexity: S
|
||||||
- files: apps/property-survey/src/features/photo/MarkerPhotoPanel.tsx
|
- files: apps/property-survey/app/index.tsx
|
||||||
- depends: photo-capture
|
- depends: photo-capture
|
||||||
- acceptance: 展开屏内联右侧面板,折叠屏底部抽屉;按物品分组展示照片缩略图(3列网格),含物品名小标题;点击缩略图全屏预览,左右箭头切换;无照片物品显示「暂无照片」占位;无选中标记时面板为空白引导
|
- acceptance: 选中标记点且处于查看模式时,showDetail=true,右侧面板渲染 MarkerPhotoPanel;切换楼层或模式时自动清空 selectedMarker
|
||||||
|
|||||||
42
CLAUDE.md
42
CLAUDE.md
@ -1,26 +1,38 @@
|
|||||||
# react-phone-apps
|
# react-phone-apps
|
||||||
|
|
||||||
<!-- BLUEPRINT_MANAGED_START version="1.4.2" -->
|
<!-- BLUEPRINT_MANAGED_START version="1.4.3" -->
|
||||||
## 项目蓝图
|
## 项目蓝图
|
||||||
|
|
||||||
本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。
|
本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。
|
||||||
|
|
||||||
**每次对话都应遵循以下流程:**
|
### 对话开始时必须执行
|
||||||
|
|
||||||
### 1. 了解当前状态
|
1. 读取 `.blueprint/manifest.yaml` 了解项目全景和当前状态
|
||||||
对话开始时,先读取 `.blueprint/manifest.yaml` 了解项目模块和状态。
|
2. 读取 `.blueprint/CONVENTIONS.md` 了解完整工作流规则
|
||||||
|
|
||||||
### 2. 讨论需求时更新蓝图
|
### 需求讨论 → 蓝图同步规则
|
||||||
- 确认了新需求 → 在 manifest.yaml 新增模块,创建 `.blueprint/modules/<id>.md`
|
|
||||||
- 拆分了大需求 → 在模块文件中添加任务卡
|
|
||||||
- 调整了方向 → 更新模块关系(edges)
|
|
||||||
|
|
||||||
### 3. 完成实现后标记蓝图
|
| 用户说的 | Claude 应做的 |
|
||||||
- 完成了功能 → 更新对应任务卡状态为 `done`(前缀改为 ✅)
|
|----------|--------------|
|
||||||
- 更新 manifest.yaml 顶部的 `updated` 日期
|
| "我想加个 XX 功能" | 确认需求后:manifest 新增模块(status: concept),创建 `.blueprint/modules/<id>.md` |
|
||||||
|
| "XX 功能需要 A、B、C" | 在模块文件中拆分为任务卡 |
|
||||||
|
| "这个方案确定了" | 模块 status → planned,补充任务卡 files/acceptance |
|
||||||
|
| "开始做 XX" | 模块 status → in_progress |
|
||||||
|
| 没提蓝图但在讨论功能 | 主动提议更新蓝图 |
|
||||||
|
|
||||||
### 4. 任务卡规范
|
### 复杂度决策(讨论新功能 / 拆解需求时)
|
||||||
详见 `.blueprint/CONVENTIONS.md`,核心规则:
|
|
||||||
- 任务卡同时具备 `files` + `acceptance` + complexity `S/M` → 标记为 📋 可派发
|
| complexity | 执行流程 |
|
||||||
- 可派发的任务卡可以直接交给 Claude Agent 独立完成
|
|-----------|---------|
|
||||||
|
| S / M | 直接拆任务卡,确保每张卡有 `files` + `acceptance` + `complexity` → 标记 📋 |
|
||||||
|
| L(复杂系统设计) | 先 `/architect` 输出设计文档,用户确认后 `/splitter` 拆成 S/M 任务卡 |
|
||||||
|
|
||||||
|
> L 级任务禁止直接写代码,必须先经过 `/architect` → `/splitter` 流程。
|
||||||
|
|
||||||
|
### 任务执行两阶段提交
|
||||||
|
|
||||||
|
- **开始写代码前**:任务卡前缀改为 🔵,status → in_progress
|
||||||
|
- **完成验收后**:任务卡前缀改为 ✅,status → done,更新 manifest `updated` 日期
|
||||||
|
|
||||||
|
完整规则(迭代管理、阻塞处理、复盘笔记等)详见 `.blueprint/CONVENTIONS.md`。
|
||||||
<!-- BLUEPRINT_MANAGED_END -->
|
<!-- BLUEPRINT_MANAGED_END -->
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"devices": [
|
"devices": [
|
||||||
{
|
{
|
||||||
"installationId": "626cc0bf-3d4a-47d1-9fd9-50b31e88db04",
|
"installationId": "626cc0bf-3d4a-47d1-9fd9-50b31e88db04",
|
||||||
"lastUsed": 1775749877472
|
"lastUsed": 1775754747519
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,16 +18,7 @@
|
|||||||
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||||
<provider
|
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="unspecified">
|
||||||
android:name="androidx.core.content.FileProvider"
|
|
||||||
android:authorities="${applicationId}.provider"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/file_paths"/>
|
|
||||||
</provider>
|
|
||||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="unspecified">
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
"expo": "~52.0.0",
|
"expo": "~52.0.0",
|
||||||
"expo-build-properties": "^55.0.11",
|
"expo-build-properties": "^55.0.11",
|
||||||
"expo-dev-client": "~5.0.20",
|
"expo-dev-client": "~5.0.20",
|
||||||
|
"expo-document-picker": "~13.0.2",
|
||||||
"expo-file-system": "~18.0.0",
|
"expo-file-system": "~18.0.0",
|
||||||
"expo-image-picker": "~16.0.0",
|
"expo-image-picker": "~16.0.0",
|
||||||
"expo-linking": "^7.0.5",
|
"expo-linking": "^7.0.5",
|
||||||
|
|||||||
@ -39,7 +39,6 @@ export interface MapSize {
|
|||||||
interface FloorMapProps {
|
interface FloorMapProps {
|
||||||
floor: FloorNumber
|
floor: FloorNumber
|
||||||
markers: Marker[]
|
markers: Marker[]
|
||||||
markerItemCounts?: Record<number, number>
|
|
||||||
onMarkerPress: (marker: Marker) => void
|
onMarkerPress: (marker: Marker) => void
|
||||||
selectedMarkerId?: number
|
selectedMarkerId?: number
|
||||||
onMarkerDragEnd: (marker: Marker, x: number, y: number) => void
|
onMarkerDragEnd: (marker: Marker, x: number, y: number) => void
|
||||||
@ -49,7 +48,6 @@ interface FloorMapProps {
|
|||||||
export function FloorMap({
|
export function FloorMap({
|
||||||
floor,
|
floor,
|
||||||
markers,
|
markers,
|
||||||
markerItemCounts = {},
|
|
||||||
onMarkerPress,
|
onMarkerPress,
|
||||||
selectedMarkerId,
|
selectedMarkerId,
|
||||||
onMarkerDragEnd,
|
onMarkerDragEnd,
|
||||||
@ -203,13 +201,13 @@ export function FloorMap({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{markers.map((marker) => (
|
{markers.map((marker, index) => (
|
||||||
<MapMarker
|
<MapMarker
|
||||||
key={marker.id}
|
key={marker.id}
|
||||||
marker={marker}
|
marker={marker}
|
||||||
mapSize={mapSize}
|
mapSize={mapSize}
|
||||||
mapScale={scale}
|
mapScale={scale}
|
||||||
itemCount={markerItemCounts[marker.id] ?? 0}
|
seqNumber={index + 1}
|
||||||
isSelected={marker.id === selectedMarkerId}
|
isSelected={marker.id === selectedMarkerId}
|
||||||
onPress={() => onMarkerPress(marker)}
|
onPress={() => onMarkerPress(marker)}
|
||||||
onDragEnd={(x, y) => onMarkerDragEnd(marker, x, y)}
|
onDragEnd={(x, y) => onMarkerDragEnd(marker, x, y)}
|
||||||
|
|||||||
@ -24,13 +24,13 @@ interface MapMarkerProps {
|
|||||||
marker: Marker
|
marker: Marker
|
||||||
mapSize: MapSize
|
mapSize: MapSize
|
||||||
mapScale: SharedValue<number>
|
mapScale: SharedValue<number>
|
||||||
itemCount?: number
|
seqNumber: number
|
||||||
isSelected?: boolean
|
isSelected?: boolean
|
||||||
onPress: () => void
|
onPress: () => void
|
||||||
onDragEnd: (x: number, y: number) => void
|
onDragEnd: (x: number, y: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapMarker({ marker, mapSize, mapScale, itemCount = 0, isSelected = false, onPress, onDragEnd }: MapMarkerProps) {
|
export function MapMarker({ marker, mapSize, mapScale, seqNumber, isSelected = false, onPress, onDragEnd }: MapMarkerProps) {
|
||||||
const left = marker.x * mapSize.width - ICON_SIZE / 2
|
const left = marker.x * mapSize.width - ICON_SIZE / 2
|
||||||
const top = marker.y * mapSize.height - ICON_SIZE
|
const top = marker.y * mapSize.height - ICON_SIZE
|
||||||
|
|
||||||
@ -134,11 +134,9 @@ export function MapMarker({ marker, mapSize, mapScale, itemCount = 0, isSelected
|
|||||||
|
|
||||||
<Ionicons name="location" size={ICON_SIZE} color={markerColor} />
|
<Ionicons name="location" size={ICON_SIZE} color={markerColor} />
|
||||||
|
|
||||||
{itemCount > 0 && (
|
<View style={[styles.badge, { backgroundColor: markerColor }]}>
|
||||||
<View style={styles.badge}>
|
<Text style={styles.badgeText}>{seqNumber}</Text>
|
||||||
<Text style={styles.badgeText}>{itemCount}</Text>
|
</View>
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
)
|
)
|
||||||
@ -163,13 +161,14 @@ const styles = StyleSheet.create({
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: BADGE_OFFSET,
|
top: BADGE_OFFSET,
|
||||||
right: BADGE_OFFSET,
|
right: BADGE_OFFSET,
|
||||||
minWidth: 12,
|
minWidth: 13,
|
||||||
height: 12,
|
height: 13,
|
||||||
borderRadius: 6,
|
borderRadius: 7,
|
||||||
backgroundColor: '#EF4444',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
paddingHorizontal: 2,
|
paddingHorizontal: 2,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#fff',
|
||||||
},
|
},
|
||||||
badgeText: {
|
badgeText: {
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
|
|||||||
@ -85,9 +85,10 @@ export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey
|
|||||||
{markers.length === 0 && (
|
{markers.length === 0 && (
|
||||||
<Text style={styles.emptyHint}>长按地图{'\n'}添加标记</Text>
|
<Text style={styles.emptyHint}>长按地图{'\n'}添加标记</Text>
|
||||||
)}
|
)}
|
||||||
{markers.map((m) => {
|
{markers.map((m, index) => {
|
||||||
const isSelected = m.id === selectedMarkerId
|
const isSelected = m.id === selectedMarkerId
|
||||||
const dotColor = m.color ?? '#3B82F6'
|
const dotColor = m.color ?? '#3B82F6'
|
||||||
|
const seqNumber = index + 1
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={m.id}
|
key={m.id}
|
||||||
@ -121,11 +122,9 @@ export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{m.itemCount > 0 && (
|
<View style={[styles.countBadge, { backgroundColor: dotColor }]}>
|
||||||
<View style={[styles.countBadge, { backgroundColor: dotColor }]}>
|
<Text style={styles.countBadgeText}>{seqNumber}</Text>
|
||||||
<Text style={styles.countBadgeText}>{m.itemCount}</Text>
|
</View>
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -137,7 +136,6 @@ export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey
|
|||||||
<FloorMap
|
<FloorMap
|
||||||
floor={floor}
|
floor={floor}
|
||||||
markers={markers}
|
markers={markers}
|
||||||
markerItemCounts={Object.fromEntries(markers.map((m) => [m.id, m.itemCount]))}
|
|
||||||
onMarkerPress={onMarkerPress}
|
onMarkerPress={onMarkerPress}
|
||||||
selectedMarkerId={selectedMarkerId}
|
selectedMarkerId={selectedMarkerId}
|
||||||
onMarkerDragEnd={handleMarkerDragEnd}
|
onMarkerDragEnd={handleMarkerDragEnd}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Alert, StyleSheet, TouchableOpacity, View } from 'react-native'
|
import { StyleSheet, TouchableOpacity, View } from 'react-native'
|
||||||
import { Ionicons } from '@expo/vector-icons'
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
import { usePhotoCapture } from './usePhotoCapture'
|
import { usePhotoCapture } from './usePhotoCapture'
|
||||||
|
|
||||||
@ -10,26 +10,27 @@ interface PhotoPickerProps {
|
|||||||
export function PhotoPicker({ itemId, onAdded }: PhotoPickerProps) {
|
export function PhotoPicker({ itemId, onAdded }: PhotoPickerProps) {
|
||||||
const { capture } = usePhotoCapture(itemId, onAdded)
|
const { capture } = usePhotoCapture(itemId, onAdded)
|
||||||
|
|
||||||
function showPicker() {
|
|
||||||
Alert.alert('添加照片', '请选择来源', [
|
|
||||||
{ text: '拍照', onPress: () => setTimeout(() => capture('camera'), 300) },
|
|
||||||
{ text: '从相册选择', onPress: () => setTimeout(() => capture('library'), 300) },
|
|
||||||
{ text: '取消', style: 'cancel' },
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity style={styles.btn} onPress={showPicker} activeOpacity={0.7}>
|
<View style={styles.row}>
|
||||||
<Ionicons name="camera-outline" size={20} color="#9CA3AF" />
|
<TouchableOpacity style={styles.btn} onPress={() => capture('camera')} activeOpacity={0.7}>
|
||||||
</TouchableOpacity>
|
<Ionicons name="camera-outline" size={20} color="#6B7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.btn} onPress={() => capture('library')} activeOpacity={0.7}>
|
||||||
|
<Ionicons name="image-outline" size={20} color="#6B7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
btn: {
|
row: {
|
||||||
width: '100%',
|
flexDirection: 'row',
|
||||||
paddingVertical: 10,
|
gap: 6,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 10,
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
borderColor: '#D1D5DB',
|
borderColor: '#D1D5DB',
|
||||||
borderStyle: 'dashed',
|
borderStyle: 'dashed',
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import * as ImagePicker from 'expo-image-picker'
|
import * as ImagePicker from 'expo-image-picker'
|
||||||
|
import * as DocumentPicker from 'expo-document-picker'
|
||||||
import * as FileSystem from 'expo-file-system'
|
import * as FileSystem from 'expo-file-system'
|
||||||
import { Alert } from 'react-native'
|
import { Alert } from 'react-native'
|
||||||
import { insertPhoto } from '../../db/photos'
|
import { insertPhoto } from '../../db/photos'
|
||||||
@ -26,48 +27,43 @@ async function copyToAppDir(sourceUri: string): Promise<string> {
|
|||||||
export function usePhotoCapture(itemId: number, onAdded: () => void) {
|
export function usePhotoCapture(itemId: number, onAdded: () => void) {
|
||||||
const capture = useCallback(
|
const capture = useCallback(
|
||||||
async (source: 'camera' | 'library') => {
|
async (source: 'camera' | 'library') => {
|
||||||
let permissionResult: ImagePicker.PermissionResponse
|
|
||||||
|
|
||||||
if (source === 'camera') {
|
|
||||||
permissionResult = await ImagePicker.requestCameraPermissionsAsync()
|
|
||||||
} else {
|
|
||||||
permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!permissionResult.granted) {
|
|
||||||
Alert.alert(
|
|
||||||
'权限不足',
|
|
||||||
source === 'camera' ? '请在系统设置中开启相机权限' : '请在系统设置中开启相册权限',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const pickOptions: ImagePicker.ImagePickerOptions = {
|
|
||||||
mediaTypes: ImagePicker.MediaType.Images,
|
|
||||||
allowsEditing: false,
|
|
||||||
quality: JPEG_QUALITY,
|
|
||||||
exif: false,
|
|
||||||
base64: false,
|
|
||||||
allowsMultipleSelection: source === 'library',
|
|
||||||
}
|
|
||||||
|
|
||||||
const result =
|
|
||||||
source === 'camera'
|
|
||||||
? await ImagePicker.launchCameraAsync({ ...pickOptions, allowsMultipleSelection: false })
|
|
||||||
: await ImagePicker.launchImageLibraryAsync(pickOptions)
|
|
||||||
|
|
||||||
if (result.canceled) return
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const asset of result.assets) {
|
if (source === 'camera') {
|
||||||
const manipulated = await resizeImage(asset.uri, asset.width ?? MAX_WIDTH)
|
const permissionResult = await ImagePicker.requestCameraPermissionsAsync()
|
||||||
const filePath = await copyToAppDir(manipulated)
|
if (!permissionResult.granted) {
|
||||||
await insertPhoto({ itemId, filePath, sortOrder: Date.now() })
|
Alert.alert('权限不足', '请在系统设置中开启相机权限')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = await ImagePicker.launchCameraAsync({
|
||||||
|
mediaTypes: ['images'],
|
||||||
|
allowsEditing: false,
|
||||||
|
quality: JPEG_QUALITY,
|
||||||
|
exif: false,
|
||||||
|
base64: false,
|
||||||
|
allowsMultipleSelection: false,
|
||||||
|
})
|
||||||
|
if (result.canceled) return
|
||||||
|
for (const asset of result.assets) {
|
||||||
|
const filePath = await copyToAppDir(await resizeImage(asset.uri, asset.width ?? MAX_WIDTH))
|
||||||
|
await insertPhoto({ itemId, filePath, sortOrder: Date.now() })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 使用 document picker 绕过三星系统 Photo Picker 崩溃问题
|
||||||
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
|
type: 'image/*',
|
||||||
|
multiple: false,
|
||||||
|
copyToCacheDirectory: true,
|
||||||
|
})
|
||||||
|
if (result.canceled) return
|
||||||
|
for (const asset of result.assets) {
|
||||||
|
const filePath = await copyToAppDir(asset.uri)
|
||||||
|
await insertPhoto({ itemId, filePath, sortOrder: Date.now() })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onAdded()
|
onAdded()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('保存照片失败:', err)
|
Alert.alert('错误', String(err))
|
||||||
Alert.alert('错误', '照片保存失败,请重试')
|
console.error('添加照片失败:', err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[itemId, onAdded],
|
[itemId, onAdded],
|
||||||
|
|||||||
@ -1,12 +1,7 @@
|
|||||||
import { type ReactNode, useRef } from 'react'
|
import { type ReactNode } from 'react'
|
||||||
import { LayoutAnimation, Platform, StyleSheet, UIManager, View } from 'react-native'
|
import { StyleSheet, View } from 'react-native'
|
||||||
import { useFoldState } from './useFoldState'
|
import { useFoldState } from './useFoldState'
|
||||||
|
|
||||||
// Android 需要手动开启 LayoutAnimation 支持
|
|
||||||
if (Platform.OS === 'android') {
|
|
||||||
UIManager.setLayoutAnimationEnabledExperimental?.(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const DETAIL_PANEL_WIDTH = 260
|
const DETAIL_PANEL_WIDTH = 260
|
||||||
|
|
||||||
interface FoldableLayoutProps {
|
interface FoldableLayoutProps {
|
||||||
@ -18,13 +13,6 @@ interface FoldableLayoutProps {
|
|||||||
export function FoldableLayout({ left, right, showDetail = false }: FoldableLayoutProps) {
|
export function FoldableLayout({ left, right, showDetail = false }: FoldableLayoutProps) {
|
||||||
const { isUnfolded } = useFoldState()
|
const { isUnfolded } = useFoldState()
|
||||||
|
|
||||||
// 在 props 变化引起的 re-render 之前触发原生布局动画
|
|
||||||
const prevShowDetail = useRef(showDetail)
|
|
||||||
if (prevShowDetail.current !== showDetail) {
|
|
||||||
prevShowDetail.current = showDetail
|
|
||||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isUnfolded) {
|
if (!isUnfolded) {
|
||||||
return <View style={styles.single}>{left}</View>
|
return <View style={styles.single}>{left}</View>
|
||||||
}
|
}
|
||||||
@ -41,7 +29,13 @@ export function FoldableLayout({ left, right, showDetail = false }: FoldableLayo
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
single: { flex: 1 },
|
single: { flex: 1 },
|
||||||
dual: { flex: 1, flexDirection: 'row' },
|
dual: { flex: 1 },
|
||||||
mapPane: { flexGrow: 1 },
|
mapPane: { ...StyleSheet.absoluteFillObject },
|
||||||
detailPane: { width: DETAIL_PANEL_WIDTH },
|
detailPane: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: DETAIL_PANEL_WIDTH,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@ -31,6 +31,9 @@ importers:
|
|||||||
expo-dev-client:
|
expo-dev-client:
|
||||||
specifier: ~5.0.20
|
specifier: ~5.0.20
|
||||||
version: 5.0.20(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))
|
version: 5.0.20(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-document-picker:
|
||||||
|
specifier: ~13.0.2
|
||||||
|
version: 13.0.3(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-file-system:
|
expo-file-system:
|
||||||
specifier: ~18.0.0
|
specifier: ~18.0.0
|
||||||
version: 18.0.12(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))
|
version: 18.0.12(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))
|
||||||
@ -2107,6 +2110,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
expo: '*'
|
expo: '*'
|
||||||
|
|
||||||
|
expo-document-picker@13.0.3:
|
||||||
|
resolution: {integrity: sha512-348xcsiA/YhgWm1SuJNNdb5cUDpRJYCyIk8MhOU2MEDxbVRR+Q1TiUBTCIMVqaWHcxsFQzP56Wwv9n24qjeILg==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: '*'
|
||||||
|
|
||||||
expo-file-system@18.0.12:
|
expo-file-system@18.0.12:
|
||||||
resolution: {integrity: sha512-HAkrd/mb8r+G3lJ9MzmGeuW2B+BxQR1joKfeCyY4deLl1zoZ48FrAWjgZjHK9aHUVhJ0ehzInu/NQtikKytaeg==}
|
resolution: {integrity: sha512-HAkrd/mb8r+G3lJ9MzmGeuW2B+BxQR1joKfeCyY4deLl1zoZ48FrAWjgZjHK9aHUVhJ0ehzInu/NQtikKytaeg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -6749,6 +6757,10 @@ snapshots:
|
|||||||
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: 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-dev-menu-interface: 1.9.3(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-dev-menu-interface: 1.9.3(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-document-picker@13.0.3(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-file-system@18.0.12(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)):
|
expo-file-system@18.0.12(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)):
|
||||||
dependencies:
|
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: 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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user