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:
lanrtop 2026-04-10 18:02:22 +09:00
parent 1c6d491eaf
commit e7ab0d1ef1
12 changed files with 862 additions and 18 deletions

View File

@ -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

View File

@ -0,0 +1,68 @@
---
id: ai-recognize
name: AI 照片识别填充表单
area: rn-app
status: planned
---
## 概述
用户在物品登记表单中,选取当前物品的一张或多张照片,可附加文字补充描述,发送给通义千问 Qwen-VL-Max 进行多模态识别。AI 返回富文本描述后,用户在回复文本中框选任意片段,点击字段注入按钮将其填入表单对应字段(名称、规格、单价、单位、备注)。
## 技术选型
- **API**:通义千问 Qwen-VL-Max阿里云灵积 DashScopeOpenAI 兼容格式)
- **多图策略**:多轮对话叠加(每轮可追加图片或文字)
- **选区注入**TextInput selectable + lastSelectionRefblur 安全)
- **图片压缩**expo-image-manipulator600px / 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() 读写 SecureStorechat(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[] 参数;管理 selectedIdstogglePhoto 切换、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 时显示 ActivityIndicatorresponse 区域用 multiline TextInputeditable=falseselectable=trueonSelectionChange 更新 hook底部 InjectionToolbar 固定显示「→名称」「→规格」「→单价」「→单位」「→备注」5个按钮点击时调 onInject(field, selectedText)selectedText 为空时按钮 disabled注入成功后对应按钮短暂变绿300mserror 非空时在 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 并标记 isDirtyItemFields 照片列顶部「照片」标题行右侧增加「✨ 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 限制内且不明显影响识别准确度

View File

@ -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
}
}
]
}

View File

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

View File

@ -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<ViewMode>('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() {
<Stack.Screen
options={{
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" />
</TouchableOpacity>
</View>
),
}}
/>
<View style={styles.container}>
<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
floor={filter.floor}
keyword={filter.keyword}
@ -45,7 +83,9 @@ export default function SummaryScreen() {
}
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>
</>
)
@ -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' },
})

View File

@ -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",

View File

@ -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<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) {
const [markers, setMarkers] = useState<MarkerWithItems[]>([])
const [activeTab, setActiveTab] = useState<SidebarTab>('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<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 (
<View style={styles.container}>
<View style={styles.sidebar}>
@ -70,16 +123,42 @@ export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey
activeOpacity={0.7}
>
<Text style={[styles.tabText, activeTab === tab && styles.tabTextActive]}>
{tab === 'detail' ? '明细' : '聚合'}
{tab === 'detail' ? `明细 ${markers.length}` : '聚合'}
</Text>
</TouchableOpacity>
))}
</View>
{activeTab === 'aggregate' ? (
<View style={styles.aggregatePlaceholder}>
<Text style={styles.emptyHint}>{'\n'}</Text>
<ScrollView style={styles.sidebarScroll} showsVerticalScrollIndicator={false}>
{(() => {
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>
{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}>
{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,

View File

@ -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,
},
})

View 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' },
})

View File

@ -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,

View File

@ -38,6 +38,44 @@ async function getPhotoPathsByItem(itemId: number): Promise<string[]> {
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> {
const canShare = await Sharing.isAvailableAsync()
if (!canShare) throw new Error('当前设备不支持分享功能')

34
pnpm-lock.yaml generated
View File

@ -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)