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": [ "devices": [
{ {
"installationId": "626cc0bf-3d4a-47d1-9fd9-50b31e88db04", "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"> <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.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <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.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/> <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.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_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/> <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"> <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> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <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" "package": "com.example.propertysurvey"
}, },
"plugins": [ "plugins": [
[
"expo-image-picker",
{
"cameraPermission": "允许访问相机以拍摄照片",
"microphonePermission": false,
"photosPermission": "允许访问相册以选择照片"
}
],
[ [
"expo-media-library", "expo-media-library",
{ {

View File

@ -73,4 +73,8 @@ export async function initDatabase(): Promise<void> {
if (!itemCols.some((c) => c.name === 'unit')) { if (!itemCols.some((c) => c.name === 'unit')) {
await database.execAsync(`ALTER TABLE items ADD COLUMN unit TEXT NOT NULL DEFAULT ''`) 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> { export async function insertItem(params: InsertItemParams): Promise<Item> {
const db = getDatabase() const db = getDatabase()
const result = await db.runAsync( const result = await db.runAsync(
`INSERT INTO items (marker_id, name, category, quantity, unit, spec, estimated_value, reference_price, remark) `INSERT INTO items (marker_id, name, category, quantity, unit, spec, estimated_value, reference_price, remark, confirmed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
params.markerId, params.markerId,
params.name, params.name,
@ -16,12 +16,13 @@ export async function insertItem(params: InsertItemParams): Promise<Item> {
params.estimatedValue, params.estimatedValue,
params.referencePrice, params.referencePrice,
params.remark, params.remark,
params.confirmedAt ?? null,
], ],
) )
const row = await db.getFirstAsync<Item>( const row = await db.getFirstAsync<Item>(
`SELECT id, marker_id as markerId, name, category, quantity, unit, spec, `SELECT id, marker_id as markerId, name, category, quantity, unit, spec,
estimated_value as estimatedValue, reference_price as referencePrice, remark, 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 = ?`, FROM items WHERE id = ?`,
[result.lastInsertRowId], [result.lastInsertRowId],
) )
@ -40,7 +41,7 @@ export async function getItemsByMarker(
return db.getAllAsync<ItemWithPhotoCount>( return db.getAllAsync<ItemWithPhotoCount>(
`SELECT i.id, i.marker_id as markerId, i.name, i.category, i.quantity, i.unit, i.spec, `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.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 COUNT(p.id) as photoCount
FROM items i FROM items i
LEFT JOIN photos p ON p.item_id = i.id 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.estimatedValue !== undefined) { fields.push('estimated_value = ?'); values.push(params.estimatedValue) }
if (params.referencePrice !== undefined) { fields.push('reference_price = ?'); values.push(params.referencePrice) } 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.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 if (fields.length === 0) return
fields.push("updated_at = datetime('now')") 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) `INSERT INTO items (marker_id, name, category, quantity, unit, spec, estimated_value, reference_price, remark)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[toMarkerId, item.name, item.category, item.quantity, item.unit, item.spec, item.estimated_value, item.reference_price, item.remark], [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, estimated_value REAL NOT NULL DEFAULT 0,
reference_price REAL NOT NULL DEFAULT 0, reference_price REAL NOT NULL DEFAULT 0,
remark TEXT NOT NULL DEFAULT '', remark TEXT NOT NULL DEFAULT '',
confirmed_at TEXT DEFAULT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_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 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) { export function ItemFields({ hook }: ItemFieldsProps) {
const { form, setField, nameError, photoItemId, save } = hook const { form, setField, nameError, photoItemId, save } = hook
const { categories } = useCategories() const { categories } = useCategories()
function toggleConfirm() {
if (form.confirmedAt) {
setField('confirmedAt', null)
} else {
setField('confirmedAt', new Date().toISOString())
}
}
return ( return (
<View style={styles.root}> <View style={styles.root}>
{/* 左列:表单字段 */} {/* 左列:表单字段 */}
@ -150,6 +167,16 @@ export function ItemFields({ hook }: ItemFieldsProps) {
{/* 冻结在底部的保存按钮 */} {/* 冻结在底部的保存按钮 */}
<View style={styles.saveBar}> <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}> <TouchableOpacity style={styles.saveBtn} onPress={save} activeOpacity={0.8}>
<Text style={styles.saveBtnText}></Text> <Text style={styles.saveBtnText}></Text>
</TouchableOpacity> </TouchableOpacity>
@ -221,7 +248,7 @@ const styles = StyleSheet.create({
}, },
fieldLabel: { fontSize: 14, color: '#374151', flex: 1 }, fieldLabel: { fontSize: 14, color: '#374151', flex: 1 },
fieldInput: { flex: 1, fontSize: 14, color: '#111827', paddingVertical: 10 }, 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: { remarkInput: {
paddingHorizontal: 14, paddingHorizontal: 14,
@ -237,11 +264,21 @@ const styles = StyleSheet.create({
saveBar: { saveBar: {
paddingHorizontal: 14, paddingHorizontal: 14,
paddingVertical: 10, paddingTop: 10,
borderTopWidth: StyleSheet.hairlineWidth, paddingBottom: 10,
borderTopColor: '#E5E7EB', gap: 8,
borderTopWidth: 1,
borderTopColor: '#D1D5DB',
backgroundColor: '#fff', backgroundColor: '#fff',
}, },
confirmRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 4,
},
confirmLabel: { fontSize: 13, color: '#9CA3AF' },
confirmLabelDone: { color: '#10B981', fontWeight: '600' },
saveBtn: { saveBtn: {
backgroundColor: '#10B981', backgroundColor: '#10B981',
borderRadius: 8, borderRadius: 8,
@ -251,7 +288,7 @@ const styles = StyleSheet.create({
saveBtnText: { color: '#fff', fontSize: 14, fontWeight: '700' }, saveBtnText: { color: '#fff', fontSize: 14, fontWeight: '700' },
// 分隔线 // 分隔线
colDivider: { width: StyleSheet.hairlineWidth, backgroundColor: '#E5E7EB' }, colDivider: { width: 1, backgroundColor: '#D1D5DB' },
// 右列:照片 // 右列:照片
photoCol: { photoCol: {

View File

@ -157,7 +157,7 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
backgroundColor: '#fff', backgroundColor: '#fff',
borderLeftWidth: StyleSheet.hairlineWidth, borderLeftWidth: StyleSheet.hairlineWidth,
borderLeftColor: '#E5E7EB', borderLeftColor: '#D1D5DB',
}, },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
@ -165,8 +165,8 @@ const styles = StyleSheet.create({
justifyContent: 'space-between', justifyContent: 'space-between',
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 12, paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: 1,
borderBottomColor: '#E5E7EB', borderBottomColor: '#D1D5DB',
}, },
headerActions: { flexDirection: 'row', alignItems: 'center', gap: 16 }, headerActions: { flexDirection: 'row', alignItems: 'center', gap: 16 },
headerTitle: { fontSize: 16, fontWeight: '700', color: '#111827' }, headerTitle: { fontSize: 16, fontWeight: '700', color: '#111827' },
@ -177,8 +177,8 @@ const styles = StyleSheet.create({
gap: 10, gap: 10,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 10, paddingVertical: 10,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: 1,
borderBottomColor: '#E5E7EB', borderBottomColor: '#D1D5DB',
}, },
colorDot: { width: 24, height: 24, borderRadius: 12 }, colorDot: { width: 24, height: 24, borderRadius: 12 },
colorDotSelected: { borderWidth: 3, borderColor: '#111827' }, colorDotSelected: { borderWidth: 3, borderColor: '#111827' },

View File

@ -11,6 +11,7 @@ interface FormState {
estimatedValue: string estimatedValue: string
referencePrice: string referencePrice: string
remark: string remark: string
confirmedAt: string | null
} }
const DEFAULT_FORM: FormState = { const DEFAULT_FORM: FormState = {
@ -22,6 +23,7 @@ const DEFAULT_FORM: FormState = {
estimatedValue: '', estimatedValue: '',
referencePrice: '', referencePrice: '',
remark: '', remark: '',
confirmedAt: null,
} }
export function useItemForm(markerId: number, onSaved: () => void) { export function useItemForm(markerId: number, onSaved: () => void) {
@ -50,6 +52,7 @@ export function useItemForm(markerId: number, onSaved: () => void) {
estimatedValue: item.estimatedValue ? String(item.estimatedValue) : '', estimatedValue: item.estimatedValue ? String(item.estimatedValue) : '',
referencePrice: item.referencePrice ? String(item.referencePrice) : '', referencePrice: item.referencePrice ? String(item.referencePrice) : '',
remark: item.remark, remark: item.remark,
confirmedAt: item.confirmedAt,
}) })
setIsDirty(false) setIsDirty(false)
} }
@ -76,6 +79,7 @@ export function useItemForm(markerId: number, onSaved: () => void) {
estimatedValue: parseFloat(form.estimatedValue) || 0, estimatedValue: parseFloat(form.estimatedValue) || 0,
referencePrice: parseFloat(form.referencePrice) || 0, referencePrice: parseFloat(form.referencePrice) || 0,
remark: form.remark.trim(), remark: form.remark.trim(),
confirmedAt: form.confirmedAt,
} }
try { try {
@ -109,6 +113,7 @@ export function useItemForm(markerId: number, onSaved: () => void) {
estimatedValue: parseFloat(form.estimatedValue) || 0, estimatedValue: parseFloat(form.estimatedValue) || 0,
referencePrice: parseFloat(form.referencePrice) || 0, referencePrice: parseFloat(form.referencePrice) || 0,
remark: form.remark.trim(), remark: form.remark.trim(),
confirmedAt: form.confirmedAt,
} }
try { try {
if (editingId !== null) { if (editingId !== null) {

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native' import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
import { Ionicons } from '@expo/vector-icons'
import type { FloorNumber, Marker } from '@repo/types' import type { FloorNumber, Marker } from '@repo/types'
import { getMarkersByFloor, updateMarker } from '../../db/markers' import { getMarkersByFloor, updateMarker } from '../../db/markers'
import { getItemsByMarker } from '../../db/items' import { getItemsByMarker } from '../../db/items'
@ -8,7 +9,7 @@ import { FloorMap } from './FloorMap'
interface MarkerWithItems extends Marker { interface MarkerWithItems extends Marker {
itemCount: number itemCount: number
itemNames: string[] itemNames: string[]
itemSummaries: { name: string; quantity: number }[] itemSummaries: { name: string; quantity: number; confirmedAt: string | null }[]
} }
interface MarkerListProps { interface MarkerListProps {
@ -36,7 +37,7 @@ export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey
...m, ...m,
itemCount: markerItems.length, itemCount: markerItems.length,
itemNames: markerItems.map((i) => i.name), 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) setMarkers(withItems)
@ -108,6 +109,12 @@ export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey
</Text> </Text>
{(m.itemSummaries?.length ?? 0) > 0 && m.itemSummaries.map((s, i) => ( {(m.itemSummaries?.length ?? 0) > 0 && m.itemSummaries.map((s, i) => (
<View key={i} style={styles.itemSummaryRow}> <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.itemNames} numberOfLines={1}>{s.name}</Text>
<Text style={styles.itemQty}>×{s.quantity}</Text> <Text style={styles.itemQty}>×{s.quantity}</Text>
</View> </View>
@ -210,7 +217,8 @@ const styles = StyleSheet.create({
itemContent: { flex: 1 }, itemContent: { flex: 1 },
itemLabel: { fontSize: 12, fontWeight: '600', color: '#374151' }, itemLabel: { fontSize: 12, fontWeight: '600', color: '#374151' },
itemLabelSelected: { color: '#065F46' }, 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 }, itemNames: { fontSize: 11, color: '#6B7280', flex: 1, lineHeight: 15 },
itemQty: { fontSize: 11, color: '#9CA3AF', lineHeight: 15, marginLeft: 4 }, itemQty: { fontSize: 11, color: '#9CA3AF', lineHeight: 15, marginLeft: 4 },
countBadge: { countBadge: {

View File

@ -1,6 +1,5 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { import {
Dimensions,
Image, Image,
Modal, Modal,
ScrollView, ScrollView,
@ -31,12 +30,11 @@ interface MarkerPhotoPanelProps {
onClose: () => void onClose: () => void
} }
const SCREEN_W = Dimensions.get('window').width
export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) { export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) {
const [groups, setGroups] = useState<PhotoGroup[]>([]) const [groups, setGroups] = useState<PhotoGroup[]>([])
const [allPhotos, setAllPhotos] = useState<PhotoEntry[]>([]) const [allPhotos, setAllPhotos] = useState<PhotoEntry[]>([])
const [previewIndex, setPreviewIndex] = useState<number | null>(null) const [previewIndex, setPreviewIndex] = useState<number | null>(null)
const touchStartX = useRef(0)
const load = useCallback(async () => { const load = useCallback(async () => {
if (!marker) { setGroups([]); setAllPhotos([]); return } if (!marker) { setGroups([]); setAllPhotos([]); return }
@ -61,6 +59,14 @@ export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) {
if (!marker) return null if (!marker) return null
const totalPhotos = allPhotos.length 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 ( return (
<View style={styles.panel}> <View style={styles.panel}>
@ -122,47 +128,55 @@ export function MarkerPhotoPanel({ marker, onClose }: MarkerPhotoPanelProps) {
{/* 全屏预览 */} {/* 全屏预览 */}
<Modal <Modal
visible={previewIndex !== null} visible={previewIndex !== null}
transparent
animationType="fade" animationType="fade"
statusBarTranslucent statusBarTranslucent
onRequestClose={() => setPreviewIndex(null)} 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}> <View style={styles.previewTopBar}>
<Text style={styles.previewItemName} numberOfLines={1}> <Text style={styles.previewItemName} numberOfLines={1}>
{previewIndex !== null ? allPhotos[previewIndex]?.itemName : ''} {current?.itemName ?? ''}
</Text> </Text>
<Text style={styles.previewCounter}> <Text style={styles.previewCounter}>
{(previewIndex ?? 0) + 1} / {totalPhotos} {(previewIndex ?? 0) + 1} / {totalPhotos}
</Text> </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" /> <Ionicons name="close" size={26} color="#fff" />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={styles.previewImageArea}> {/* 单张照片 */}
{previewIndex !== null && allPhotos[previewIndex] && ( <View style={styles.photoArea}>
{current && (
<Image <Image
source={{ uri: allPhotos[previewIndex].photo.filePath }} source={{ uri: current.photo.filePath }}
style={styles.previewImg} style={StyleSheet.absoluteFill}
resizeMode="contain" resizeMode="contain"
/> />
)} )}
</View> </View>
{/* 左箭头 */}
{previewIndex !== null && previewIndex > 0 && ( {previewIndex !== null && previewIndex > 0 && (
<TouchableOpacity <TouchableOpacity style={styles.arrowLeft} onPress={goPrev}>
style={styles.arrowLeft}
onPress={() => setPreviewIndex((i) => Math.max(0, (i ?? 1) - 1))}
>
<Ionicons name="chevron-back" size={34} color="#fff" /> <Ionicons name="chevron-back" size={34} color="#fff" />
</TouchableOpacity> </TouchableOpacity>
)} )}
{/* 右箭头 */}
{previewIndex !== null && previewIndex < totalPhotos - 1 && ( {previewIndex !== null && previewIndex < totalPhotos - 1 && (
<TouchableOpacity <TouchableOpacity style={styles.arrowRight} onPress={goNext}>
style={styles.arrowRight}
onPress={() => setPreviewIndex((i) => Math.min(totalPhotos - 1, (i ?? 0) + 1))}
>
<Ionicons name="chevron-forward" size={34} color="#fff" /> <Ionicons name="chevron-forward" size={34} color="#fff" />
</TouchableOpacity> </TouchableOpacity>
)} )}
@ -220,22 +234,25 @@ const styles = StyleSheet.create({
empty: { alignItems: 'center', paddingTop: 60, gap: 12 }, empty: { alignItems: 'center', paddingTop: 60, gap: 12 },
emptyText: { fontSize: 14, color: '#9CA3AF' }, emptyText: { fontSize: 14, color: '#9CA3AF' },
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.94)', justifyContent: 'center' }, overlay: {
flex: 1,
backgroundColor: '#000',
},
previewTopBar: { previewTopBar: {
position: 'absolute',
top: 0, left: 0, right: 0,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
paddingHorizontal: 16, paddingHorizontal: 16,
paddingTop: 52, paddingTop: 52,
paddingBottom: 12, paddingBottom: 12,
zIndex: 10,
}, },
previewItemName: { flex: 1, color: '#fff', fontSize: 15, fontWeight: '600' }, previewItemName: { flex: 1, color: '#fff', fontSize: 15, fontWeight: '600' },
previewCounter: { color: 'rgba(255,255,255,0.6)', fontSize: 13, marginHorizontal: 12 }, 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 }, photoArea: {
arrowLeft: { position: 'absolute', left: 8, top: '50%', padding: 8, zIndex: 10 }, flex: 1,
arrowRight: { position: 'absolute', right: 8, top: '50%', padding: 8, zIndex: 10 }, },
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 { import {
Alert, Alert,
FlatList,
Image, Image,
Modal, Modal,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity, TouchableOpacity,
useWindowDimensions,
View, View,
} from 'react-native' } from 'react-native'
import { Ionicons } from '@expo/vector-icons' import { Ionicons } from '@expo/vector-icons'
@ -23,6 +25,8 @@ export function PhotoGrid({ itemId }: PhotoGridProps) {
const [photos, setPhotos] = useState<Photo[]>([]) const [photos, setPhotos] = useState<Photo[]>([])
const [previewIndex, setPreviewIndex] = useState<number | null>(null) const [previewIndex, setPreviewIndex] = useState<number | null>(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const { width: screenWidth } = useWindowDimensions()
const flatListRef = useRef<FlatList<Photo>>(null)
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@ -117,22 +121,33 @@ export function PhotoGrid({ itemId }: PhotoGridProps) {
<Ionicons name="download-outline" size={22} color="#fff" /> <Ionicons name="download-outline" size={22} color="#fff" />
</TouchableOpacity> </TouchableOpacity>
<ScrollView <FlatList
ref={flatListRef}
data={photos}
horizontal horizontal
pagingEnabled pagingEnabled
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentOffset={{ x: (previewIndex ?? 0) * 400, y: 0 }} keyExtractor={(p) => String(p.id)}
> initialScrollIndex={previewIndex ?? 0}
{photos.map((photo) => ( getItemLayout={(_, index) => ({
<View key={photo.id} style={styles.previewPage}> 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 <Image
source={{ uri: photo.filePath }} source={{ uri: photo.filePath }}
style={styles.previewImg} style={[styles.previewImg, { width: screenWidth }]}
resizeMode="contain" resizeMode="contain"
/> />
</View> </View>
))} )}
</ScrollView> />
<Text style={styles.counter}> <Text style={styles.counter}>
{(previewIndex ?? 0) + 1} / {photos.length} {(previewIndex ?? 0) + 1} / {photos.length}
</Text> </Text>
@ -175,12 +190,10 @@ const styles = StyleSheet.create({
}, },
saveBtnDisabled: { opacity: 0.4 }, saveBtnDisabled: { opacity: 0.4 },
previewPage: { previewPage: {
width: 400,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
previewImg: { previewImg: {
width: 400,
height: 500, height: 500,
}, },
counter: { counter: {

View File

@ -12,8 +12,8 @@ export function PhotoPicker({ itemId, onAdded }: PhotoPickerProps) {
function showPicker() { function showPicker() {
Alert.alert('添加照片', '请选择来源', [ Alert.alert('添加照片', '请选择来源', [
{ text: '拍照', onPress: () => capture('camera') }, { text: '拍照', onPress: () => setTimeout(() => capture('camera'), 300) },
{ text: '从相册选择', onPress: () => capture('library') }, { text: '从相册选择', onPress: () => setTimeout(() => capture('library'), 300) },
{ text: '取消', style: 'cancel' }, { text: '取消', style: 'cancel' },
]) ])
} }

View File

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