feat: 新增物品确认状态与相机权限配置

- items 表新增 confirmed_at 字段,支持标记物品登记已完成
- 物品表单底部新增确认勾选,标记列表同步展示已确认/待确认图标
- Android 补充 CAMERA、READ_MEDIA_IMAGES 权限及 FileProvider 配置(兼容 Android 13+)
- expo-image-picker 插件配置中文权限说明
- PhotoPicker 延迟启动拍摄/选图,避免 Alert 关闭与系统弹窗冲突
- MarkerPhotoPanel 照片预览改为单张显示 + 触摸滑动切换,替代有缺陷的 ScrollView pagingEnabled
- PhotoGrid 预览改用 FlatList + getItemLayout,以实时屏幕宽度修正分页尺寸

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
lanrtop 2026-04-10 01:05:03 +09:00
parent cea2a5d208
commit cb7c5ad7f4
15 changed files with 175 additions and 59 deletions

View File

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

View File

@ -1,6 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
@ -16,6 +18,15 @@
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
</provider>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="unspecified">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path name="external-files" path="." />
<external-files-path name="external-app-files" path="." />
<cache-path name="cache" path="." />
<external-cache-path name="external-cache" path="." />
<files-path name="internal-files" path="." />
</paths>

View File

@ -25,6 +25,14 @@
"package": "com.example.propertysurvey"
},
"plugins": [
[
"expo-image-picker",
{
"cameraPermission": "允许访问相机以拍摄照片",
"microphonePermission": false,
"photosPermission": "允许访问相册以选择照片"
}
],
[
"expo-media-library",
{

View File

@ -73,4 +73,8 @@ export async function initDatabase(): Promise<void> {
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`)
}
}

View File

@ -4,8 +4,8 @@ import { getDatabase } from './database'
export async function insertItem(params: InsertItemParams): Promise<Item> {
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<Item> {
params.estimatedValue,
params.referencePrice,
params.remark,
params.confirmedAt ?? null,
],
)
const row = await db.getFirstAsync<Item>(
`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<ItemWithPhotoCount>(
`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],
// 复制时不继承确认状态,新标记点重新确认
)
}
}

View File

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

View File

@ -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 (
<View style={styles.root}>
{/* 左列:表单字段 */}
@ -150,6 +167,16 @@ export function ItemFields({ hook }: ItemFieldsProps) {
{/* 冻结在底部的保存按钮 */}
<View style={styles.saveBar}>
<TouchableOpacity style={styles.confirmRow} onPress={toggleConfirm} activeOpacity={0.7}>
<Ionicons
name={form.confirmedAt ? 'checkbox' : 'square-outline'}
size={22}
color={form.confirmedAt ? '#10B981' : '#9CA3AF'}
/>
<Text style={[styles.confirmLabel, form.confirmedAt ? styles.confirmLabelDone : null]}>
{form.confirmedAt ? formatConfirmedAt(form.confirmedAt) : '待确认'}
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.saveBtn} onPress={save} activeOpacity={0.8}>
<Text style={styles.saveBtnText}></Text>
</TouchableOpacity>
@ -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: {

View File

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

View File

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

View File

@ -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
</Text>
{(m.itemSummaries?.length ?? 0) > 0 && m.itemSummaries.map((s, i) => (
<View key={i} style={styles.itemSummaryRow}>
<Ionicons
name={s.confirmedAt ? 'checkbox' : 'square-outline'}
size={11}
color={s.confirmedAt ? '#10B981' : '#9CA3AF'}
style={styles.itemCheckbox}
/>
<Text style={styles.itemNames} numberOfLines={1}>{s.name}</Text>
<Text style={styles.itemQty}>×{s.quantity}</Text>
</View>
@ -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: {

View File

@ -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<PhotoGroup[]>([])
const [allPhotos, setAllPhotos] = useState<PhotoEntry[]>([])
const [previewIndex, setPreviewIndex] = useState<number | null>(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 (
<View style={styles.panel}>
@ -122,47 +128,55 @@ export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) {
{/* 全屏预览 */}
<Modal
visible={previewIndex !== null}
transparent
animationType="fade"
statusBarTranslucent
onRequestClose={() => setPreviewIndex(null)}
>
<View style={styles.overlay}>
<View
style={styles.overlay}
onTouchStart={(e) => { touchStartX.current = e.nativeEvent.pageX }}
onTouchEnd={(e) => {
const dx = e.nativeEvent.pageX - touchStartX.current
if (dx < -50) goNext()
else if (dx > 50) goPrev()
}}
>
{/* 顶部信息栏 */}
<View style={styles.previewTopBar}>
<Text style={styles.previewItemName} numberOfLines={1}>
{previewIndex !== null ? allPhotos[previewIndex]?.itemName : ''}
{current?.itemName ?? ''}
</Text>
<Text style={styles.previewCounter}>
{(previewIndex ?? 0) + 1} / {totalPhotos}
</Text>
<TouchableOpacity onPress={() => setPreviewIndex(null)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<TouchableOpacity
onPress={() => setPreviewIndex(null)}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<Ionicons name="close" size={26} color="#fff" />
</TouchableOpacity>
</View>
<View style={styles.previewImageArea}>
{previewIndex !== null && allPhotos[previewIndex] && (
{/* 单张照片 */}
<View style={styles.photoArea}>
{current && (
<Image
source={{ uri: allPhotos[previewIndex].photo.filePath }}
style={styles.previewImg}
source={{ uri: current.photo.filePath }}
style={StyleSheet.absoluteFill}
resizeMode="contain"
/>
)}
</View>
{/* 左箭头 */}
{previewIndex !== null && previewIndex > 0 && (
<TouchableOpacity
style={styles.arrowLeft}
onPress={() => setPreviewIndex((i) => Math.max(0, (i ?? 1) - 1))}
>
<TouchableOpacity style={styles.arrowLeft} onPress={goPrev}>
<Ionicons name="chevron-back" size={34} color="#fff" />
</TouchableOpacity>
)}
{/* 右箭头 */}
{previewIndex !== null && previewIndex < totalPhotos - 1 && (
<TouchableOpacity
style={styles.arrowRight}
onPress={() => setPreviewIndex((i) => Math.min(totalPhotos - 1, (i ?? 0) + 1))}
>
<TouchableOpacity style={styles.arrowRight} onPress={goNext}>
<Ionicons name="chevron-forward" size={34} color="#fff" />
</TouchableOpacity>
)}
@ -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 },
})

View File

@ -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<Photo[]>([])
const [previewIndex, setPreviewIndex] = useState<number | null>(null)
const [saving, setSaving] = useState(false)
const { width: screenWidth } = useWindowDimensions()
const flatListRef = useRef<FlatList<Photo>>(null)
const load = useCallback(async () => {
try {
@ -117,22 +121,33 @@ export function PhotoGrid({ itemId }: PhotoGridProps) {
<Ionicons name="download-outline" size={22} color="#fff" />
</TouchableOpacity>
<ScrollView
<FlatList
ref={flatListRef}
data={photos}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
contentOffset={{ x: (previewIndex ?? 0) * 400, y: 0 }}
>
{photos.map((photo) => (
<View key={photo.id} style={styles.previewPage}>
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 }) => (
<View style={[styles.previewPage, { width: screenWidth }]}>
<Image
source={{ uri: photo.filePath }}
style={styles.previewImg}
style={[styles.previewImg, { width: screenWidth }]}
resizeMode="contain"
/>
</View>
))}
</ScrollView>
)}
/>
<Text style={styles.counter}>
{(previewIndex ?? 0) + 1} / {photos.length}
</Text>
@ -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: {

View File

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

View File

@ -31,6 +31,7 @@ export interface Item {
estimatedValue: number
referencePrice: number
remark: string
confirmedAt: string | null
createdAt: string
updatedAt: string
}