feat: 汇总页新增聚合/分组视图与纯数据导出,实现地图侧栏聚合视图
- summary 新增明细/聚合/分组三 Tab,对应 SummaryAggregateList(楼层→分类) 和 SummaryGroupList(按物品名分组,可展开查看分布位置) - 新增 exportDataOnly() 仅导出 CSV 不含照片,header 新增两个导出按钮 - MarkerList 聚合 Tab 从 placeholder 升级为按颜色+分类分组的实际视图 - 蓝图新增 ai-recognize 模块(planned),引入 expo-image-manipulator / expo-secure-store 依赖为后续 AI 识别功能做准备 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1c6d491eaf
commit
e7ab0d1ef1
@ -110,12 +110,19 @@ modules:
|
|||||||
progress: 100
|
progress: 100
|
||||||
position: [980, 1080]
|
position: [980, 1080]
|
||||||
|
|
||||||
|
- id: ai-recognize
|
||||||
|
name: AI 照片识别填充表单
|
||||||
|
area: rn-app
|
||||||
|
status: planned
|
||||||
|
progress: 0
|
||||||
|
position: [980, 1280]
|
||||||
|
|
||||||
- id: pdf-report
|
- id: pdf-report
|
||||||
name: PDF 报告导出
|
name: PDF 报告导出
|
||||||
area: rn-app
|
area: rn-app
|
||||||
status: concept
|
status: concept
|
||||||
progress: 0
|
progress: 0
|
||||||
position: [980, 1280]
|
position: [980, 1480]
|
||||||
|
|
||||||
# ── 前端应用(Web,预留)──────────────────────────────────────
|
# ── 前端应用(Web,预留)──────────────────────────────────────
|
||||||
- id: app-scaffold
|
- id: app-scaffold
|
||||||
@ -224,6 +231,14 @@ edges:
|
|||||||
to: item-form
|
to: item-form
|
||||||
type: dependency
|
type: dependency
|
||||||
|
|
||||||
|
# AI 识别
|
||||||
|
- from: photo-capture
|
||||||
|
to: ai-recognize
|
||||||
|
type: dependency
|
||||||
|
- from: item-form
|
||||||
|
to: ai-recognize
|
||||||
|
type: dependency
|
||||||
|
|
||||||
# Web 前端(预留)
|
# Web 前端(预留)
|
||||||
- from: monorepo-setup
|
- from: monorepo-setup
|
||||||
to: app-scaffold
|
to: app-scaffold
|
||||||
|
|||||||
68
.blueprint/modules/ai-recognize.md
Normal file
68
.blueprint/modules/ai-recognize.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
id: ai-recognize
|
||||||
|
name: AI 照片识别填充表单
|
||||||
|
area: rn-app
|
||||||
|
status: planned
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
用户在物品登记表单中,选取当前物品的一张或多张照片,可附加文字补充描述,发送给通义千问 Qwen-VL-Max 进行多模态识别。AI 返回富文本描述后,用户在回复文本中框选任意片段,点击字段注入按钮将其填入表单对应字段(名称、规格、单价、单位、备注)。
|
||||||
|
|
||||||
|
## 技术选型
|
||||||
|
|
||||||
|
- **API**:通义千问 Qwen-VL-Max(阿里云灵积 DashScope,OpenAI 兼容格式)
|
||||||
|
- **多图策略**:多轮对话叠加(每轮可追加图片或文字)
|
||||||
|
- **选区注入**:TextInput selectable + lastSelectionRef(blur 安全)
|
||||||
|
- **图片压缩**:expo-image-manipulator(600px / 0.65 质量,约 60KB/张)
|
||||||
|
- **密钥存储**:expo-secure-store
|
||||||
|
|
||||||
|
## 新增依赖
|
||||||
|
|
||||||
|
```
|
||||||
|
expo-secure-store # API Key / access_token 加密存储
|
||||||
|
expo-image-manipulator # 识别前压缩图片
|
||||||
|
```
|
||||||
|
|
||||||
|
## 任务卡
|
||||||
|
|
||||||
|
### 📋 通义千问 API 服务层
|
||||||
|
- status: todo
|
||||||
|
- complexity: S
|
||||||
|
- files: apps/property-survey/src/services/qianfanAI.ts
|
||||||
|
- acceptance: 封装 DashScope OpenAI 兼容接口;getApiKey() / saveApiKey() 读写 SecureStore;chat(messages) 发送多轮对话(支持 image_url + text content 混合);token 不存在时抛出带中文提示的 Error;网络/鉴权失败统一抛出可读错误;导出 QianfanMessage 类型
|
||||||
|
|
||||||
|
### 📋 API Key 设置 UI
|
||||||
|
- status: todo
|
||||||
|
- complexity: S
|
||||||
|
- files: apps/property-survey/src/features/settings/QianfanApiSettings.tsx
|
||||||
|
- depends: ai-recognize
|
||||||
|
- acceptance: 含 API Key 输入框(secureTextEntry)和保存按钮;「测试连接」按钮调 qianfanAI.chat 发一条纯文字 ping,成功显示绿色"连通 ✓",失败显示红色错误原因;组件可独立渲染,供后续接入设置入口
|
||||||
|
|
||||||
|
### 📋 useAiRecognize hook
|
||||||
|
- status: todo
|
||||||
|
- complexity: M
|
||||||
|
- files: apps/property-survey/src/features/recognize/useAiRecognize.ts
|
||||||
|
- depends: ai-recognize
|
||||||
|
- acceptance: 接收 photos: Photo[] 参数;管理 selectedIds(togglePhoto 切换)、hint 文字、messages 历史、loading、response、error 状态;lastSelectionRef 仅在 end>start 时更新(blur 不清空);getSelectedText() 从 lastSelectionRef 提取 response 子串;recognize() 将选中照片 filePath 用 expo-image-manipulator 压缩后转 base64,拼入 messages,调 qianfanAI.chat,追加 AI 回复到 messages 并更新 response;未配置 API Key 时 throw 并在 error 中显示引导文字;支持多轮追加(再次调 recognize() 时保留 messages 历史)
|
||||||
|
|
||||||
|
### 📋 AI 识别面板 UI
|
||||||
|
- status: todo
|
||||||
|
- complexity: M
|
||||||
|
- files: apps/property-survey/src/features/recognize/AiRecognizePanel.tsx, apps/property-survey/src/features/recognize/PhotoSelector.tsx, apps/property-survey/src/features/recognize/InjectionToolbar.tsx
|
||||||
|
- depends: ai-recognize
|
||||||
|
- acceptance: AiRecognizePanel 为全屏 Modal;顶部 PhotoSelector 横向滚动展示照片缩略图,选中项右上角绿色勾;hint TextInput 单行(placeholder:"补充描述,如品牌型号…");「识别」按钮 loading 时显示 ActivityIndicator;response 区域用 multiline TextInput(editable=false,selectable=true,onSelectionChange 更新 hook);底部 InjectionToolbar 固定显示「→名称」「→规格」「→单价」「→单位」「→备注」5个按钮,点击时调 onInject(field, selectedText);selectedText 为空时按钮 disabled;注入成功后对应按钮短暂变绿(300ms);error 非空时在 response 区上方显示红色提示条
|
||||||
|
|
||||||
|
### 📋 表单接线
|
||||||
|
- status: todo
|
||||||
|
- complexity: S
|
||||||
|
- files: apps/property-survey/src/features/item/useItemForm.ts, apps/property-survey/src/features/item/ItemFields.tsx
|
||||||
|
- depends: ai-recognize
|
||||||
|
- acceptance: useItemForm 新增 injectField(key: keyof FormState, value: string) 方法(调 setField 并标记 isDirty);ItemFields 照片列顶部「照片」标题行右侧增加「✨ AI」文字按钮(仅 photoItemId 非空时显示);点击打开 AiRecognizePanel Modal,传入当前物品照片列表和 injectField 回调;注入后 Modal 保持打开,用户可继续框选其他字段
|
||||||
|
|
||||||
|
## 决策记录
|
||||||
|
|
||||||
|
- 选用通义千问 Qwen-VL-Max:阿里电商生态训练数据对家具/家电/品牌商品识别最准,且价格为同档位最低(约 0.003元/千token)
|
||||||
|
- 多轮对话而非一次性多图:允许用户逐步追加照片和追问,比一次提交更灵活
|
||||||
|
- TextInput selectable 方案而非分句 Chip:原生文本选择体验与系统一致,且支持任意粒度框选(如只选价格数字);用 lastSelectionRef 解决 blur 丢选区问题
|
||||||
|
- 图片压缩到 600px/0.65:实测约 55-70KB/张,3张约 200KB,在 4MB API 限制内且不明显影响识别准确度
|
||||||
@ -171,6 +171,99 @@
|
|||||||
"todo": 0,
|
"todo": 0,
|
||||||
"total": 54
|
"total": 54
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "2026-04-10T05:03:24Z",
|
||||||
|
"blocked_reasons": [],
|
||||||
|
"conventions_version": "1.4.3",
|
||||||
|
"delta": {
|
||||||
|
"newly_blocked": [],
|
||||||
|
"newly_done": [
|
||||||
|
"category-management/全局分类 Context",
|
||||||
|
"category-management/分类数据库表与 CRUD",
|
||||||
|
"category-management/设置页 UI(分类增删改)",
|
||||||
|
"floor-map/SVG 地图渲染(多层架构)",
|
||||||
|
"floor-map/SVG 地图生成脚本",
|
||||||
|
"floor-map/捏合缩放 + 单指平移手势",
|
||||||
|
"floor-map/楼层标记点数据加载与标记列表",
|
||||||
|
"floor-map/长按添加标记点",
|
||||||
|
"item-form/物品确认状态",
|
||||||
|
"local-data-layer/分类 CRUD 操作",
|
||||||
|
"local-data-layer/在线迁移脚本",
|
||||||
|
"photo-capture/Android 权限与 FileProvider 配置",
|
||||||
|
"photo-capture/全屏预览捏合缩放",
|
||||||
|
"photo-capture/摄像头调用与照片存储",
|
||||||
|
"photo-capture/查看模式照片面板(MarkerPhotoPanel)",
|
||||||
|
"photo-capture/照片网格预览与删除(物品表单内)",
|
||||||
|
"shared-types/核心业务类型",
|
||||||
|
"survey-app-shell/查看/编辑模式切换",
|
||||||
|
"survey-app-shell/查看模式照片面板集成"
|
||||||
|
],
|
||||||
|
"unblocked": []
|
||||||
|
},
|
||||||
|
"iteration": 2,
|
||||||
|
"modules": {
|
||||||
|
"blocked": 0,
|
||||||
|
"concept": 9,
|
||||||
|
"done": 9,
|
||||||
|
"in_progress": 0,
|
||||||
|
"planned": 0,
|
||||||
|
"stalled": [],
|
||||||
|
"total": 18
|
||||||
|
},
|
||||||
|
"task_ids": {
|
||||||
|
"blocked": [],
|
||||||
|
"done": [
|
||||||
|
"category-management/全局分类 Context",
|
||||||
|
"category-management/分类数据库表与 CRUD",
|
||||||
|
"category-management/设置页 UI(分类增删改)",
|
||||||
|
"floor-map/SVG 地图渲染(多层架构)",
|
||||||
|
"floor-map/SVG 地图生成脚本",
|
||||||
|
"floor-map/捏合缩放 + 单指平移手势",
|
||||||
|
"floor-map/标记点图标组件",
|
||||||
|
"floor-map/楼层标记点数据加载与标记列表",
|
||||||
|
"floor-map/长按添加标记点",
|
||||||
|
"inventory-summary/CSV 导出与系统分享",
|
||||||
|
"inventory-summary/导出照片关联索引",
|
||||||
|
"inventory-summary/汇总列表与筛选栏",
|
||||||
|
"inventory-summary/汇总数据查询 Hook",
|
||||||
|
"inventory-summary/统计汇总卡片",
|
||||||
|
"item-form/底部抽屉/右栏容器",
|
||||||
|
"item-form/标记点下物品列表",
|
||||||
|
"item-form/物品字段表单",
|
||||||
|
"item-form/物品确认状态",
|
||||||
|
"local-data-layer/SQLite 初始化与 Schema 建表",
|
||||||
|
"local-data-layer/分类 CRUD 操作",
|
||||||
|
"local-data-layer/在线迁移脚本",
|
||||||
|
"local-data-layer/标记点 CRUD 操作",
|
||||||
|
"local-data-layer/照片路径 CRUD 操作",
|
||||||
|
"local-data-layer/物品记录 CRUD 操作",
|
||||||
|
"monorepo-setup/初始化 pnpm workspace",
|
||||||
|
"monorepo-setup/统一 ESLint / Prettier 配置",
|
||||||
|
"monorepo-setup/统一 TypeScript 基础配置",
|
||||||
|
"photo-capture/Android 权限与 FileProvider 配置",
|
||||||
|
"photo-capture/全屏预览捏合缩放",
|
||||||
|
"photo-capture/摄像头调用与照片存储",
|
||||||
|
"photo-capture/查看模式照片面板(MarkerPhotoPanel)",
|
||||||
|
"photo-capture/照片网格预览与删除(物品表单内)",
|
||||||
|
"shared-types/搭建类型包骨架",
|
||||||
|
"shared-types/核心业务类型",
|
||||||
|
"survey-app-shell/Expo 应用骨架初始化",
|
||||||
|
"survey-app-shell/双栏/单栏自适应布局容器",
|
||||||
|
"survey-app-shell/折叠屏状态感知 Hook",
|
||||||
|
"survey-app-shell/查看/编辑模式切换",
|
||||||
|
"survey-app-shell/查看模式照片面板集成",
|
||||||
|
"survey-app-shell/楼层切换标签组件"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"blocked": 0,
|
||||||
|
"dispatched": 0,
|
||||||
|
"done": 40,
|
||||||
|
"in_progress": 0,
|
||||||
|
"todo": 2,
|
||||||
|
"total": 69
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -2,7 +2,7 @@
|
|||||||
"devices": [
|
"devices": [
|
||||||
{
|
{
|
||||||
"installationId": "626cc0bf-3d4a-47d1-9fd9-50b31e88db04",
|
"installationId": "626cc0bf-3d4a-47d1-9fd9-50b31e88db04",
|
||||||
"lastUsed": 1775756191145
|
"lastUsed": 1775806816251
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,35 @@
|
|||||||
import { Alert, StyleSheet, TouchableOpacity, View } from 'react-native'
|
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
|
||||||
import { Ionicons } from '@expo/vector-icons'
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
import { useSummaryData } from '../src/features/summary/useSummaryData'
|
import { useSummaryData } from '../src/features/summary/useSummaryData'
|
||||||
import { FilterBar } from '../src/features/summary/FilterBar'
|
import { FilterBar } from '../src/features/summary/FilterBar'
|
||||||
import { SummaryStats } from '../src/features/summary/SummaryStats'
|
import { SummaryStats } from '../src/features/summary/SummaryStats'
|
||||||
import { SummaryList } from '../src/features/summary/SummaryList'
|
import { SummaryList } from '../src/features/summary/SummaryList'
|
||||||
import { exportWithPhotos } from '../src/features/summary/exportData'
|
import { SummaryAggregateList } from '../src/features/summary/SummaryAggregateList'
|
||||||
|
import { SummaryGroupList } from '../src/features/summary/SummaryGroupList'
|
||||||
|
import { exportWithPhotos, exportDataOnly } from '../src/features/summary/exportData'
|
||||||
import type { FloorNumber } from '@repo/types'
|
import type { FloorNumber } from '@repo/types'
|
||||||
import { Stack, useFocusEffect } from 'expo-router'
|
import { Stack, useFocusEffect } from 'expo-router'
|
||||||
import { useCallback } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
|
|
||||||
|
type ViewMode = 'detail' | 'aggregate' | 'group'
|
||||||
|
|
||||||
export default function SummaryScreen() {
|
export default function SummaryScreen() {
|
||||||
const { rows, filter, setFilter, refresh } = useSummaryData()
|
const { rows, filter, setFilter, refresh } = useSummaryData()
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('detail')
|
||||||
|
|
||||||
useFocusEffect(useCallback(() => {
|
useFocusEffect(useCallback(() => {
|
||||||
refresh()
|
refresh()
|
||||||
}, []))
|
}, []))
|
||||||
|
|
||||||
async function handleExport() {
|
async function handleExportData() {
|
||||||
|
try {
|
||||||
|
await exportDataOnly(rows)
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert('导出失败', String(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExportWithPhotos() {
|
||||||
try {
|
try {
|
||||||
await exportWithPhotos(rows)
|
await exportWithPhotos(rows)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -29,14 +42,39 @@ export default function SummaryScreen() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
options={{
|
options={{
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity onPress={handleExport} style={styles.exportBtn}>
|
<View style={styles.headerButtons}>
|
||||||
|
<TouchableOpacity onPress={handleExportData} style={styles.exportBtn}>
|
||||||
|
<Ionicons name="document-outline" size={22} color="#3B82F6" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={handleExportWithPhotos} style={styles.exportBtn}>
|
||||||
<Ionicons name="share-outline" size={22} color="#10B981" />
|
<Ionicons name="share-outline" size={22} color="#10B981" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<SummaryStats rows={rows} />
|
<SummaryStats rows={rows} />
|
||||||
|
<View style={styles.tabRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.tab, viewMode === 'detail' && styles.tabActive]}
|
||||||
|
onPress={() => setViewMode('detail')}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tabText, viewMode === 'detail' && styles.tabTextActive]}>明细</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.tab, viewMode === 'aggregate' && styles.tabActive]}
|
||||||
|
onPress={() => setViewMode('aggregate')}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tabText, viewMode === 'aggregate' && styles.tabTextActive]}>聚合</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.tab, viewMode === 'group' && styles.tabActive]}
|
||||||
|
onPress={() => setViewMode('group')}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tabText, viewMode === 'group' && styles.tabTextActive]}>分组</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
<FilterBar
|
<FilterBar
|
||||||
floor={filter.floor}
|
floor={filter.floor}
|
||||||
keyword={filter.keyword}
|
keyword={filter.keyword}
|
||||||
@ -45,7 +83,9 @@ export default function SummaryScreen() {
|
|||||||
}
|
}
|
||||||
onKeywordChange={(keyword) => setFilter((f) => ({ ...f, keyword }))}
|
onKeywordChange={(keyword) => setFilter((f) => ({ ...f, keyword }))}
|
||||||
/>
|
/>
|
||||||
<SummaryList rows={rows} />
|
{viewMode === 'detail' && <SummaryList rows={rows} />}
|
||||||
|
{viewMode === 'aggregate' && <SummaryAggregateList rows={rows} />}
|
||||||
|
{viewMode === 'group' && <SummaryGroupList rows={rows} />}
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@ -53,5 +93,29 @@ export default function SummaryScreen() {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: '#fff' },
|
container: { flex: 1, backgroundColor: '#fff' },
|
||||||
|
headerButtons: { flexDirection: 'row', alignItems: 'center' },
|
||||||
exportBtn: { paddingHorizontal: 8 },
|
exportBtn: { paddingHorizontal: 8 },
|
||||||
|
tabRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
gap: 8,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: '#E5E7EB',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
},
|
||||||
|
tab: {
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#D1D5DB',
|
||||||
|
backgroundColor: '#F9FAFB',
|
||||||
|
},
|
||||||
|
tabActive: {
|
||||||
|
borderColor: '#10B981',
|
||||||
|
backgroundColor: '#ECFDF5',
|
||||||
|
},
|
||||||
|
tabText: { fontSize: 13, color: '#6B7280' },
|
||||||
|
tabTextActive: { color: '#065F46', fontWeight: '700' },
|
||||||
})
|
})
|
||||||
|
|||||||
@ -19,10 +19,12 @@
|
|||||||
"expo-dev-client": "~5.0.20",
|
"expo-dev-client": "~5.0.20",
|
||||||
"expo-document-picker": "~13.0.2",
|
"expo-document-picker": "~13.0.2",
|
||||||
"expo-file-system": "~18.0.0",
|
"expo-file-system": "~18.0.0",
|
||||||
|
"expo-image-manipulator": "^55.0.15",
|
||||||
"expo-image-picker": "~16.0.0",
|
"expo-image-picker": "~16.0.0",
|
||||||
"expo-linking": "^7.0.5",
|
"expo-linking": "^7.0.5",
|
||||||
"expo-media-library": "~16.0.0",
|
"expo-media-library": "~16.0.0",
|
||||||
"expo-router": "~4.0.0",
|
"expo-router": "~4.0.0",
|
||||||
|
"expo-secure-store": "^55.0.13",
|
||||||
"expo-sharing": "~12.0.0",
|
"expo-sharing": "~12.0.0",
|
||||||
"expo-sqlite": "~15.1.0",
|
"expo-sqlite": "~15.1.0",
|
||||||
"expo-status-bar": "~2.0.0",
|
"expo-status-bar": "~2.0.0",
|
||||||
|
|||||||
@ -6,10 +6,17 @@ import { getMarkersByFloor, updateMarker } from '../../db/markers'
|
|||||||
import { getItemsByMarker } from '../../db/items'
|
import { getItemsByMarker } from '../../db/items'
|
||||||
import { FloorMap } from './FloorMap'
|
import { FloorMap } from './FloorMap'
|
||||||
|
|
||||||
|
interface ItemSummary {
|
||||||
|
name: string
|
||||||
|
quantity: number
|
||||||
|
confirmedAt: string | null
|
||||||
|
category: string
|
||||||
|
}
|
||||||
|
|
||||||
interface MarkerWithItems extends Marker {
|
interface MarkerWithItems extends Marker {
|
||||||
itemCount: number
|
itemCount: number
|
||||||
itemNames: string[]
|
itemNames: string[]
|
||||||
itemSummaries: { name: string; quantity: number; confirmedAt: string | null }[]
|
itemSummaries: ItemSummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MarkerListProps {
|
interface MarkerListProps {
|
||||||
@ -22,6 +29,43 @@ interface MarkerListProps {
|
|||||||
|
|
||||||
type SidebarTab = 'detail' | 'aggregate'
|
type SidebarTab = 'detail' | 'aggregate'
|
||||||
|
|
||||||
|
interface AggItem { name: string; total: number }
|
||||||
|
interface AggGroup { color: string; category: string; groupSeqs: number[]; items: AggItem[] }
|
||||||
|
|
||||||
|
function buildAggregateGroups(markers: MarkerWithItems[]): AggGroup[] {
|
||||||
|
const qtyMap = new Map<string, Map<string, number>>()
|
||||||
|
const groupSeqMap = new Map<string, Set<number>>()
|
||||||
|
const order: string[] = []
|
||||||
|
|
||||||
|
for (const [mi, m] of markers.entries()) {
|
||||||
|
const color = m.color ?? '#3B82F6'
|
||||||
|
const seq = mi + 1
|
||||||
|
for (const s of m.itemSummaries) {
|
||||||
|
const key = `${color}\x00${s.category}`
|
||||||
|
if (!qtyMap.has(key)) {
|
||||||
|
qtyMap.set(key, new Map())
|
||||||
|
groupSeqMap.set(key, new Set())
|
||||||
|
order.push(key)
|
||||||
|
}
|
||||||
|
const nm = qtyMap.get(key)!
|
||||||
|
nm.set(s.name, (nm.get(s.name) ?? 0) + s.quantity)
|
||||||
|
groupSeqMap.get(key)!.add(seq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return order.map((key) => {
|
||||||
|
const sep = key.indexOf('\x00')
|
||||||
|
const color = key.slice(0, sep)
|
||||||
|
const category = key.slice(sep + 1)
|
||||||
|
return {
|
||||||
|
color,
|
||||||
|
category,
|
||||||
|
groupSeqs: Array.from(groupSeqMap.get(key)!),
|
||||||
|
items: Array.from(qtyMap.get(key)!.entries()).map(([name, total]) => ({ name, total })),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey }: MarkerListProps) {
|
export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey }: MarkerListProps) {
|
||||||
const [markers, setMarkers] = useState<MarkerWithItems[]>([])
|
const [markers, setMarkers] = useState<MarkerWithItems[]>([])
|
||||||
const [activeTab, setActiveTab] = useState<SidebarTab>('detail')
|
const [activeTab, setActiveTab] = useState<SidebarTab>('detail')
|
||||||
@ -37,7 +81,7 @@ export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey
|
|||||||
...m,
|
...m,
|
||||||
itemCount: markerItems.length,
|
itemCount: markerItems.length,
|
||||||
itemNames: markerItems.map((i) => i.name),
|
itemNames: markerItems.map((i) => i.name),
|
||||||
itemSummaries: markerItems.map((i) => ({ name: i.name, quantity: i.quantity, confirmedAt: i.confirmedAt })),
|
itemSummaries: markerItems.map((i) => ({ name: i.name, quantity: i.quantity, confirmedAt: i.confirmedAt, category: i.category })),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
setMarkers(withItems)
|
setMarkers(withItems)
|
||||||
@ -57,6 +101,15 @@ export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 统计每个物品名出现在几个标记中
|
||||||
|
const itemMarkerCount = new Map<string, number>()
|
||||||
|
for (const m of markers) {
|
||||||
|
const namesInMarker = new Set(m.itemSummaries.map((s) => s.name))
|
||||||
|
for (const name of namesInMarker) {
|
||||||
|
itemMarkerCount.set(name, (itemMarkerCount.get(name) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.sidebar}>
|
<View style={styles.sidebar}>
|
||||||
@ -70,16 +123,42 @@ export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey
|
|||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={[styles.tabText, activeTab === tab && styles.tabTextActive]}>
|
<Text style={[styles.tabText, activeTab === tab && styles.tabTextActive]}>
|
||||||
{tab === 'detail' ? '明细' : '聚合'}
|
{tab === 'detail' ? `明细 ${markers.length}` : '聚合'}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{activeTab === 'aggregate' ? (
|
{activeTab === 'aggregate' ? (
|
||||||
<View style={styles.aggregatePlaceholder}>
|
<ScrollView style={styles.sidebarScroll} showsVerticalScrollIndicator={false}>
|
||||||
<Text style={styles.emptyHint}>聚合视图{'\n'}开发中</Text>
|
{(() => {
|
||||||
|
const groups = buildAggregateGroups(markers)
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return <Text style={styles.emptyHint}>长按地图{'\n'}添加标记</Text>
|
||||||
|
}
|
||||||
|
return groups.map((g, gi) => (
|
||||||
|
<View key={gi} style={styles.aggGroup}>
|
||||||
|
<View style={styles.aggGroupHeader}>
|
||||||
|
<View style={[styles.dot, { backgroundColor: g.color }]} />
|
||||||
|
<Text style={styles.aggCatLabel} numberOfLines={1}>{g.category}</Text>
|
||||||
|
<View style={styles.aggSeqList}>
|
||||||
|
{g.groupSeqs.map((seq) => (
|
||||||
|
<View key={seq} style={[styles.aggSeqBadge, { backgroundColor: g.color }]}>
|
||||||
|
<Text style={styles.aggSeqText}>{seq}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{g.items.map((item) => (
|
||||||
|
<View key={item.name} style={styles.aggItemRow}>
|
||||||
|
<Text style={styles.aggItemName} numberOfLines={1}>{item.name}</Text>
|
||||||
|
<Text style={styles.aggItemQty}>×{item.total}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
})()}
|
||||||
|
</ScrollView>
|
||||||
) : (
|
) : (
|
||||||
<ScrollView style={styles.sidebarScroll} showsVerticalScrollIndicator={false}>
|
<ScrollView style={styles.sidebarScroll} showsVerticalScrollIndicator={false}>
|
||||||
{markers.length === 0 && (
|
{markers.length === 0 && (
|
||||||
@ -173,7 +252,49 @@ const styles = StyleSheet.create({
|
|||||||
tabText: { fontSize: 12, color: '#9CA3AF', fontWeight: '500' },
|
tabText: { fontSize: 12, color: '#9CA3AF', fontWeight: '500' },
|
||||||
tabTextActive: { color: '#10B981', fontWeight: '700' },
|
tabTextActive: { color: '#10B981', fontWeight: '700' },
|
||||||
sidebarScroll: { flex: 1 },
|
sidebarScroll: { flex: 1 },
|
||||||
aggregatePlaceholder: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
aggGroup: {
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: '#F3F4F6',
|
||||||
|
paddingBottom: 6,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
aggGroupHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 5,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 3,
|
||||||
|
},
|
||||||
|
aggCatLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#6B7280',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
aggItemRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingLeft: 23,
|
||||||
|
paddingVertical: 3,
|
||||||
|
},
|
||||||
|
aggItemName: { fontSize: 12, color: '#374151', flex: 1 },
|
||||||
|
aggItemQty: { fontSize: 12, fontWeight: '600', color: '#6B7280', marginLeft: 4 },
|
||||||
|
aggSeqList: { flexDirection: 'row', gap: 2 },
|
||||||
|
aggSeqBadge: {
|
||||||
|
minWidth: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 7,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 2,
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
aggSeqText: { color: '#fff', fontSize: 9, fontWeight: '700' },
|
||||||
|
|
||||||
emptyHint: {
|
emptyHint: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
|
|||||||
@ -0,0 +1,180 @@
|
|||||||
|
import { ScrollView, StyleSheet, Text, View } from 'react-native'
|
||||||
|
import type { SummaryRow } from './useSummaryData'
|
||||||
|
|
||||||
|
const FLOOR_BG: Record<number, string> = {
|
||||||
|
1: '#DBEAFE',
|
||||||
|
2: '#FEF3C7',
|
||||||
|
3: '#FCE7F3',
|
||||||
|
4: '#D1FAE5',
|
||||||
|
}
|
||||||
|
const FLOOR_FG: Record<number, string> = {
|
||||||
|
1: '#1D4ED8',
|
||||||
|
2: '#92400E',
|
||||||
|
3: '#9D174D',
|
||||||
|
4: '#065F46',
|
||||||
|
}
|
||||||
|
const FLOOR_LABEL: Record<number, string> = {
|
||||||
|
1: '1F',
|
||||||
|
2: '2F',
|
||||||
|
3: '3F',
|
||||||
|
4: '楼顶',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AggItem {
|
||||||
|
name: string
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AggCategory {
|
||||||
|
category: string
|
||||||
|
items: AggItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AggFloor {
|
||||||
|
floor: number
|
||||||
|
categories: AggCategory[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregate(rows: SummaryRow[]): AggFloor[] {
|
||||||
|
const floorMap = new Map<number, Map<string, Map<string, number>>>()
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!floorMap.has(row.floor)) floorMap.set(row.floor, new Map())
|
||||||
|
const catMap = floorMap.get(row.floor)!
|
||||||
|
if (!catMap.has(row.category)) catMap.set(row.category, new Map())
|
||||||
|
const nameMap = catMap.get(row.category)!
|
||||||
|
nameMap.set(row.name, (nameMap.get(row.name) ?? 0) + row.quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(floorMap.entries())
|
||||||
|
.sort(([a], [b]) => a - b)
|
||||||
|
.map(([floor, catMap]) => ({
|
||||||
|
floor,
|
||||||
|
categories: Array.from(catMap.entries()).map(([category, nameMap]) => ({
|
||||||
|
category,
|
||||||
|
items: Array.from(nameMap.entries()).map(([name, total]) => ({ name, total })),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rows: SummaryRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SummaryAggregateList({ rows }: Props) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Text style={styles.emptyText}>暂无登记记录</Text>
|
||||||
|
<Text style={styles.emptyHint}>在「登记」页长按平面图添加标记点开始登记</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const floors = aggregate(rows)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={styles.list}>
|
||||||
|
{floors.map((floorData) => {
|
||||||
|
const bg = FLOOR_BG[floorData.floor] ?? '#F3F4F6'
|
||||||
|
const fg = FLOOR_FG[floorData.floor] ?? '#374151'
|
||||||
|
const label = FLOOR_LABEL[floorData.floor] ?? `${floorData.floor}F`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={floorData.floor} style={styles.floorBlock}>
|
||||||
|
<View style={[styles.floorHeader, { backgroundColor: bg }]}>
|
||||||
|
<Text style={[styles.floorLabel, { color: fg }]}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{floorData.categories.map((catData) => (
|
||||||
|
<View key={catData.category}>
|
||||||
|
<View style={styles.catHeader}>
|
||||||
|
<View style={[styles.catDot, { backgroundColor: fg }]} />
|
||||||
|
<Text style={styles.catLabel}>{catData.category}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{catData.items.map((item) => (
|
||||||
|
<View key={item.name} style={styles.itemRow}>
|
||||||
|
<Text style={styles.itemName} numberOfLines={1}>{item.name}</Text>
|
||||||
|
<Text style={styles.itemQty}>× {item.total}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
list: { paddingBottom: 80 },
|
||||||
|
empty: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingTop: 100 },
|
||||||
|
emptyText: { fontSize: 16, fontWeight: '600', color: '#6B7280' },
|
||||||
|
emptyHint: { fontSize: 13, color: '#9CA3AF', marginTop: 8, textAlign: 'center', paddingHorizontal: 40 },
|
||||||
|
|
||||||
|
floorBlock: {
|
||||||
|
marginTop: 8,
|
||||||
|
marginHorizontal: 12,
|
||||||
|
borderRadius: 10,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
},
|
||||||
|
|
||||||
|
floorHeader: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 9,
|
||||||
|
},
|
||||||
|
floorLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
|
||||||
|
catHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingTop: 10,
|
||||||
|
paddingBottom: 4,
|
||||||
|
},
|
||||||
|
catDot: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
catLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#6B7280',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
},
|
||||||
|
|
||||||
|
itemRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderTopColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
itemName: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#111827',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
itemQty: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
})
|
||||||
228
apps/property-survey/src/features/summary/SummaryGroupList.tsx
Normal file
228
apps/property-survey/src/features/summary/SummaryGroupList.tsx
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
|
||||||
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
|
import type { SummaryRow } from './useSummaryData'
|
||||||
|
|
||||||
|
const FLOOR_BG: Record<number, string> = {
|
||||||
|
1: '#DBEAFE', 2: '#FEF3C7', 3: '#FCE7F3', 4: '#D1FAE5',
|
||||||
|
}
|
||||||
|
const FLOOR_FG: Record<number, string> = {
|
||||||
|
1: '#1D4ED8', 2: '#92400E', 3: '#9D174D', 4: '#065F46',
|
||||||
|
}
|
||||||
|
const CATEGORY_LABEL: Record<string, string> = {
|
||||||
|
furniture: '家具', appliance: '家电', electronics: '电子', vehicle: '车辆', other: '其他',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NameGroup {
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
totalQuantity: number
|
||||||
|
totalPhotos: number
|
||||||
|
rows: SummaryRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByName(rows: SummaryRow[]): NameGroup[] {
|
||||||
|
const map = new Map<string, NameGroup>()
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = row.name
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, { name: row.name, category: row.category, totalQuantity: 0, totalPhotos: 0, rows: [] })
|
||||||
|
}
|
||||||
|
const g = map.get(key)!
|
||||||
|
g.totalQuantity += row.quantity
|
||||||
|
g.totalPhotos += row.photoCount
|
||||||
|
g.rows.push(row)
|
||||||
|
}
|
||||||
|
return Array.from(map.values()).sort((a, b) => b.totalQuantity - a.totalQuantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rows: SummaryRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SummaryGroupList({ rows }: Props) {
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Text style={styles.emptyText}>暂无登记记录</Text>
|
||||||
|
<Text style={styles.emptyHint}>在「登记」页长按平面图添加标记点开始登记</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = groupByName(rows)
|
||||||
|
|
||||||
|
function toggleExpand(name: string) {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.has(name) ? next.delete(name) : next.add(name)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
data={groups}
|
||||||
|
keyExtractor={(g) => g.name}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
renderItem={({ item: group }) => {
|
||||||
|
const isOpen = expanded.has(group.name)
|
||||||
|
// 统计出现的楼层(去重、排序)
|
||||||
|
const floors = [...new Set(group.rows.map((r) => r.floor))].sort()
|
||||||
|
const locationCount = group.rows.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.card}>
|
||||||
|
{/* 分组标题行 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.groupHeader}
|
||||||
|
onPress={() => toggleExpand(group.name)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.groupLeft}>
|
||||||
|
<Text style={styles.groupName} numberOfLines={1}>{group.name}</Text>
|
||||||
|
<View style={styles.groupMeta}>
|
||||||
|
<View style={styles.catBadge}>
|
||||||
|
<Text style={styles.catBadgeText}>{CATEGORY_LABEL[group.category] ?? group.category}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.metaText}>{locationCount} 处</Text>
|
||||||
|
<Text style={styles.metaDot}>·</Text>
|
||||||
|
<View style={styles.floorTags}>
|
||||||
|
{floors.map((f) => (
|
||||||
|
<View key={f} style={[styles.floorTag, { backgroundColor: FLOOR_BG[f] ?? '#F3F4F6' }]}>
|
||||||
|
<Text style={[styles.floorTagText, { color: FLOOR_FG[f] ?? '#374151' }]}>
|
||||||
|
{f === 4 ? '顶' : `${f}F`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.groupRight}>
|
||||||
|
<Text style={styles.totalQty}>{group.totalQuantity}</Text>
|
||||||
|
<Text style={styles.totalQtyLabel}>件</Text>
|
||||||
|
<Ionicons
|
||||||
|
name={isOpen ? 'chevron-up' : 'chevron-down'}
|
||||||
|
size={14}
|
||||||
|
color="#9CA3AF"
|
||||||
|
style={{ marginLeft: 8 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* 展开的明细行 */}
|
||||||
|
{isOpen && (
|
||||||
|
<View style={styles.detailBlock}>
|
||||||
|
{group.rows.map((row) => (
|
||||||
|
<View key={row.itemId} style={styles.detailRow}>
|
||||||
|
<View style={[styles.floorBadge, { backgroundColor: FLOOR_BG[row.floor] ?? '#F3F4F6' }]}>
|
||||||
|
<Text style={[styles.floorBadgeText, { color: FLOOR_FG[row.floor] ?? '#374151' }]}>
|
||||||
|
{row.floor === 4 ? '顶' : `${row.floor}F`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.detailContent}>
|
||||||
|
<Text style={styles.markerLabel} numberOfLines={1}>
|
||||||
|
{row.markerLabel || '—'}
|
||||||
|
</Text>
|
||||||
|
{!!row.remark && (
|
||||||
|
<Text style={styles.remarkText} numberOfLines={1}>{row.remark}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.detailRight}>
|
||||||
|
<Text style={styles.detailQty}>× {row.quantity}</Text>
|
||||||
|
{row.photoCount > 0 && (
|
||||||
|
<View style={styles.photoInfo}>
|
||||||
|
<Ionicons name="image-outline" size={11} color="#9CA3AF" />
|
||||||
|
<Text style={styles.photoCount}>{row.photoCount}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
list: { paddingVertical: 8, paddingHorizontal: 12, paddingBottom: 80 },
|
||||||
|
empty: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingTop: 100 },
|
||||||
|
emptyText: { fontSize: 16, fontWeight: '600', color: '#6B7280' },
|
||||||
|
emptyHint: { fontSize: 13, color: '#9CA3AF', marginTop: 8, textAlign: 'center', paddingHorizontal: 40 },
|
||||||
|
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: 10,
|
||||||
|
marginBottom: 8,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
|
||||||
|
groupHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
groupLeft: { flex: 1 },
|
||||||
|
groupName: { fontSize: 15, fontWeight: '700', color: '#111827', marginBottom: 5 },
|
||||||
|
groupMeta: { flexDirection: 'row', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
|
||||||
|
catBadge: {
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
borderRadius: 4,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
},
|
||||||
|
catBadgeText: { fontSize: 11, color: '#6B7280', fontWeight: '500' },
|
||||||
|
metaText: { fontSize: 12, color: '#6B7280' },
|
||||||
|
metaDot: { fontSize: 12, color: '#D1D5DB' },
|
||||||
|
floorTags: { flexDirection: 'row', gap: 4 },
|
||||||
|
floorTag: {
|
||||||
|
borderRadius: 4,
|
||||||
|
paddingHorizontal: 5,
|
||||||
|
paddingVertical: 2,
|
||||||
|
},
|
||||||
|
floorTagText: { fontSize: 11, fontWeight: '700' },
|
||||||
|
|
||||||
|
groupRight: { flexDirection: 'row', alignItems: 'baseline', gap: 2 },
|
||||||
|
totalQty: { fontSize: 22, fontWeight: '700', color: '#111827' },
|
||||||
|
totalQtyLabel: { fontSize: 12, color: '#6B7280' },
|
||||||
|
|
||||||
|
detailBlock: {
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderTopColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
detailRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 9,
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderTopColor: '#F9FAFB',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
floorBadge: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 6,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
floorBadgeText: { fontSize: 11, fontWeight: '700' },
|
||||||
|
detailContent: { flex: 1 },
|
||||||
|
markerLabel: { fontSize: 13, color: '#374151', fontWeight: '500' },
|
||||||
|
remarkText: { fontSize: 12, color: '#9CA3AF', marginTop: 1 },
|
||||||
|
detailRight: { alignItems: 'flex-end', gap: 3 },
|
||||||
|
detailQty: { fontSize: 13, fontWeight: '600', color: '#374151' },
|
||||||
|
photoInfo: { flexDirection: 'row', alignItems: 'center', gap: 2 },
|
||||||
|
photoCount: { fontSize: 11, color: '#9CA3AF' },
|
||||||
|
})
|
||||||
@ -45,6 +45,7 @@ export function SummaryStats({ rows }: SummaryStatsProps) {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
scroll: {
|
scroll: {
|
||||||
backgroundColor: '#F0FDF4',
|
backgroundColor: '#F0FDF4',
|
||||||
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
container: {
|
container: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -53,7 +54,7 @@ const styles = StyleSheet.create({
|
|||||||
gap: 8,
|
gap: 8,
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
width: 100,
|
paddingHorizontal: 14,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
|
|||||||
@ -38,6 +38,44 @@ async function getPhotoPathsByItem(itemId: number): Promise<string[]> {
|
|||||||
return rows.map((r) => r.filePath)
|
return rows.map((r) => r.filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 仅导出 CSV 数据(不含照片)
|
||||||
|
export async function exportDataOnly(rows: SummaryRow[]): Promise<void> {
|
||||||
|
const canShare = await Sharing.isAvailableAsync()
|
||||||
|
if (!canShare) throw new Error('当前设备不支持分享功能')
|
||||||
|
|
||||||
|
const DATA_HEADERS = ['楼层', '标记点名', '物品名', '类别', '数量', '单价', '评估参考价格', '备注', '照片数']
|
||||||
|
|
||||||
|
const csvLines = [
|
||||||
|
DATA_HEADERS.join(','),
|
||||||
|
...rows.map((r) =>
|
||||||
|
[
|
||||||
|
`${r.floor}F`,
|
||||||
|
r.markerLabel || '—',
|
||||||
|
r.name,
|
||||||
|
r.category,
|
||||||
|
r.quantity,
|
||||||
|
r.estimatedValue > 0 ? r.estimatedValue : '',
|
||||||
|
r.referencePrice > 0 ? r.referencePrice : '',
|
||||||
|
r.remark,
|
||||||
|
r.photoCount,
|
||||||
|
]
|
||||||
|
.map(escapeCsv)
|
||||||
|
.join(','),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
const csvContent = BOM + csvLines.join('\r\n')
|
||||||
|
const csvPath = `${FileSystem.cacheDirectory}不动产摸底_${formatDate(new Date())}.csv`
|
||||||
|
await FileSystem.writeAsStringAsync(csvPath, csvContent, {
|
||||||
|
encoding: FileSystem.EncodingType.UTF8,
|
||||||
|
})
|
||||||
|
|
||||||
|
await Sharing.shareAsync(csvPath, {
|
||||||
|
mimeType: 'text/csv',
|
||||||
|
dialogTitle: '导出摸底数据(仅数据)',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function exportWithPhotos(rows: SummaryRow[]): Promise<void> {
|
export async function exportWithPhotos(rows: SummaryRow[]): Promise<void> {
|
||||||
const canShare = await Sharing.isAvailableAsync()
|
const canShare = await Sharing.isAvailableAsync()
|
||||||
if (!canShare) throw new Error('当前设备不支持分享功能')
|
if (!canShare) throw new Error('当前设备不支持分享功能')
|
||||||
|
|||||||
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
@ -37,6 +37,9 @@ importers:
|
|||||||
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))
|
||||||
|
expo-image-manipulator:
|
||||||
|
specifier: ^55.0.15
|
||||||
|
version: 55.0.15(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-image-picker:
|
expo-image-picker:
|
||||||
specifier: ~16.0.0
|
specifier: ~16.0.0
|
||||||
version: 16.0.6(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: 16.0.6(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))
|
||||||
@ -49,6 +52,9 @@ importers:
|
|||||||
expo-router:
|
expo-router:
|
||||||
specifier: ~4.0.0
|
specifier: ~4.0.0
|
||||||
version: 4.0.22(2up5cuuap7dudmj6aangd7ypjy)
|
version: 4.0.22(2up5cuuap7dudmj6aangd7ypjy)
|
||||||
|
expo-secure-store:
|
||||||
|
specifier: ^55.0.13
|
||||||
|
version: 55.0.13(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-sharing:
|
expo-sharing:
|
||||||
specifier: ~12.0.0
|
specifier: ~12.0.0
|
||||||
version: 12.0.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))
|
version: 12.0.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))
|
||||||
@ -2132,6 +2138,16 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
expo: '*'
|
expo: '*'
|
||||||
|
|
||||||
|
expo-image-loader@55.0.0:
|
||||||
|
resolution: {integrity: sha512-NOjp56wDrfuA5aiNAybBIjqIn1IxKeGJ8CECWZncQ/GzjZfyTYAHTCyeApYkdKkMBLHINzI4BbTGSlbCa0fXXQ==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: '*'
|
||||||
|
|
||||||
|
expo-image-manipulator@55.0.15:
|
||||||
|
resolution: {integrity: sha512-AbwC1PHhoJb7OeMlbb52RK/nsTZMAuJDz2RlbDHtrD6zFOKV1LcMomdJqVYCC5v7ILH5qLF01iygpZt54GlydQ==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: '*'
|
||||||
|
|
||||||
expo-image-picker@16.0.6:
|
expo-image-picker@16.0.6:
|
||||||
resolution: {integrity: sha512-HN4xZirFjsFDIsWFb12AZh19fRzuvZjj2ll17cGr19VNRP06S/VPQU3Tdccn5vwUzQhOBlLu704CnNm278boiQ==}
|
resolution: {integrity: sha512-HN4xZirFjsFDIsWFb12AZh19fRzuvZjj2ll17cGr19VNRP06S/VPQU3Tdccn5vwUzQhOBlLu704CnNm278boiQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -2188,6 +2204,11 @@ packages:
|
|||||||
react-native-reanimated:
|
react-native-reanimated:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
expo-secure-store@55.0.13:
|
||||||
|
resolution: {integrity: sha512-I6r0JNO1Fd4o0Gu7Ixiic7s89lqgdUHq17uBH9y1f/AntoyKn71TdtYJH82RgfsBbu5qNVzrwImmvlANyOlITQ==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: '*'
|
||||||
|
|
||||||
expo-sharing@12.0.1:
|
expo-sharing@12.0.1:
|
||||||
resolution: {integrity: sha512-wBT+WeXwapj/9NWuLJO01vi9bdlchYu/Q/xD8slL/Ls4vVYku8CPqzkTtDFcjLrjtlJqyeHsdQXwKLvORmBIew==}
|
resolution: {integrity: sha512-wBT+WeXwapj/9NWuLJO01vi9bdlchYu/Q/xD8slL/Ls4vVYku8CPqzkTtDFcjLrjtlJqyeHsdQXwKLvORmBIew==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -6777,6 +6798,15 @@ snapshots:
|
|||||||
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)
|
||||||
|
|
||||||
|
expo-image-loader@55.0.0(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-image-manipulator@55.0.15(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-image-loader: 55.0.0(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-image-picker@16.0.6(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-image-picker@16.0.6(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:
|
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)
|
||||||
@ -6855,6 +6885,10 @@ snapshots:
|
|||||||
- react-native
|
- react-native
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
expo-secure-store@55.0.13(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-sharing@12.0.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-sharing@12.0.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)):
|
||||||
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