react-phone-apps/apps/property-survey/src/features/summary/exportData.ts
lanrtop e7ab0d1ef1 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>
2026-04-10 18:02:22 +09:00

168 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import JSZip from 'jszip'
import * as FileSystem from 'expo-file-system'
import * as Sharing from 'expo-sharing'
import { getDatabase } from '../../db/database'
import type { SummaryRow } from './useSummaryData'
const BOM = '\uFEFF'
// 主 CSV 表头(含照片目录列)
const HEADERS = ['楼层', '标记点名', '物品名', '类别', '数量', '单价', '评估参考价格', '备注', '照片数', '照片目录']
// 照片索引 CSV 表头
const INDEX_HEADERS = ['物品ID', '楼层', '标记点', '物品名', '照片文件路径']
function escapeCsv(value: string | number): string {
const str = String(value)
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
function sanitizeDirName(name: string): string {
return name.replace(/[\\/:*?"<>|]/g, '_').slice(0, 20)
}
function formatDate(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}`
}
async function getPhotoPathsByItem(itemId: number): Promise<string[]> {
const db = getDatabase()
const rows = await db.getAllAsync<{ filePath: string }>(
`SELECT file_path as filePath FROM photos WHERE item_id = ? ORDER BY sort_order ASC`,
[itemId],
)
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('当前设备不支持分享功能')
const zip = new JSZip()
const photosFolder = zip.folder('photos')!
// 照片索引行(每张照片一行)
const indexLines: string[] = [INDEX_HEADERS.join(',')]
// 为每个物品生成照片子目录名,并打包照片
const photoDirMap = new Map<number, string>() // itemId → 子目录路径(相对 ZIP 根)
for (const row of rows) {
if (row.photoCount === 0) continue
const paths = await getPhotoPathsByItem(row.itemId)
if (paths.length === 0) continue
const dirName = `${row.floor}F_${sanitizeDirName(row.markerLabel)}_${sanitizeDirName(row.name)}(${row.itemId})`
const itemFolder = photosFolder.folder(dirName)!
const relativeDirPath = `photos/${dirName}/`
photoDirMap.set(row.itemId, relativeDirPath)
for (let i = 0; i < paths.length; i++) {
const filePath = paths[i]
const photoFileName = `${i + 1}.jpg`
const photoRelativePath = `${relativeDirPath}${photoFileName}`
try {
const info = await FileSystem.getInfoAsync(filePath)
if (!info.exists) continue
const base64 = await FileSystem.readAsStringAsync(filePath, {
encoding: FileSystem.EncodingType.Base64,
})
itemFolder.file(photoFileName, base64, { base64: true })
} catch {
// 单张照片读取失败时跳过,不中断整体导出
continue
}
// 写入索引行
indexLines.push(
[row.itemId, `${row.floor}F`, row.markerLabel, row.name, photoRelativePath]
.map(escapeCsv)
.join(','),
)
}
}
// 生成照片索引文件
if (indexLines.length > 1) {
photosFolder.file('index.csv', BOM + indexLines.join('\r\n'))
}
// 生成主 CSV含照片目录列
const csvLines = [
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,
photoDirMap.get(r.itemId) ?? '',
]
.map(escapeCsv)
.join(','),
),
]
zip.file('摸底数据.csv', BOM + csvLines.join('\r\n'))
// 生成 ZIP 并分享
const zipBase64 = await zip.generateAsync({ type: 'base64' })
const zipPath = `${FileSystem.cacheDirectory}不动产摸底_${formatDate(new Date())}.zip`
await FileSystem.writeAsStringAsync(zipPath, zipBase64, {
encoding: FileSystem.EncodingType.Base64,
})
await Sharing.shareAsync(zipPath, {
mimeType: 'application/zip',
dialogTitle: '导出不动产摸底数据',
})
}