diff --git a/apps/property-survey/.expo/devices.json b/apps/property-survey/.expo/devices.json index 02c3728..6fccd46 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": 1775747314027 + "lastUsed": 1775749877472 } ] } diff --git a/apps/property-survey/android/app/src/main/AndroidManifest.xml b/apps/property-survey/android/app/src/main/AndroidManifest.xml index 76e2eee..2ff16d9 100644 --- a/apps/property-survey/android/app/src/main/AndroidManifest.xml +++ b/apps/property-survey/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + @@ -16,6 +18,15 @@ + + + diff --git a/apps/property-survey/android/app/src/main/res/xml/file_paths.xml b/apps/property-survey/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..963f285 --- /dev/null +++ b/apps/property-survey/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/property-survey/app.json b/apps/property-survey/app.json index 48f5725..09cc48d 100644 --- a/apps/property-survey/app.json +++ b/apps/property-survey/app.json @@ -25,6 +25,14 @@ "package": "com.example.propertysurvey" }, "plugins": [ + [ + "expo-image-picker", + { + "cameraPermission": "允许访问相机以拍摄照片", + "microphonePermission": false, + "photosPermission": "允许访问相册以选择照片" + } + ], [ "expo-media-library", { diff --git a/apps/property-survey/src/db/database.ts b/apps/property-survey/src/db/database.ts index f73e807..398eb22 100644 --- a/apps/property-survey/src/db/database.ts +++ b/apps/property-survey/src/db/database.ts @@ -73,4 +73,8 @@ export async function initDatabase(): Promise { if (!itemCols.some((c) => c.name === 'unit')) { await database.execAsync(`ALTER TABLE items ADD COLUMN unit TEXT NOT NULL DEFAULT ''`) } + // 迁移:为旧数据库添加 confirmed_at 列 + if (!itemCols.some((c) => c.name === 'confirmed_at')) { + await database.execAsync(`ALTER TABLE items ADD COLUMN confirmed_at TEXT DEFAULT NULL`) + } } diff --git a/apps/property-survey/src/db/items.ts b/apps/property-survey/src/db/items.ts index 3020c15..f25447a 100644 --- a/apps/property-survey/src/db/items.ts +++ b/apps/property-survey/src/db/items.ts @@ -4,8 +4,8 @@ import { getDatabase } from './database' export async function insertItem(params: InsertItemParams): Promise { const db = getDatabase() const result = await db.runAsync( - `INSERT INTO items (marker_id, name, category, quantity, unit, spec, estimated_value, reference_price, remark) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO items (marker_id, name, category, quantity, unit, spec, estimated_value, reference_price, remark, confirmed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ params.markerId, params.name, @@ -16,12 +16,13 @@ export async function insertItem(params: InsertItemParams): Promise { params.estimatedValue, params.referencePrice, params.remark, + params.confirmedAt ?? null, ], ) const row = await db.getFirstAsync( `SELECT id, marker_id as markerId, name, category, quantity, unit, spec, estimated_value as estimatedValue, reference_price as referencePrice, remark, - created_at as createdAt, updated_at as updatedAt + confirmed_at as confirmedAt, created_at as createdAt, updated_at as updatedAt FROM items WHERE id = ?`, [result.lastInsertRowId], ) @@ -40,7 +41,7 @@ export async function getItemsByMarker( return db.getAllAsync( `SELECT i.id, i.marker_id as markerId, i.name, i.category, i.quantity, i.unit, i.spec, i.estimated_value as estimatedValue, i.reference_price as referencePrice, i.remark, - i.created_at as createdAt, i.updated_at as updatedAt, + i.confirmed_at as confirmedAt, i.created_at as createdAt, i.updated_at as updatedAt, COUNT(p.id) as photoCount FROM items i LEFT JOIN photos p ON p.item_id = i.id @@ -67,6 +68,7 @@ export async function updateItem( if (params.estimatedValue !== undefined) { fields.push('estimated_value = ?'); values.push(params.estimatedValue) } if (params.referencePrice !== undefined) { fields.push('reference_price = ?'); values.push(params.referencePrice) } if (params.remark !== undefined) { fields.push('remark = ?'); values.push(params.remark) } + if (params.confirmedAt !== undefined) { fields.push('confirmed_at = ?'); values.push(params.confirmedAt ?? null) } if (fields.length === 0) return fields.push("updated_at = datetime('now')") @@ -88,6 +90,7 @@ export async function copyItemsToMarker(fromMarkerId: number, toMarkerId: number `INSERT INTO items (marker_id, name, category, quantity, unit, spec, estimated_value, reference_price, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [toMarkerId, item.name, item.category, item.quantity, item.unit, item.spec, item.estimated_value, item.reference_price, item.remark], + // 复制时不继承确认状态,新标记点重新确认 ) } } diff --git a/apps/property-survey/src/db/schema.ts b/apps/property-survey/src/db/schema.ts index bcac244..1735ea9 100644 --- a/apps/property-survey/src/db/schema.ts +++ b/apps/property-survey/src/db/schema.ts @@ -22,6 +22,7 @@ export const CREATE_ITEMS_TABLE = ` estimated_value REAL NOT NULL DEFAULT 0, reference_price REAL NOT NULL DEFAULT 0, remark TEXT NOT NULL DEFAULT '', + confirmed_at TEXT DEFAULT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) diff --git a/apps/property-survey/src/features/item/ItemFields.tsx b/apps/property-survey/src/features/item/ItemFields.tsx index 8b86f05..bcb5883 100644 --- a/apps/property-survey/src/features/item/ItemFields.tsx +++ b/apps/property-survey/src/features/item/ItemFields.tsx @@ -19,10 +19,27 @@ interface ItemFieldsProps { hook: FormHook } +function formatConfirmedAt(iso: string): string { + const d = new Date(iso) + const mm = String(d.getMonth() + 1).padStart(2, '0') + const dd = String(d.getDate()).padStart(2, '0') + const hh = String(d.getHours()).padStart(2, '0') + const min = String(d.getMinutes()).padStart(2, '0') + return `${mm}-${dd} ${hh}:${min}` +} + export function ItemFields({ hook }: ItemFieldsProps) { const { form, setField, nameError, photoItemId, save } = hook const { categories } = useCategories() + function toggleConfirm() { + if (form.confirmedAt) { + setField('confirmedAt', null) + } else { + setField('confirmedAt', new Date().toISOString()) + } + } + return ( {/* 左列:表单字段 */} @@ -150,6 +167,16 @@ export function ItemFields({ hook }: ItemFieldsProps) { {/* 冻结在底部的保存按钮 */} + + + + {form.confirmedAt ? formatConfirmedAt(form.confirmedAt) : '待确认'} + + 保存 @@ -221,7 +248,7 @@ const styles = StyleSheet.create({ }, fieldLabel: { fontSize: 14, color: '#374151', flex: 1 }, fieldInput: { flex: 1, fontSize: 14, color: '#111827', paddingVertical: 10 }, - divider: { height: StyleSheet.hairlineWidth, backgroundColor: '#E5E7EB', marginLeft: 14 }, + divider: { height: 1, backgroundColor: '#D1D5DB', marginLeft: 14 }, remarkInput: { paddingHorizontal: 14, @@ -237,11 +264,21 @@ const styles = StyleSheet.create({ saveBar: { paddingHorizontal: 14, - paddingVertical: 10, - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: '#E5E7EB', + paddingTop: 10, + paddingBottom: 10, + gap: 8, + borderTopWidth: 1, + borderTopColor: '#D1D5DB', backgroundColor: '#fff', }, + confirmRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingVertical: 4, + }, + confirmLabel: { fontSize: 13, color: '#9CA3AF' }, + confirmLabelDone: { color: '#10B981', fontWeight: '600' }, saveBtn: { backgroundColor: '#10B981', borderRadius: 8, @@ -251,7 +288,7 @@ const styles = StyleSheet.create({ saveBtnText: { color: '#fff', fontSize: 14, fontWeight: '700' }, // 分隔线 - colDivider: { width: StyleSheet.hairlineWidth, backgroundColor: '#E5E7EB' }, + colDivider: { width: 1, backgroundColor: '#D1D5DB' }, // 右列:照片 photoCol: { diff --git a/apps/property-survey/src/features/item/ItemFormSheet.tsx b/apps/property-survey/src/features/item/ItemFormSheet.tsx index a56b8e0..47abeff 100644 --- a/apps/property-survey/src/features/item/ItemFormSheet.tsx +++ b/apps/property-survey/src/features/item/ItemFormSheet.tsx @@ -157,7 +157,7 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#fff', borderLeftWidth: StyleSheet.hairlineWidth, - borderLeftColor: '#E5E7EB', + borderLeftColor: '#D1D5DB', }, header: { flexDirection: 'row', @@ -165,8 +165,8 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', paddingHorizontal: 16, paddingVertical: 12, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#E5E7EB', + borderBottomWidth: 1, + borderBottomColor: '#D1D5DB', }, headerActions: { flexDirection: 'row', alignItems: 'center', gap: 16 }, headerTitle: { fontSize: 16, fontWeight: '700', color: '#111827' }, @@ -177,8 +177,8 @@ const styles = StyleSheet.create({ gap: 10, paddingHorizontal: 16, paddingVertical: 10, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#E5E7EB', + borderBottomWidth: 1, + borderBottomColor: '#D1D5DB', }, colorDot: { width: 24, height: 24, borderRadius: 12 }, colorDotSelected: { borderWidth: 3, borderColor: '#111827' }, diff --git a/apps/property-survey/src/features/item/useItemForm.ts b/apps/property-survey/src/features/item/useItemForm.ts index 85a5343..69e2294 100644 --- a/apps/property-survey/src/features/item/useItemForm.ts +++ b/apps/property-survey/src/features/item/useItemForm.ts @@ -11,6 +11,7 @@ interface FormState { estimatedValue: string referencePrice: string remark: string + confirmedAt: string | null } const DEFAULT_FORM: FormState = { @@ -22,6 +23,7 @@ const DEFAULT_FORM: FormState = { estimatedValue: '', referencePrice: '', remark: '', + confirmedAt: null, } export function useItemForm(markerId: number, onSaved: () => void) { @@ -50,6 +52,7 @@ export function useItemForm(markerId: number, onSaved: () => void) { estimatedValue: item.estimatedValue ? String(item.estimatedValue) : '', referencePrice: item.referencePrice ? String(item.referencePrice) : '', remark: item.remark, + confirmedAt: item.confirmedAt, }) setIsDirty(false) } @@ -76,6 +79,7 @@ export function useItemForm(markerId: number, onSaved: () => void) { estimatedValue: parseFloat(form.estimatedValue) || 0, referencePrice: parseFloat(form.referencePrice) || 0, remark: form.remark.trim(), + confirmedAt: form.confirmedAt, } try { @@ -109,6 +113,7 @@ export function useItemForm(markerId: number, onSaved: () => void) { estimatedValue: parseFloat(form.estimatedValue) || 0, referencePrice: parseFloat(form.referencePrice) || 0, remark: form.remark.trim(), + confirmedAt: form.confirmedAt, } try { if (editingId !== null) { diff --git a/apps/property-survey/src/features/map/MarkerList.tsx b/apps/property-survey/src/features/map/MarkerList.tsx index 14d8e66..9ba6c18 100644 --- a/apps/property-survey/src/features/map/MarkerList.tsx +++ b/apps/property-survey/src/features/map/MarkerList.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react' import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native' +import { Ionicons } from '@expo/vector-icons' import type { FloorNumber, Marker } from '@repo/types' import { getMarkersByFloor, updateMarker } from '../../db/markers' import { getItemsByMarker } from '../../db/items' @@ -8,7 +9,7 @@ import { FloorMap } from './FloorMap' interface MarkerWithItems extends Marker { itemCount: number itemNames: string[] - itemSummaries: { name: string; quantity: number }[] + itemSummaries: { name: string; quantity: number; confirmedAt: string | null }[] } interface MarkerListProps { @@ -36,7 +37,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 })), + itemSummaries: markerItems.map((i) => ({ name: i.name, quantity: i.quantity, confirmedAt: i.confirmedAt })), } }) setMarkers(withItems) @@ -108,6 +109,12 @@ export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey {(m.itemSummaries?.length ?? 0) > 0 && m.itemSummaries.map((s, i) => ( + {s.name} ×{s.quantity} @@ -210,7 +217,8 @@ const styles = StyleSheet.create({ itemContent: { flex: 1 }, itemLabel: { fontSize: 12, fontWeight: '600', color: '#374151' }, itemLabelSelected: { color: '#065F46' }, - itemSummaryRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 2 }, + itemSummaryRow: { flexDirection: 'row', alignItems: 'center', marginTop: 2, gap: 2 }, + itemCheckbox: { flexShrink: 0 }, itemNames: { fontSize: 11, color: '#6B7280', flex: 1, lineHeight: 15 }, itemQty: { fontSize: 11, color: '#9CA3AF', lineHeight: 15, marginLeft: 4 }, countBadge: { diff --git a/apps/property-survey/src/features/photo/MarkerPhotoPanel.tsx b/apps/property-survey/src/features/photo/MarkerPhotoPanel.tsx index fe380f9..094f22f 100644 --- a/apps/property-survey/src/features/photo/MarkerPhotoPanel.tsx +++ b/apps/property-survey/src/features/photo/MarkerPhotoPanel.tsx @@ -1,6 +1,5 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { - Dimensions, Image, Modal, ScrollView, @@ -31,12 +30,11 @@ interface MarkerPhotoPanelProps { onClose: () => void } -const SCREEN_W = Dimensions.get('window').width - export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) { const [groups, setGroups] = useState([]) const [allPhotos, setAllPhotos] = useState([]) const [previewIndex, setPreviewIndex] = useState(null) + const touchStartX = useRef(0) const load = useCallback(async () => { if (!marker) { setGroups([]); setAllPhotos([]); return } @@ -61,6 +59,14 @@ export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) { if (!marker) return null const totalPhotos = allPhotos.length + const current = previewIndex !== null ? allPhotos[previewIndex] : null + + function goPrev() { + setPreviewIndex((i) => (i !== null && i > 0 ? i - 1 : i)) + } + function goNext() { + setPreviewIndex((i) => (i !== null && i < totalPhotos - 1 ? i + 1 : i)) + } return ( @@ -122,47 +128,55 @@ export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) { {/* 全屏预览 */} setPreviewIndex(null)} > - + { touchStartX.current = e.nativeEvent.pageX }} + onTouchEnd={(e) => { + const dx = e.nativeEvent.pageX - touchStartX.current + if (dx < -50) goNext() + else if (dx > 50) goPrev() + }} + > + {/* 顶部信息栏 */} - {previewIndex !== null ? allPhotos[previewIndex]?.itemName : ''} + {current?.itemName ?? ''} {(previewIndex ?? 0) + 1} / {totalPhotos} - setPreviewIndex(null)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> + setPreviewIndex(null)} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > - - {previewIndex !== null && allPhotos[previewIndex] && ( + {/* 单张照片 */} + + {current && ( )} + {/* 左箭头 */} {previewIndex !== null && previewIndex > 0 && ( - setPreviewIndex((i) => Math.max(0, (i ?? 1) - 1))} - > + )} + {/* 右箭头 */} {previewIndex !== null && previewIndex < totalPhotos - 1 && ( - setPreviewIndex((i) => Math.min(totalPhotos - 1, (i ?? 0) + 1))} - > + )} @@ -220,22 +234,25 @@ const styles = StyleSheet.create({ empty: { alignItems: 'center', paddingTop: 60, gap: 12 }, emptyText: { fontSize: 14, color: '#9CA3AF' }, - overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.94)', justifyContent: 'center' }, + overlay: { + flex: 1, + backgroundColor: '#000', + }, previewTopBar: { - position: 'absolute', - top: 0, left: 0, right: 0, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingTop: 52, paddingBottom: 12, - zIndex: 10, }, previewItemName: { flex: 1, color: '#fff', fontSize: 15, fontWeight: '600' }, previewCounter: { color: 'rgba(255,255,255,0.6)', fontSize: 13, marginHorizontal: 12 }, - previewImageArea: { flex: 1, justifyContent: 'center', alignItems: 'center' }, - previewImg: { width: SCREEN_W, height: SCREEN_W * 1.2 }, - arrowLeft: { position: 'absolute', left: 8, top: '50%', padding: 8, zIndex: 10 }, - arrowRight: { position: 'absolute', right: 8, top: '50%', padding: 8, zIndex: 10 }, + + photoArea: { + flex: 1, + }, + + arrowLeft: { position: 'absolute', left: 8, top: '50%', padding: 12 }, + arrowRight: { position: 'absolute', right: 8, top: '50%', padding: 12 }, }) diff --git a/apps/property-survey/src/features/photo/PhotoGrid.tsx b/apps/property-survey/src/features/photo/PhotoGrid.tsx index 55acf5a..2a7bfd5 100644 --- a/apps/property-survey/src/features/photo/PhotoGrid.tsx +++ b/apps/property-survey/src/features/photo/PhotoGrid.tsx @@ -1,12 +1,14 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Alert, + FlatList, Image, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, + useWindowDimensions, View, } from 'react-native' import { Ionicons } from '@expo/vector-icons' @@ -23,6 +25,8 @@ export function PhotoGrid({ itemId }: PhotoGridProps) { const [photos, setPhotos] = useState([]) const [previewIndex, setPreviewIndex] = useState(null) const [saving, setSaving] = useState(false) + const { width: screenWidth } = useWindowDimensions() + const flatListRef = useRef>(null) const load = useCallback(async () => { try { @@ -117,22 +121,33 @@ export function PhotoGrid({ itemId }: PhotoGridProps) { - - {photos.map((photo) => ( - + keyExtractor={(p) => String(p.id)} + initialScrollIndex={previewIndex ?? 0} + getItemLayout={(_, index) => ({ + length: screenWidth, + offset: screenWidth * index, + index, + })} + onMomentumScrollEnd={(e) => { + const idx = Math.round(e.nativeEvent.contentOffset.x / screenWidth) + setPreviewIndex(idx) + }} + renderItem={({ item: photo }) => ( + - ))} - + )} + /> {(previewIndex ?? 0) + 1} / {photos.length} @@ -175,12 +190,10 @@ const styles = StyleSheet.create({ }, saveBtnDisabled: { opacity: 0.4 }, previewPage: { - width: 400, justifyContent: 'center', alignItems: 'center', }, previewImg: { - width: 400, height: 500, }, counter: { diff --git a/apps/property-survey/src/features/photo/PhotoPicker.tsx b/apps/property-survey/src/features/photo/PhotoPicker.tsx index 3b9521b..a6abe73 100644 --- a/apps/property-survey/src/features/photo/PhotoPicker.tsx +++ b/apps/property-survey/src/features/photo/PhotoPicker.tsx @@ -12,8 +12,8 @@ export function PhotoPicker({ itemId, onAdded }: PhotoPickerProps) { function showPicker() { Alert.alert('添加照片', '请选择来源', [ - { text: '拍照', onPress: () => capture('camera') }, - { text: '从相册选择', onPress: () => capture('library') }, + { text: '拍照', onPress: () => setTimeout(() => capture('camera'), 300) }, + { text: '从相册选择', onPress: () => setTimeout(() => capture('library'), 300) }, { text: '取消', style: 'cancel' }, ]) } diff --git a/packages/types/src/survey.ts b/packages/types/src/survey.ts index ad3aa93..cd97947 100644 --- a/packages/types/src/survey.ts +++ b/packages/types/src/survey.ts @@ -31,6 +31,7 @@ export interface Item { estimatedValue: number referencePrice: number remark: string + confirmedAt: string | null createdAt: string updatedAt: string }