- 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>
168 lines
5.3 KiB
TypeScript
168 lines
5.3 KiB
TypeScript
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: '导出不动产摸底数据',
|
||
})
|
||
}
|