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:
parent
cea2a5d208
commit
cb7c5ad7f4
@ -2,7 +2,7 @@
|
||||
"devices": [
|
||||
{
|
||||
"installationId": "626cc0bf-3d4a-47d1-9fd9-50b31e88db04",
|
||||
"lastUsed": 1775747314027
|
||||
"lastUsed": 1775749877472
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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"/>
|
||||
|
||||
@ -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>
|
||||
@ -25,6 +25,14 @@
|
||||
"package": "com.example.propertysurvey"
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"cameraPermission": "允许访问相机以拍摄照片",
|
||||
"microphonePermission": false,
|
||||
"photosPermission": "允许访问相册以选择照片"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-media-library",
|
||||
{
|
||||
|
||||
@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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],
|
||||
// 复制时不继承确认状态,新标记点重新确认
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'))
|
||||
)
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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 },
|
||||
})
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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' },
|
||||
])
|
||||
}
|
||||
|
||||
@ -31,6 +31,7 @@ export interface Item {
|
||||
estimatedValue: number
|
||||
referencePrice: number
|
||||
remark: string
|
||||
confirmedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user