From e7ab0d1ef181698e4554f6a765a68f3a39af9da8 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Fri, 10 Apr 2026 18:02:22 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B1=87=E6=80=BB=E9=A1=B5=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E8=81=9A=E5=90=88/=E5=88=86=E7=BB=84=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E4=B8=8E=E7=BA=AF=E6=95=B0=E6=8D=AE=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=EF=BC=8C=E5=AE=9E=E7=8E=B0=E5=9C=B0=E5=9B=BE=E4=BE=A7=E6=A0=8F?= =?UTF-8?q?=E8=81=9A=E5=90=88=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .blueprint/manifest.yaml | 17 +- .blueprint/modules/ai-recognize.md | 68 ++++++ .blueprint/usage.json | 93 +++++++ apps/property-survey/.expo/devices.json | 2 +- apps/property-survey/app/summary.tsx | 80 +++++- apps/property-survey/package.json | 2 + .../src/features/map/MarkerList.tsx | 135 ++++++++++- .../features/summary/SummaryAggregateList.tsx | 180 ++++++++++++++ .../src/features/summary/SummaryGroupList.tsx | 228 ++++++++++++++++++ .../src/features/summary/SummaryStats.tsx | 3 +- .../src/features/summary/exportData.ts | 38 +++ pnpm-lock.yaml | 34 +++ 12 files changed, 862 insertions(+), 18 deletions(-) create mode 100644 .blueprint/modules/ai-recognize.md create mode 100644 apps/property-survey/src/features/summary/SummaryAggregateList.tsx create mode 100644 apps/property-survey/src/features/summary/SummaryGroupList.tsx diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 733d5cb..2b2180a 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -110,12 +110,19 @@ modules: progress: 100 position: [980, 1080] + - id: ai-recognize + name: AI 照片识别填充表单 + area: rn-app + status: planned + progress: 0 + position: [980, 1280] + - id: pdf-report name: PDF 报告导出 area: rn-app status: concept progress: 0 - position: [980, 1280] + position: [980, 1480] # ── 前端应用(Web,预留)────────────────────────────────────── - id: app-scaffold @@ -224,6 +231,14 @@ edges: to: item-form type: dependency + # AI 识别 + - from: photo-capture + to: ai-recognize + type: dependency + - from: item-form + to: ai-recognize + type: dependency + # Web 前端(预留) - from: monorepo-setup to: app-scaffold diff --git a/.blueprint/modules/ai-recognize.md b/.blueprint/modules/ai-recognize.md new file mode 100644 index 0000000..850b0ac --- /dev/null +++ b/.blueprint/modules/ai-recognize.md @@ -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 限制内且不明显影响识别准确度 diff --git a/.blueprint/usage.json b/.blueprint/usage.json index ac5a5f9..be6a2c0 100644 --- a/.blueprint/usage.json +++ b/.blueprint/usage.json @@ -171,6 +171,99 @@ "todo": 0, "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 + } } ] } \ No newline at end of file diff --git a/apps/property-survey/.expo/devices.json b/apps/property-survey/.expo/devices.json index 30c6aa6..98bff93 100644 --- a/apps/property-survey/.expo/devices.json +++ b/apps/property-survey/.expo/devices.json @@ -2,7 +2,7 @@ "devices": [ { "installationId": "626cc0bf-3d4a-47d1-9fd9-50b31e88db04", - "lastUsed": 1775756191145 + "lastUsed": 1775806816251 } ] } diff --git a/apps/property-survey/app/summary.tsx b/apps/property-survey/app/summary.tsx index a38dd4e..cb8d867 100644 --- a/apps/property-survey/app/summary.tsx +++ b/apps/property-survey/app/summary.tsx @@ -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 { useSummaryData } from '../src/features/summary/useSummaryData' import { FilterBar } from '../src/features/summary/FilterBar' import { SummaryStats } from '../src/features/summary/SummaryStats' 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 { Stack, useFocusEffect } from 'expo-router' -import { useCallback } from 'react' +import { useCallback, useState } from 'react' + +type ViewMode = 'detail' | 'aggregate' | 'group' export default function SummaryScreen() { const { rows, filter, setFilter, refresh } = useSummaryData() + const [viewMode, setViewMode] = useState('detail') useFocusEffect(useCallback(() => { refresh() }, [])) - async function handleExport() { + async function handleExportData() { + try { + await exportDataOnly(rows) + } catch (err) { + Alert.alert('导出失败', String(err)) + } + } + + async function handleExportWithPhotos() { try { await exportWithPhotos(rows) } catch (err) { @@ -29,14 +42,39 @@ export default function SummaryScreen() { ( - - - + + + + + + + + ), }} /> + + setViewMode('detail')} + > + 明细 + + setViewMode('aggregate')} + > + 聚合 + + setViewMode('group')} + > + 分组 + + setFilter((f) => ({ ...f, keyword }))} /> - + {viewMode === 'detail' && } + {viewMode === 'aggregate' && } + {viewMode === 'group' && } ) @@ -53,5 +93,29 @@ export default function SummaryScreen() { const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#fff' }, + headerButtons: { flexDirection: 'row', alignItems: 'center' }, 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' }, }) diff --git a/apps/property-survey/package.json b/apps/property-survey/package.json index a6ca99b..4b26a9e 100644 --- a/apps/property-survey/package.json +++ b/apps/property-survey/package.json @@ -19,10 +19,12 @@ "expo-dev-client": "~5.0.20", "expo-document-picker": "~13.0.2", "expo-file-system": "~18.0.0", + "expo-image-manipulator": "^55.0.15", "expo-image-picker": "~16.0.0", "expo-linking": "^7.0.5", "expo-media-library": "~16.0.0", "expo-router": "~4.0.0", + "expo-secure-store": "^55.0.13", "expo-sharing": "~12.0.0", "expo-sqlite": "~15.1.0", "expo-status-bar": "~2.0.0", diff --git a/apps/property-survey/src/features/map/MarkerList.tsx b/apps/property-survey/src/features/map/MarkerList.tsx index daef923..3ca3f6d 100644 --- a/apps/property-survey/src/features/map/MarkerList.tsx +++ b/apps/property-survey/src/features/map/MarkerList.tsx @@ -6,10 +6,17 @@ import { getMarkersByFloor, updateMarker } from '../../db/markers' import { getItemsByMarker } from '../../db/items' import { FloorMap } from './FloorMap' +interface ItemSummary { + name: string + quantity: number + confirmedAt: string | null + category: string +} + interface MarkerWithItems extends Marker { itemCount: number itemNames: string[] - itemSummaries: { name: string; quantity: number; confirmedAt: string | null }[] + itemSummaries: ItemSummary[] } interface MarkerListProps { @@ -22,6 +29,43 @@ interface MarkerListProps { 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>() + const groupSeqMap = new Map>() + 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) { const [markers, setMarkers] = useState([]) const [activeTab, setActiveTab] = useState('detail') @@ -37,7 +81,7 @@ export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey ...m, itemCount: markerItems.length, 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) @@ -57,6 +101,15 @@ export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey } } + // 统计每个物品名出现在几个标记中 + const itemMarkerCount = new Map() + 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 ( @@ -70,16 +123,42 @@ export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey activeOpacity={0.7} > - {tab === 'detail' ? '明细' : '聚合'} + {tab === 'detail' ? `明细 ${markers.length}` : '聚合'} ))} {activeTab === 'aggregate' ? ( - - 聚合视图{'\n'}开发中 - + + {(() => { + const groups = buildAggregateGroups(markers) + if (groups.length === 0) { + return 长按地图{'\n'}添加标记 + } + return groups.map((g, gi) => ( + + + + {g.category} + + {g.groupSeqs.map((seq) => ( + + {seq} + + ))} + + + {g.items.map((item) => ( + + {item.name} + ×{item.total} + + ))} + + )) + })()} + ) : ( {markers.length === 0 && ( @@ -173,7 +252,49 @@ const styles = StyleSheet.create({ tabText: { fontSize: 12, color: '#9CA3AF', fontWeight: '500' }, tabTextActive: { color: '#10B981', fontWeight: '700' }, 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: { fontSize: 11, diff --git a/apps/property-survey/src/features/summary/SummaryAggregateList.tsx b/apps/property-survey/src/features/summary/SummaryAggregateList.tsx new file mode 100644 index 0000000..59d480d --- /dev/null +++ b/apps/property-survey/src/features/summary/SummaryAggregateList.tsx @@ -0,0 +1,180 @@ +import { ScrollView, StyleSheet, Text, View } from 'react-native' +import type { SummaryRow } from './useSummaryData' + +const FLOOR_BG: Record = { + 1: '#DBEAFE', + 2: '#FEF3C7', + 3: '#FCE7F3', + 4: '#D1FAE5', +} +const FLOOR_FG: Record = { + 1: '#1D4ED8', + 2: '#92400E', + 3: '#9D174D', + 4: '#065F46', +} +const FLOOR_LABEL: Record = { + 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>>() + + 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 ( + + 暂无登记记录 + 在「登记」页长按平面图添加标记点开始登记 + + ) + } + + const floors = aggregate(rows) + + return ( + + {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 ( + + + {label} + + + {floorData.categories.map((catData) => ( + + + + {catData.category} + + + {catData.items.map((item) => ( + + {item.name} + × {item.total} + + ))} + + ))} + + ) + })} + + ) +} + +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, + }, +}) diff --git a/apps/property-survey/src/features/summary/SummaryGroupList.tsx b/apps/property-survey/src/features/summary/SummaryGroupList.tsx new file mode 100644 index 0000000..5a6e5c0 --- /dev/null +++ b/apps/property-survey/src/features/summary/SummaryGroupList.tsx @@ -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 = { + 1: '#DBEAFE', 2: '#FEF3C7', 3: '#FCE7F3', 4: '#D1FAE5', +} +const FLOOR_FG: Record = { + 1: '#1D4ED8', 2: '#92400E', 3: '#9D174D', 4: '#065F46', +} +const CATEGORY_LABEL: Record = { + 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() + 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>(new Set()) + + if (rows.length === 0) { + return ( + + 暂无登记记录 + 在「登记」页长按平面图添加标记点开始登记 + + ) + } + + 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 ( + 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 ( + + {/* 分组标题行 */} + toggleExpand(group.name)} + activeOpacity={0.7} + > + + {group.name} + + + {CATEGORY_LABEL[group.category] ?? group.category} + + {locationCount} 处 + · + + {floors.map((f) => ( + + + {f === 4 ? '顶' : `${f}F`} + + + ))} + + + + + + {group.totalQuantity} + + + + + + {/* 展开的明细行 */} + {isOpen && ( + + {group.rows.map((row) => ( + + + + {row.floor === 4 ? '顶' : `${row.floor}F`} + + + + + {row.markerLabel || '—'} + + {!!row.remark && ( + {row.remark} + )} + + + × {row.quantity} + {row.photoCount > 0 && ( + + + {row.photoCount} + + )} + + + ))} + + )} + + ) + }} + /> + ) +} + +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' }, +}) diff --git a/apps/property-survey/src/features/summary/SummaryStats.tsx b/apps/property-survey/src/features/summary/SummaryStats.tsx index b2f87e0..06a770f 100644 --- a/apps/property-survey/src/features/summary/SummaryStats.tsx +++ b/apps/property-survey/src/features/summary/SummaryStats.tsx @@ -45,6 +45,7 @@ export function SummaryStats({ rows }: SummaryStatsProps) { const styles = StyleSheet.create({ scroll: { backgroundColor: '#F0FDF4', + flexShrink: 0, }, container: { flexDirection: 'row', @@ -53,7 +54,7 @@ const styles = StyleSheet.create({ gap: 8, }, card: { - width: 100, + paddingHorizontal: 14, alignItems: 'center', backgroundColor: '#fff', borderRadius: 10, diff --git a/apps/property-survey/src/features/summary/exportData.ts b/apps/property-survey/src/features/summary/exportData.ts index 0193012..896a12c 100644 --- a/apps/property-survey/src/features/summary/exportData.ts +++ b/apps/property-survey/src/features/summary/exportData.ts @@ -38,6 +38,44 @@ async function getPhotoPathsByItem(itemId: number): Promise { return rows.map((r) => r.filePath) } +// 仅导出 CSV 数据(不含照片) +export async function exportDataOnly(rows: SummaryRow[]): Promise { + 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 { const canShare = await Sharing.isAvailableAsync() if (!canShare) throw new Error('当前设备不支持分享功能') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a25c582..e17df85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,9 @@ importers: expo-file-system: 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)) + 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: 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)) @@ -49,6 +52,9 @@ importers: expo-router: specifier: ~4.0.0 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: 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)) @@ -2132,6 +2138,16 @@ packages: peerDependencies: 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: resolution: {integrity: sha512-HN4xZirFjsFDIsWFb12AZh19fRzuvZjj2ll17cGr19VNRP06S/VPQU3Tdccn5vwUzQhOBlLu704CnNm278boiQ==} peerDependencies: @@ -2188,6 +2204,11 @@ packages: react-native-reanimated: optional: true + expo-secure-store@55.0.13: + resolution: {integrity: sha512-I6r0JNO1Fd4o0Gu7Ixiic7s89lqgdUHq17uBH9y1f/AntoyKn71TdtYJH82RgfsBbu5qNVzrwImmvlANyOlITQ==} + peerDependencies: + expo: '*' + expo-sharing@12.0.1: resolution: {integrity: sha512-wBT+WeXwapj/9NWuLJO01vi9bdlchYu/Q/xD8slL/Ls4vVYku8CPqzkTtDFcjLrjtlJqyeHsdQXwKLvORmBIew==} peerDependencies: @@ -6777,6 +6798,15 @@ snapshots: 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)): + 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)): 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) @@ -6855,6 +6885,10 @@ snapshots: - react-native - 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)): 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)