feat: 新增物品分类管理与第四楼支持

- 新增「设置」Tab,支持物品分类的增删改(数据库持久化)
- 物品分类由硬编码枚举迁移为动态分类,通过 CategoriesContext 全局共享
- items 表新增 unit(单位)、spec(规格)字段,附带旧库迁移
- markers 表 CHECK 约束扩展支持第 4 楼(楼顶),附带重建表迁移
- SVG 地图渲染:overdraw 预渲染提升清晰度,嵌入图片改用原生 Image 渲染
- FoldableLayout 改为 showDetail prop 控制动画宽度

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
lanrtop 2026-04-09 23:12:52 +09:00
parent 4e1d92264d
commit eab08d8c96
23 changed files with 935 additions and 251 deletions

View File

@ -0,0 +1,8 @@
{
"devices": [
{
"installationId": "626cc0bf-3d4a-47d1-9fd9-50b31e88db04",
"lastUsed": 1775741394159
}
]
}

View File

@ -6,9 +6,9 @@ export * from 'expo-router';
declare module 'expo-router' { declare module 'expo-router' {
export namespace ExpoRouter { export namespace ExpoRouter {
export interface __routes<T extends string | object = string> { export interface __routes<T extends string | object = string> {
hrefInputParams: { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/`; params?: Router.UnknownInputParams; } | { pathname: `/summary`; params?: Router.UnknownInputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; }; hrefInputParams: { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/`; params?: Router.UnknownInputParams; } | { pathname: `/settings`; params?: Router.UnknownInputParams; } | { pathname: `/summary`; params?: Router.UnknownInputParams; } | { pathname: `/../src/features/item/ItemFormSheet`; params?: Router.UnknownInputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; };
hrefOutputParams: { pathname: Router.RelativePathString, params?: Router.UnknownOutputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownOutputParams } | { pathname: `/`; params?: Router.UnknownOutputParams; } | { pathname: `/summary`; params?: Router.UnknownOutputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownOutputParams; }; hrefOutputParams: { pathname: Router.RelativePathString, params?: Router.UnknownOutputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownOutputParams } | { pathname: `/`; params?: Router.UnknownOutputParams; } | { pathname: `/settings`; params?: Router.UnknownOutputParams; } | { pathname: `/summary`; params?: Router.UnknownOutputParams; } | { pathname: `/../src/features/item/ItemFormSheet`; params?: Router.UnknownOutputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownOutputParams; };
href: Router.RelativePathString | Router.ExternalPathString | `/${`?${string}` | `#${string}` | ''}` | `/summary${`?${string}` | `#${string}` | ''}` | `/_sitemap${`?${string}` | `#${string}` | ''}` | { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/`; params?: Router.UnknownInputParams; } | { pathname: `/summary`; params?: Router.UnknownInputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; }; href: Router.RelativePathString | Router.ExternalPathString | `/${`?${string}` | `#${string}` | ''}` | `/settings${`?${string}` | `#${string}` | ''}` | `/summary${`?${string}` | `#${string}` | ''}` | `/../src/features/item/ItemFormSheet${`?${string}` | `#${string}` | ''}` | `/_sitemap${`?${string}` | `#${string}` | ''}` | { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/`; params?: Router.UnknownInputParams; } | { pathname: `/settings`; params?: Router.UnknownInputParams; } | { pathname: `/summary`; params?: Router.UnknownInputParams; } | { pathname: `/../src/features/item/ItemFormSheet`; params?: Router.UnknownInputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; };
} }
} }
} }

View File

@ -4,6 +4,7 @@ import { StatusBar } from 'expo-status-bar'
import { ActivityIndicator, View } from 'react-native' import { ActivityIndicator, View } from 'react-native'
import { SafeAreaProvider } from 'react-native-safe-area-context' import { SafeAreaProvider } from 'react-native-safe-area-context'
import { initDatabase } from '../src/db/database' import { initDatabase } from '../src/db/database'
import { CategoriesProvider } from '../src/features/settings/CategoriesContext'
export default function RootLayout() { export default function RootLayout() {
const [dbReady, setDbReady] = useState(false) const [dbReady, setDbReady] = useState(false)
@ -28,6 +29,7 @@ export default function RootLayout() {
return ( return (
<SafeAreaProvider> <SafeAreaProvider>
<StatusBar style="auto" /> <StatusBar style="auto" />
<CategoriesProvider>
<Tabs <Tabs
screenOptions={{ screenOptions={{
headerShown: true, headerShown: true,
@ -49,7 +51,15 @@ export default function RootLayout() {
tabBarLabel: '汇总', tabBarLabel: '汇总',
}} }}
/> />
<Tabs.Screen
name="settings"
options={{
title: '设置',
tabBarLabel: '设置',
}}
/>
</Tabs> </Tabs>
</CategoriesProvider>
</SafeAreaProvider> </SafeAreaProvider>
) )
} }

View File

@ -2,7 +2,6 @@ import { useState } from 'react'
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native' import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { GestureHandlerRootView } from 'react-native-gesture-handler' import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { Ionicons } from '@expo/vector-icons'
import { FoldableLayout } from '../src/layout/FoldableLayout' import { FoldableLayout } from '../src/layout/FoldableLayout'
import { MarkerList } from '../src/features/map/MarkerList' import { MarkerList } from '../src/features/map/MarkerList'
import { ItemFormSheet } from '../src/features/item/ItemFormSheet' import { ItemFormSheet } from '../src/features/item/ItemFormSheet'
@ -10,7 +9,8 @@ import { MarkerPhotoPanel } from '../src/features/photo/MarkerPhotoPanel'
import type { FloorNumber, Marker } from '@repo/types' import type { FloorNumber, Marker } from '@repo/types'
type AppMode = 'view' | 'edit' type AppMode = 'view' | 'edit'
const FLOORS: FloorNumber[] = [1, 2, 3] const FLOORS: FloorNumber[] = [1, 2, 3, 4]
const FLOOR_LABEL: Record<FloorNumber, string> = { 1: '1F', 2: '2F', 3: '3F', 4: '楼顶' }
export default function RegisterScreen() { export default function RegisterScreen() {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
@ -28,7 +28,6 @@ export default function RegisterScreen() {
setSelectedMarker(null) setSelectedMarker(null)
} }
// 右栏:有标记选中时显示内容,否则显示引导占位
const rightPanel = selectedMarker const rightPanel = selectedMarker
? mode === 'view' ? mode === 'view'
? <MarkerPhotoPanel marker={selectedMarker} onClose={() => setSelectedMarker(null)} /> ? <MarkerPhotoPanel marker={selectedMarker} onClose={() => setSelectedMarker(null)} />
@ -37,7 +36,7 @@ export default function RegisterScreen() {
onClose={() => setSelectedMarker(null)} onClose={() => setSelectedMarker(null)}
onMarkerUpdated={() => setMarkersRefreshKey((k) => k + 1)} onMarkerUpdated={() => setMarkersRefreshKey((k) => k + 1)}
/> />
: <RightPlaceholder mode={mode} /> : null
return ( return (
<GestureHandlerRootView style={styles.root}> <GestureHandlerRootView style={styles.root}>
@ -67,7 +66,7 @@ export default function RegisterScreen() {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.floorBtnText, currentFloor === f && styles.floorBtnTextActive]}> <Text style={[styles.floorBtnText, currentFloor === f && styles.floorBtnTextActive]}>
{f}F {FLOOR_LABEL[f]}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
@ -85,26 +84,12 @@ export default function RegisterScreen() {
/> />
} }
right={rightPanel} right={rightPanel}
showDetail={selectedMarker !== null}
/> />
</GestureHandlerRootView> </GestureHandlerRootView>
) )
} }
// 未选中标记时右栏的引导占位
function RightPlaceholder({ mode }: { mode: AppMode }) {
return (
<View style={placeholder.container}>
<Ionicons
name={mode === 'view' ? 'eye-outline' : 'create-outline'}
size={36}
color="#D1D5DB"
/>
<Text style={placeholder.text}>
{mode === 'view' ? '选择左侧标记点\n查看照片' : '选择左侧标记点\n开始编辑'}
</Text>
</View>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
root: { flex: 1 }, root: { flex: 1 },
@ -151,20 +136,3 @@ const styles = StyleSheet.create({
floorBtnTextActive: { color: '#fff' }, floorBtnTextActive: { color: '#fff' },
}) })
const placeholder = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
backgroundColor: '#FAFAFA',
borderLeftWidth: StyleSheet.hairlineWidth,
borderLeftColor: '#E5E7EB',
},
text: {
fontSize: 13,
color: '#9CA3AF',
textAlign: 'center',
lineHeight: 20,
},
})

View File

@ -0,0 +1,211 @@
import { useCallback, useState } from 'react'
import {
Alert,
FlatList,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native'
import { Ionicons } from '@expo/vector-icons'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { deleteCategory, insertCategory, updateCategory, type Category } from '../src/db/categories'
import { useCategories } from '../src/features/settings/CategoriesContext'
export default function SettingsScreen() {
const insets = useSafeAreaInsets()
const { categories, reload } = useCategories()
const [newName, setNewName] = useState('')
const [editingId, setEditingId] = useState<number | null>(null)
const [editingName, setEditingName] = useState('')
const handleAdd = useCallback(async () => {
const name = newName.trim()
if (!name) return
try {
await insertCategory(name)
setNewName('')
await reload()
} catch (err) {
console.error('新增分类失败:', err)
}
}, [newName, reload])
function startEdit(cat: Category) {
setEditingId(cat.id)
setEditingName(cat.name)
}
const handleUpdate = useCallback(async () => {
if (editingId === null) return
const name = editingName.trim()
if (!name) return
try {
await updateCategory(editingId, name)
setEditingId(null)
await reload()
} catch (err) {
console.error('更新分类失败:', err)
}
}, [editingId, editingName, reload])
function handleDelete(cat: Category) {
Alert.alert(
'删除分类',
`确认删除「${cat.name}」?已登记物品的分类标签不受影响。`,
[
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
onPress: async () => {
try {
await deleteCategory(cat.id)
await reload()
} catch (err) {
console.error('删除分类失败:', err)
}
},
},
],
)
}
return (
<View style={[styles.container, { paddingBottom: insets.bottom }]}>
<Text style={styles.sectionTitle}></Text>
<FlatList
data={categories}
keyExtractor={(item) => String(item.id)}
style={styles.list}
ItemSeparatorComponent={() => <View style={styles.separator} />}
renderItem={({ item }) =>
editingId === item.id ? (
<View style={styles.row}>
<TextInput
style={styles.editInput}
value={editingName}
onChangeText={setEditingName}
autoFocus
returnKeyType="done"
onSubmitEditing={handleUpdate}
/>
<TouchableOpacity onPress={handleUpdate} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Ionicons name="checkmark" size={22} color="#10B981" />
</TouchableOpacity>
<TouchableOpacity onPress={() => setEditingId(null)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Ionicons name="close" size={22} color="#9CA3AF" />
</TouchableOpacity>
</View>
) : (
<View style={styles.row}>
<Text style={styles.rowName}>{item.name}</Text>
<TouchableOpacity onPress={() => startEdit(item)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Ionicons name="pencil-outline" size={18} color="#6B7280" />
</TouchableOpacity>
<TouchableOpacity onPress={() => handleDelete(item)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Ionicons name="trash-outline" size={18} color="#EF4444" />
</TouchableOpacity>
</View>
)
}
/>
{/* 新增行 */}
<View style={styles.addRow}>
<TextInput
style={styles.addInput}
value={newName}
onChangeText={setNewName}
placeholder="输入新分类名称"
placeholderTextColor="#9CA3AF"
returnKeyType="done"
onSubmitEditing={handleAdd}
/>
<TouchableOpacity style={styles.addBtn} onPress={handleAdd} activeOpacity={0.8}>
<Ionicons name="add" size={22} color="#fff" />
</TouchableOpacity>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F9FAFB' },
sectionTitle: {
fontSize: 12,
fontWeight: '600',
color: '#6B7280',
textTransform: 'uppercase',
letterSpacing: 0.5,
paddingHorizontal: 16,
paddingTop: 20,
paddingBottom: 8,
},
list: {
backgroundColor: '#fff',
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: '#E5E7EB',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#E5E7EB',
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: '#E5E7EB',
marginLeft: 16,
},
row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 13,
gap: 12,
backgroundColor: '#fff',
},
rowName: { flex: 1, fontSize: 15, color: '#111827' },
editInput: {
flex: 1,
fontSize: 15,
color: '#111827',
borderBottomWidth: 1.5,
borderBottomColor: '#10B981',
paddingVertical: 2,
},
addRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingHorizontal: 16,
paddingVertical: 16,
},
addInput: {
flex: 1,
height: 44,
borderWidth: 1,
borderColor: '#D1D5DB',
borderRadius: 10,
paddingHorizontal: 14,
fontSize: 15,
backgroundColor: '#fff',
color: '#111827',
},
addBtn: {
width: 44,
height: 44,
borderRadius: 10,
backgroundColor: '#10B981',
alignItems: 'center',
justifyContent: 'center',
},
})

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

@ -0,0 +1,37 @@
import { getDatabase } from './database'
export interface Category {
id: number
name: string
sortOrder: number
}
export async function getCategories(): Promise<Category[]> {
const db = getDatabase()
return db.getAllAsync<Category>(
`SELECT id, name, sort_order as sortOrder FROM categories ORDER BY sort_order ASC, id ASC`,
)
}
export async function insertCategory(name: string): Promise<Category> {
const db = getDatabase()
const maxRow = await db.getFirstAsync<{ max: number }>(
`SELECT COALESCE(MAX(sort_order), 0) as max FROM categories`,
)
const sortOrder = (maxRow?.max ?? 0) + 1
const result = await db.runAsync(
`INSERT INTO categories (name, sort_order) VALUES (?, ?)`,
[name, sortOrder],
)
return { id: result.lastInsertRowId, name, sortOrder }
}
export async function updateCategory(id: number, name: string): Promise<void> {
const db = getDatabase()
await db.runAsync(`UPDATE categories SET name = ? WHERE id = ?`, [name, id])
}
export async function deleteCategory(id: number): Promise<void> {
const db = getDatabase()
await db.runAsync(`DELETE FROM categories WHERE id = ?`, [id])
}

View File

@ -1,5 +1,5 @@
import * as SQLite from 'expo-sqlite' import * as SQLite from 'expo-sqlite'
import { CREATE_ITEMS_TABLE, CREATE_MARKERS_TABLE, CREATE_PHOTOS_TABLE } from './schema' import { CREATE_CATEGORIES_TABLE, CREATE_ITEMS_TABLE, CREATE_MARKERS_TABLE, CREATE_PHOTOS_TABLE, DEFAULT_CATEGORIES } from './schema'
let db: SQLite.SQLiteDatabase | null = null let db: SQLite.SQLiteDatabase | null = null
@ -18,7 +18,43 @@ export async function initDatabase(): Promise<void> {
${CREATE_MARKERS_TABLE}; ${CREATE_MARKERS_TABLE};
${CREATE_ITEMS_TABLE}; ${CREATE_ITEMS_TABLE};
${CREATE_PHOTOS_TABLE}; ${CREATE_PHOTOS_TABLE};
${CREATE_CATEGORIES_TABLE};
`) `)
// 首次初始化:写入默认分类
const catCount = await database.getFirstAsync<{ n: number }>(`SELECT COUNT(*) as n FROM categories`)
if ((catCount?.n ?? 0) === 0) {
for (let i = 0; i < DEFAULT_CATEGORIES.length; i++) {
await database.runAsync(
`INSERT OR IGNORE INTO categories (name, sort_order) VALUES (?, ?)`,
[DEFAULT_CATEGORIES[i], i + 1],
)
}
}
// 迁移:更新 markers 表 floor CHECK 约束以支持楼顶4
// SQLite 不支持直接修改约束,需重建表
const markerCheckRow = await database.getFirstAsync<{ sql: string }>(
`SELECT sql FROM sqlite_master WHERE type='table' AND name='markers'`
)
if (markerCheckRow && !markerCheckRow.sql.includes('1, 2, 3, 4')) {
await database.execAsync(`
PRAGMA foreign_keys = OFF;
BEGIN;
CREATE TABLE IF NOT EXISTS markers_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
floor INTEGER NOT NULL CHECK(floor IN (1, 2, 3, 4)),
x REAL NOT NULL,
y REAL NOT NULL,
label TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '#3B82F6',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO markers_new SELECT id, floor, x, y, label, color, created_at FROM markers;
DROP TABLE markers;
ALTER TABLE markers_new RENAME TO markers;
COMMIT;
PRAGMA foreign_keys = ON;
`)
}
// 迁移:为旧数据库添加 color 列 // 迁移:为旧数据库添加 color 列
const markerCols = await database.getAllAsync<{ name: string }>(`PRAGMA table_info(markers)`) const markerCols = await database.getAllAsync<{ name: string }>(`PRAGMA table_info(markers)`)
if (!markerCols.some((c) => c.name === 'color')) { if (!markerCols.some((c) => c.name === 'color')) {
@ -29,4 +65,12 @@ export async function initDatabase(): Promise<void> {
if (!itemCols.some((c) => c.name === 'reference_price')) { if (!itemCols.some((c) => c.name === 'reference_price')) {
await database.execAsync(`ALTER TABLE items ADD COLUMN reference_price REAL NOT NULL DEFAULT 0`) await database.execAsync(`ALTER TABLE items ADD COLUMN reference_price REAL NOT NULL DEFAULT 0`)
} }
// 迁移:为旧数据库添加 spec 列
if (!itemCols.some((c) => c.name === 'spec')) {
await database.execAsync(`ALTER TABLE items ADD COLUMN spec TEXT NOT NULL DEFAULT ''`)
}
// 迁移:为旧数据库添加 unit 列
if (!itemCols.some((c) => c.name === 'unit')) {
await database.execAsync(`ALTER TABLE items ADD COLUMN unit TEXT NOT NULL DEFAULT ''`)
}
} }

View File

@ -4,20 +4,22 @@ 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, estimated_value, reference_price, remark) `INSERT INTO items (marker_id, name, category, quantity, unit, spec, estimated_value, reference_price, remark)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
params.markerId, params.markerId,
params.name, params.name,
params.category, params.category,
params.quantity, params.quantity,
params.unit,
params.spec,
params.estimatedValue, params.estimatedValue,
params.referencePrice, params.referencePrice,
params.remark, params.remark,
], ],
) )
const row = await db.getFirstAsync<Item>( const row = await db.getFirstAsync<Item>(
`SELECT id, marker_id as markerId, name, category, quantity, `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 created_at as createdAt, updated_at as updatedAt
FROM items WHERE id = ?`, FROM items WHERE id = ?`,
@ -36,7 +38,7 @@ export async function getItemsByMarker(
const ids = Array.isArray(markerIdOrIds) ? markerIdOrIds : [markerIdOrIds] const ids = Array.isArray(markerIdOrIds) ? markerIdOrIds : [markerIdOrIds]
const placeholders = ids.map(() => '?').join(', ') const placeholders = ids.map(() => '?').join(', ')
return db.getAllAsync<ItemWithPhotoCount>( return db.getAllAsync<ItemWithPhotoCount>(
`SELECT i.id, i.marker_id as markerId, i.name, i.category, i.quantity, `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.created_at as createdAt, i.updated_at as updatedAt,
COUNT(p.id) as photoCount COUNT(p.id) as photoCount
@ -60,6 +62,8 @@ export async function updateItem(
if (params.name !== undefined) { fields.push('name = ?'); values.push(params.name) } if (params.name !== undefined) { fields.push('name = ?'); values.push(params.name) }
if (params.category !== undefined) { fields.push('category = ?'); values.push(params.category) } if (params.category !== undefined) { fields.push('category = ?'); values.push(params.category) }
if (params.quantity !== undefined) { fields.push('quantity = ?'); values.push(params.quantity) } if (params.quantity !== undefined) { fields.push('quantity = ?'); values.push(params.quantity) }
if (params.unit !== undefined) { fields.push('unit = ?'); values.push(params.unit) }
if (params.spec !== undefined) { fields.push('spec = ?'); values.push(params.spec) }
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) }
@ -73,17 +77,17 @@ export async function updateItem(
export async function copyItemsToMarker(fromMarkerId: number, toMarkerId: number): Promise<void> { export async function copyItemsToMarker(fromMarkerId: number, toMarkerId: number): Promise<void> {
const db = getDatabase() const db = getDatabase()
const items = await db.getAllAsync<{ const items = await db.getAllAsync<{
name: string; category: string; quantity: number name: string; category: string; quantity: number; unit: string; spec: string
estimated_value: number; reference_price: number; remark: string estimated_value: number; reference_price: number; remark: string
}>( }>(
`SELECT name, category, quantity, estimated_value, reference_price, remark FROM items WHERE marker_id = ?`, `SELECT name, category, quantity, unit, spec, estimated_value, reference_price, remark FROM items WHERE marker_id = ?`,
[fromMarkerId], [fromMarkerId],
) )
for (const item of items) { for (const item of items) {
await db.runAsync( await db.runAsync(
`INSERT INTO items (marker_id, name, category, quantity, 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.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

@ -1,7 +1,7 @@
export const CREATE_MARKERS_TABLE = ` export const CREATE_MARKERS_TABLE = `
CREATE TABLE IF NOT EXISTS markers ( CREATE TABLE IF NOT EXISTS markers (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
floor INTEGER NOT NULL CHECK(floor IN (1, 2, 3)), floor INTEGER NOT NULL CHECK(floor IN (1, 2, 3, 4)),
x REAL NOT NULL, x REAL NOT NULL,
y REAL NOT NULL, y REAL NOT NULL,
label TEXT NOT NULL DEFAULT '', label TEXT NOT NULL DEFAULT '',
@ -17,6 +17,8 @@ export const CREATE_ITEMS_TABLE = `
name TEXT NOT NULL, name TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'other', category TEXT NOT NULL DEFAULT 'other',
quantity INTEGER NOT NULL DEFAULT 1, quantity INTEGER NOT NULL DEFAULT 1,
unit TEXT NOT NULL DEFAULT '',
spec TEXT NOT NULL DEFAULT '',
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 '',
@ -25,6 +27,16 @@ export const CREATE_ITEMS_TABLE = `
) )
` `
export const CREATE_CATEGORIES_TABLE = `
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
sort_order INTEGER NOT NULL DEFAULT 0
)
`
export const DEFAULT_CATEGORIES = ['家具', '家电', '电子', '车辆', '其他']
export const CREATE_PHOTOS_TABLE = ` export const CREATE_PHOTOS_TABLE = `
CREATE TABLE IF NOT EXISTS photos ( CREATE TABLE IF NOT EXISTS photos (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@ -9,26 +9,19 @@ import {
View, View,
} from 'react-native' } from 'react-native'
import { Ionicons } from '@expo/vector-icons' import { Ionicons } from '@expo/vector-icons'
import type { ItemCategory } from '@repo/types'
import type { useItemForm } from './useItemForm' import type { useItemForm } from './useItemForm'
import { PhotoGrid } from '../photo/PhotoGrid' import { PhotoGrid } from '../photo/PhotoGrid'
import { useCategories } from '../settings/CategoriesContext'
type FormHook = ReturnType<typeof useItemForm> type FormHook = ReturnType<typeof useItemForm>
const CATEGORIES: { value: ItemCategory; label: string }[] = [
{ value: 'furniture', label: '家具' },
{ value: 'appliance', label: '家电' },
{ value: 'electronics', label: '电子' },
{ value: 'vehicle', label: '车辆' },
{ value: 'other', label: '其他' },
]
interface ItemFieldsProps { interface ItemFieldsProps {
hook: FormHook hook: FormHook
} }
export function ItemFields({ hook }: ItemFieldsProps) { export function ItemFields({ hook }: ItemFieldsProps) {
const { form, setField, nameError, photoItemId, save } = hook const { form, setField, nameError, photoItemId } = hook
const { categories } = useCategories()
return ( return (
<View style={styles.root}> <View style={styles.root}>
@ -57,20 +50,20 @@ export function ItemFields({ hook }: ItemFieldsProps) {
{/* 类别 */} {/* 类别 */}
<View style={styles.categoryRow}> <View style={styles.categoryRow}>
{CATEGORIES.map((cat) => ( {categories.map((cat) => (
<TouchableOpacity <TouchableOpacity
key={cat.value} key={cat.id}
style={[styles.chip, form.category === cat.value && styles.chipActive]} style={[styles.chip, form.category === cat.name && styles.chipActive]}
onPress={() => setField('category', cat.value)} onPress={() => setField('category', cat.name)}
> >
<Text style={[styles.chipLabel, form.category === cat.value && styles.chipLabelActive]}> <Text style={[styles.chipLabel, form.category === cat.name && styles.chipLabelActive]}>
{cat.label} {cat.name}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </View>
{/* 数量 / 单价 / 参考价 */} {/* 数量 / 规格 / 单价 / 参考价 */}
<View style={styles.card}> <View style={styles.card}>
<View style={styles.fieldRow}> <View style={styles.fieldRow}>
<Text style={styles.fieldLabel}></Text> <Text style={styles.fieldLabel}></Text>
@ -84,6 +77,19 @@ export function ItemFields({ hook }: ItemFieldsProps) {
/> />
</View> </View>
<View style={styles.divider} /> <View style={styles.divider} />
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}></Text>
<TextInput
style={styles.fieldInput}
value={form.unit}
onChangeText={(v) => setField('unit', v)}
placeholder="如:台、件、套"
placeholderTextColor="#9CA3AF"
returnKeyType="next"
textAlign="right"
/>
</View>
<View style={styles.divider} />
<View style={styles.fieldRow}> <View style={styles.fieldRow}>
<Text style={styles.fieldLabel}></Text> <Text style={styles.fieldLabel}></Text>
<TextInput <TextInput
@ -113,6 +119,19 @@ export function ItemFields({ hook }: ItemFieldsProps) {
</View> </View>
</View> </View>
{/* 规格 */}
<View style={styles.card}>
<TextInput
style={styles.remarkInput}
value={form.spec}
onChangeText={(v) => setField('spec', v)}
multiline
placeholder="规格1.8m×2m"
placeholderTextColor="#9CA3AF"
textAlignVertical="top"
/>
</View>
{/* 备注 */} {/* 备注 */}
<View style={styles.card}> <View style={styles.card}>
<TextInput <TextInput
@ -126,13 +145,6 @@ export function ItemFields({ hook }: ItemFieldsProps) {
/> />
</View> </View>
{/* 保存 */}
<View style={styles.saveRow}>
<TouchableOpacity style={styles.saveBtn} onPress={save}>
<Text style={styles.saveBtnText}></Text>
</TouchableOpacity>
</View>
<View style={styles.bottomSpacer} /> <View style={styles.bottomSpacer} />
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
@ -214,15 +226,6 @@ const styles = StyleSheet.create({
errorText: { fontSize: 12, color: '#EF4444', marginLeft: 4 }, errorText: { fontSize: 12, color: '#EF4444', marginLeft: 4 },
saveRow: { alignItems: 'flex-end' },
saveBtn: {
backgroundColor: '#10B981',
borderRadius: 8,
paddingVertical: 9,
paddingHorizontal: 24,
},
saveBtnText: { color: '#fff', fontSize: 14, fontWeight: '700' },
bottomSpacer: { height: 24 }, bottomSpacer: { height: 24 },
// 分隔线 // 分隔线

View File

@ -54,30 +54,18 @@ export function ItemList({ markerId, onEditItem, onAddNew, refreshKey }: ItemLis
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
renderItem={({ item }) => ( renderItem={({ item }) => (
<TouchableOpacity style={styles.card} onPress={() => onEditItem(item)}>
<Text style={styles.name} numberOfLines={1}>
{item.name}
</Text>
<Text style={styles.meta}>×{item.quantity}</Text>
{item.estimatedValue > 0 && (
<Text style={styles.meta}>¥{item.estimatedValue}</Text>
)}
{item.photoCount > 0 && (
<Text style={styles.meta}>{item.photoCount}</Text>
)}
<TouchableOpacity <TouchableOpacity
style={styles.deleteBtn} style={styles.card}
onPress={() => handleDelete(item)} onPress={() => onEditItem(item)}
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} onLongPress={() => handleDelete(item)}
delayLongPress={500}
> >
<Ionicons name="trash-outline" size={14} color="#EF4444" /> <Text style={styles.name} numberOfLines={1}>{item.name}</Text>
</TouchableOpacity>
</TouchableOpacity> </TouchableOpacity>
)} )}
ListFooterComponent={ ListHeaderComponent={
<TouchableOpacity style={styles.addBtn} onPress={onAddNew}> <TouchableOpacity style={styles.addBtn} onPress={onAddNew}>
<Ionicons name="add-circle-outline" size={18} color="#10B981" /> <Ionicons name="add-circle-outline" size={22} color="#10B981" />
<Text style={styles.addBtnText}></Text>
</TouchableOpacity> </TouchableOpacity>
} }
/> />
@ -93,21 +81,13 @@ const styles = StyleSheet.create({
borderRadius: 8, borderRadius: 8,
paddingVertical: 8, paddingVertical: 8,
paddingHorizontal: 10, paddingHorizontal: 10,
minWidth: 72,
maxWidth: 110, maxWidth: 110,
position: 'relative',
}, },
name: { fontSize: 13, fontWeight: '600', color: '#111827', marginBottom: 2 }, name: { fontSize: 13, fontWeight: '600', color: '#111827' },
meta: { fontSize: 11, color: '#6B7280', lineHeight: 16 },
deleteBtn: { position: 'absolute', top: 4, right: 4 },
addBtn: { addBtn: {
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 2,
paddingVertical: 8, paddingVertical: 8,
paddingHorizontal: 10, paddingHorizontal: 8,
minWidth: 52,
}, },
addBtnText: { color: '#10B981', fontSize: 12, fontWeight: '600' },
}) })

View File

@ -1,11 +1,13 @@
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import type { Item, InsertItemParams, ItemCategory } from '@repo/types' import type { Item, InsertItemParams } from '@repo/types'
import { insertItem, updateItem } from '../../db/items' import { insertItem, updateItem } from '../../db/items'
interface FormState { interface FormState {
name: string name: string
category: ItemCategory category: string
quantity: string quantity: string
unit: string
spec: string
estimatedValue: string estimatedValue: string
referencePrice: string referencePrice: string
remark: string remark: string
@ -13,8 +15,10 @@ interface FormState {
const DEFAULT_FORM: FormState = { const DEFAULT_FORM: FormState = {
name: '', name: '',
category: 'other', category: '',
quantity: '1', quantity: '1',
unit: '',
spec: '',
estimatedValue: '', estimatedValue: '',
referencePrice: '', referencePrice: '',
remark: '', remark: '',
@ -24,11 +28,11 @@ export function useItemForm(markerId: number, onSaved: () => void) {
const [form, setForm] = useState<FormState>(DEFAULT_FORM) const [form, setForm] = useState<FormState>(DEFAULT_FORM)
const [editingId, setEditingId] = useState<number | null>(null) const [editingId, setEditingId] = useState<number | null>(null)
const [photoItemId, setPhotoItemId] = useState<number | null>(null) const [photoItemId, setPhotoItemId] = useState<number | null>(null)
const [nameError, setNameError] = useState('') const [isDirty, setIsDirty] = useState(false)
function setField<K extends keyof FormState>(key: K, value: FormState[K]) { function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => ({ ...prev, [key]: value })) setForm((prev) => ({ ...prev, [key]: value }))
if (key === 'name') setNameError('') setIsDirty(true)
} }
function startEdit(item: Item) { function startEdit(item: Item) {
@ -38,31 +42,33 @@ export function useItemForm(markerId: number, onSaved: () => void) {
name: item.name, name: item.name,
category: item.category, category: item.category,
quantity: String(item.quantity), quantity: String(item.quantity),
unit: item.unit,
spec: item.spec,
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,
}) })
setNameError('') setIsDirty(false)
} }
function resetForm() { function resetForm() {
setEditingId(null) setEditingId(null)
setPhotoItemId(null) setPhotoItemId(null)
setForm(DEFAULT_FORM) setForm(DEFAULT_FORM)
setNameError('') setIsDirty(false)
} }
const save = useCallback(async () => { // 自动保存:有名称且有改动时才执行,不弹错误
if (!form.name.trim()) { const autoSave = useCallback(async () => {
setNameError('物品名称不能为空') if (!form.name.trim() || !isDirty) return
return
}
const params: InsertItemParams = { const params: InsertItemParams = {
markerId, markerId,
name: form.name.trim(), name: form.name.trim(),
category: form.category, category: form.category,
quantity: Math.max(1, parseInt(form.quantity, 10) || 1), quantity: Math.max(1, parseInt(form.quantity, 10) || 1),
unit: form.unit.trim(),
spec: form.spec.trim(),
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(),
@ -71,19 +77,17 @@ export function useItemForm(markerId: number, onSaved: () => void) {
try { try {
if (editingId !== null) { if (editingId !== null) {
await updateItem(editingId, params) await updateItem(editingId, params)
resetForm()
} else { } else {
const saved = await insertItem(params) const saved = await insertItem(params)
setEditingId(saved.id) setEditingId(saved.id)
setPhotoItemId(saved.id) setPhotoItemId(saved.id)
setForm(DEFAULT_FORM)
setNameError('')
} }
setIsDirty(false)
onSaved() onSaved()
} catch (err) { } catch (err) {
console.error('保存物品失败:', err) console.error('自动保存失败:', err)
} }
}, [form, editingId, markerId, onSaved]) }, [form, editingId, isDirty, markerId, onSaved])
return { form, setField, nameError, editingId, photoItemId, startEdit, resetForm, save } return { form, setField, isDirty, editingId, photoItemId, startEdit, resetForm, autoSave }
} }

View File

@ -1,5 +1,5 @@
import { useCallback, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { PixelRatio, StyleSheet, View, type LayoutChangeEvent } from 'react-native' import { Image, PixelRatio, StyleSheet, View, type LayoutChangeEvent } from 'react-native'
import { Gesture, GestureDetector } from 'react-native-gesture-handler' import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import Animated, { import Animated, {
runOnJS, runOnJS,
@ -14,9 +14,23 @@ import { FLOOR_SVG } from './floorMaps.generated'
/** SVG 原始尺寸 3200×2133宽高比约 3:2 */ /** SVG 原始尺寸 3200×2133宽高比约 3:2 */
const ASPECT_RATIO = 3200 / 2133 const ASPECT_RATIO = 3200 / 2133
const SVG_WIDTH = 3200
const MIN_SCALE = 1 const MIN_SCALE = 1
const MAX_SCALE = 5 const MAX_SCALE = 5
/**
* SvgXml 使 overdraw
* Android Canvas 100MB 80MB
* pixels = (width * dpr * k)² / ASPECT_RATIO 80MB / 4
*/
function calcSafeOverdraw(mapWidth: number): number {
const dpr = PixelRatio.get()
const MAX_BYTES = 80 * 1024 * 1024
const maxPx = Math.sqrt((MAX_BYTES / 4) * ASPECT_RATIO) // max side in actual pixels
const maxOverdraw = Math.floor(maxPx / (mapWidth * dpr))
return Math.max(1, Math.min(MAX_SCALE, maxOverdraw))
}
export interface MapSize { export interface MapSize {
width: number width: number
height: number height: number
@ -42,6 +56,10 @@ export function FloorMap({
onMarkerAdded, onMarkerAdded,
}: FloorMapProps) { }: FloorMapProps) {
const [mapSize, setMapSize] = useState<MapSize | null>(null) const [mapSize, setMapSize] = useState<MapSize | null>(null)
const svgOverdraw = useMemo(
() => (mapSize ? calcSafeOverdraw(mapSize.width) : 1),
[mapSize],
)
// 地图尺寸存为 SharedValue供 worklet 手势直接读取 // 地图尺寸存为 SharedValue供 worklet 手势直接读取
const mapWidth = useSharedValue(0) const mapWidth = useSharedValue(0)
@ -132,12 +150,59 @@ export function FloorMap({
<Animated.View <Animated.View
style={[styles.map, { width: mapSize.width, height: mapSize.height }, animatedStyle]} style={[styles.map, { width: mapSize.width, height: mapSize.height }, animatedStyle]}
> >
<SvgXml {FLOOR_SVG[floor].imageUri && (
xml={FLOOR_SVG[floor]} <Image
width={mapSize.width} source={{ uri: FLOOR_SVG[floor].imageUri }}
height={mapSize.height} style={[StyleSheet.absoluteFill, { width: mapSize.width, height: mapSize.height }]}
resizeMode="stretch"
pointerEvents="none" pointerEvents="none"
/> />
)}
{/* SVG 矢量层:以 svgOverdraw 倍分辨率预渲染,在设备安全范围内尽量清晰 */}
<View
style={{
position: 'absolute',
top: 0,
left: 0,
transform: [
{ translateX: -mapSize.width * (svgOverdraw - 1) / 2 },
{ translateY: -mapSize.height * (svgOverdraw - 1) / 2 },
{ scale: 1 / svgOverdraw },
],
}}
pointerEvents="none"
>
<SvgXml
xml={FLOOR_SVG[floor].svgXml}
width={mapSize.width * svgOverdraw}
height={mapSize.height * svgOverdraw}
/>
</View>
{/* 用户嵌入图片:从 SVG 提取,用原生 Image 精确渲染位置和旋转 */}
{FLOOR_SVG[floor].embeddedImages.map((img, i) => {
const k = mapSize.width / SVG_WIDTH
const rnW = img.width * k
const rnH = img.height * k
const rnCx = img.cx * k
const rnCy = img.cy * k
return (
<Image
key={i}
source={{ uri: img.uri }}
style={{
position: 'absolute',
left: rnCx - rnW / 2,
top: rnCy - rnH / 2,
width: rnW,
height: rnH,
opacity: img.opacity,
transform: [{ rotate: `${img.angleDeg}deg` }],
}}
resizeMode="stretch"
pointerEvents="none"
/>
)
})}
{markers.map((marker) => ( {markers.map((marker) => (
<MapMarker <MapMarker
key={marker.id} key={marker.id}

View File

@ -16,9 +16,9 @@ import { Ionicons } from '@expo/vector-icons'
import type { Marker } from '@repo/types' import type { Marker } from '@repo/types'
import type { MapSize } from './FloorMap' import type { MapSize } from './FloorMap'
const ICON_SIZE = 28 const ICON_SIZE = 20
const BADGE_OFFSET = -6 const BADGE_OFFSET = -5
const RING_SIZE = 52 const RING_SIZE = 38
interface MapMarkerProps { interface MapMarkerProps {
marker: Marker marker: Marker
@ -163,17 +163,17 @@ const styles = StyleSheet.create({
position: 'absolute', position: 'absolute',
top: BADGE_OFFSET, top: BADGE_OFFSET,
right: BADGE_OFFSET, right: BADGE_OFFSET,
minWidth: 16, minWidth: 12,
height: 16, height: 12,
borderRadius: 8, borderRadius: 6,
backgroundColor: '#EF4444', backgroundColor: '#EF4444',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
paddingHorizontal: 3, paddingHorizontal: 2,
}, },
badgeText: { badgeText: {
color: '#fff', color: '#fff',
fontSize: 10, fontSize: 8,
fontWeight: '700', fontWeight: '700',
}, },
}) })

View File

@ -8,6 +8,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 }[]
} }
interface MarkerListProps { interface MarkerListProps {
@ -17,30 +18,25 @@ interface MarkerListProps {
refreshKey?: number refreshKey?: number
} }
function formatDateTime(raw: string): string {
const d = new Date(raw.replace(' ', 'T') + 'Z') type SidebarTab = 'detail' | 'aggregate'
if (isNaN(d.getTime())) return raw
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 MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey }: MarkerListProps) { export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey }: MarkerListProps) {
const [markers, setMarkers] = useState<MarkerWithItems[]>([]) const [markers, setMarkers] = useState<MarkerWithItems[]>([])
const [activeTab, setActiveTab] = useState<SidebarTab>('detail')
const loadMarkers = useCallback(async () => { const loadMarkers = useCallback(async () => {
try { try {
const rows = await getMarkersByFloor(floor) const rows = await getMarkersByFloor(floor)
if (rows.length === 0) { setMarkers([]); return } if (!rows || rows.length === 0) { setMarkers([]); return }
const allItems = await getItemsByMarker(rows.map((m) => m.id)) const allItems = (await getItemsByMarker(rows.map((m) => m.id))) ?? []
const withItems = rows.map((m) => { const withItems = rows.map((m) => {
const markerItems = allItems.filter((i) => i.markerId === m.id) const markerItems = allItems.filter((i) => i.markerId === m.id)
return { return {
...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 })),
} }
}) })
setMarkers(withItems) setMarkers(withItems)
@ -63,12 +59,27 @@ export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.sidebar}> <View style={styles.sidebar}>
<View style={styles.sidebarHeader}> {/* 页签 */}
<Text style={styles.headerLabel}></Text> <View style={styles.tabs}>
<Text style={styles.headerNum}>{markers.length}</Text> {(['detail', 'aggregate'] as SidebarTab[]).map((tab) => (
<Text style={styles.headerLabel}></Text> <TouchableOpacity
key={tab}
style={[styles.tab, activeTab === tab && styles.tabActive]}
onPress={() => setActiveTab(tab)}
activeOpacity={0.7}
>
<Text style={[styles.tabText, activeTab === tab && styles.tabTextActive]}>
{tab === 'detail' ? '明细' : '聚合'}
</Text>
</TouchableOpacity>
))}
</View> </View>
{activeTab === 'aggregate' ? (
<View style={styles.aggregatePlaceholder}>
<Text style={styles.emptyHint}>{'\n'}</Text>
</View>
) : (
<ScrollView style={styles.sidebarScroll} showsVerticalScrollIndicator={false}> <ScrollView style={styles.sidebarScroll} showsVerticalScrollIndicator={false}>
{markers.length === 0 && ( {markers.length === 0 && (
<Text style={styles.emptyHint}>{'\n'}</Text> <Text style={styles.emptyHint}>{'\n'}</Text>
@ -93,14 +104,14 @@ export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey
style={[styles.itemLabel, isSelected && styles.itemLabelSelected]} style={[styles.itemLabel, isSelected && styles.itemLabelSelected]}
numberOfLines={1} numberOfLines={1}
> >
{m.label || '未命名'} {m.label}
</Text> </Text>
{m.itemNames.length > 0 && ( {(m.itemSummaries?.length ?? 0) > 0 && m.itemSummaries.map((s, i) => (
<Text style={styles.itemNames} numberOfLines={2}> <View key={i} style={styles.itemSummaryRow}>
{m.itemNames.join('、')} <Text style={styles.itemNames} numberOfLines={1}>{s.name}</Text>
</Text> <Text style={styles.itemQty}>×{s.quantity}</Text>
)} </View>
<Text style={styles.itemDate}>{formatDateTime(m.createdAt)}</Text> ))}
</View> </View>
{m.itemCount > 0 && ( {m.itemCount > 0 && (
@ -112,6 +123,7 @@ export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey
) )
})} })}
</ScrollView> </ScrollView>
)}
</View> </View>
<View style={styles.mapArea}> <View style={styles.mapArea}>
@ -134,23 +146,29 @@ const styles = StyleSheet.create({
// ── 侧栏 ────────────────────────────────────────── // ── 侧栏 ──────────────────────────────────────────
sidebar: { sidebar: {
width: 110, width: 120,
borderRightWidth: StyleSheet.hairlineWidth, borderRightWidth: StyleSheet.hairlineWidth,
borderRightColor: '#E5E7EB', borderRightColor: '#E5E7EB',
backgroundColor: '#FAFAFA', backgroundColor: '#FAFAFA',
}, },
sidebarHeader: { tabs: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'baseline',
paddingHorizontal: 10,
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#E5E7EB', borderBottomColor: '#E5E7EB',
gap: 2,
}, },
headerLabel: { fontSize: 11, color: '#6B7280' }, tab: {
headerNum: { fontSize: 16, fontWeight: '700', color: '#111827' }, flex: 1,
paddingVertical: 8,
alignItems: 'center',
},
tabActive: {
borderBottomWidth: 2,
borderBottomColor: '#10B981',
},
tabText: { fontSize: 12, color: '#9CA3AF', fontWeight: '500' },
tabTextActive: { color: '#10B981', fontWeight: '700' },
sidebarScroll: { flex: 1 }, sidebarScroll: { flex: 1 },
aggregatePlaceholder: { flex: 1, alignItems: 'center', justifyContent: 'center' },
emptyHint: { emptyHint: {
fontSize: 11, fontSize: 11,
@ -170,7 +188,7 @@ const styles = StyleSheet.create({
borderBottomColor: '#F3F4F6', borderBottomColor: '#F3F4F6',
gap: 6, gap: 6,
}, },
itemSelected: { backgroundColor: '#F0FDF4' }, itemSelected: { backgroundColor: '#D1FAE5' },
// 左侧选中指示条 // 左侧选中指示条
activeBar: { activeBar: {
@ -191,10 +209,10 @@ 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: '#15803D' }, itemLabelSelected: { color: '#065F46' },
itemNames: { fontSize: 11, color: '#6B7280', marginTop: 2, lineHeight: 15 }, itemSummaryRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 2 },
itemDate: { fontSize: 10, color: '#9CA3AF', marginTop: 3 }, itemNames: { fontSize: 11, color: '#6B7280', flex: 1, lineHeight: 15 },
itemQty: { fontSize: 11, color: '#9CA3AF', lineHeight: 15, marginLeft: 4 },
countBadge: { countBadge: {
minWidth: 16, minWidth: 16,
height: 16, height: 16,

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,34 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
import { getCategories, type Category } from '../../db/categories'
interface CategoriesCtx {
categories: Category[]
reload: () => Promise<void>
}
const CategoriesContext = createContext<CategoriesCtx>({ categories: [], reload: async () => {} })
export function CategoriesProvider({ children }: { children: ReactNode }) {
const [categories, setCategories] = useState<Category[]>([])
const reload = useCallback(async () => {
try {
const cats = await getCategories()
setCategories(cats)
} catch (err) {
console.error('加载分类失败:', err)
}
}, [])
useEffect(() => { reload() }, [reload])
return (
<CategoriesContext.Provider value={{ categories, reload }}>
{children}
</CategoriesContext.Provider>
)
}
export function useCategories() {
return useContext(CategoriesContext)
}

View File

@ -8,6 +8,7 @@ const FLOOR_OPTIONS: { label: string; value: FloorFilter }[] = [
{ label: '1F', value: 1 }, { label: '1F', value: 1 },
{ label: '2F', value: 2 }, { label: '2F', value: 2 },
{ label: '3F', value: 3 }, { label: '3F', value: 3 },
{ label: '楼顶', value: 4 },
] ]
interface FilterBarProps { interface FilterBarProps {

View File

@ -1,7 +1,8 @@
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native' import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'
import type { FloorNumber } from '@repo/types' import type { FloorNumber } from '@repo/types'
const FLOORS: FloorNumber[] = [1, 2, 3] const FLOORS: FloorNumber[] = [1, 2, 3, 4]
const FLOOR_LABEL: Record<FloorNumber, string> = { 1: '1F', 2: '2F', 3: '3F', 4: '楼顶' }
interface FloorTabsProps { interface FloorTabsProps {
currentFloor: FloorNumber currentFloor: FloorNumber
@ -21,7 +22,7 @@ export function FloorTabs({ currentFloor, onChange }: FloorTabsProps) {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.label, active && styles.activeLabel]}> <Text style={[styles.label, active && styles.activeLabel]}>
{floor}F {FLOOR_LABEL[floor]}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
) )

View File

@ -1,5 +1,5 @@
import type { ReactNode } from 'react' import { type ReactNode, useEffect, useRef } from 'react'
import { StyleSheet, View } from 'react-native' import { Animated, StyleSheet, View } from 'react-native'
import { useFoldState } from './useFoldState' import { useFoldState } from './useFoldState'
const DETAIL_PANEL_WIDTH = 260 const DETAIL_PANEL_WIDTH = 260
@ -7,10 +7,20 @@ const DETAIL_PANEL_WIDTH = 260
interface FoldableLayoutProps { interface FoldableLayoutProps {
left: ReactNode left: ReactNode
right: ReactNode right: ReactNode
showDetail?: boolean
} }
export function FoldableLayout({ left, right }: FoldableLayoutProps) { export function FoldableLayout({ left, right, showDetail = false }: FoldableLayoutProps) {
const { isUnfolded } = useFoldState() const { isUnfolded } = useFoldState()
const panelWidth = useRef(new Animated.Value(showDetail ? DETAIL_PANEL_WIDTH : 0)).current
useEffect(() => {
Animated.timing(panelWidth, {
toValue: showDetail ? DETAIL_PANEL_WIDTH : 0,
duration: 220,
useNativeDriver: false,
}).start()
}, [showDetail])
if (!isUnfolded) { if (!isUnfolded) {
return <View style={styles.single}>{left}</View> return <View style={styles.single}>{left}</View>
@ -19,7 +29,9 @@ export function FoldableLayout({ left, right }: FoldableLayoutProps) {
return ( return (
<View style={styles.dual}> <View style={styles.dual}>
<View style={styles.mapPane}>{left}</View> <View style={styles.mapPane}>{left}</View>
<View style={styles.detailPane}>{right}</View> <Animated.View style={[styles.detailPane, { width: panelWidth }]}>
{right}
</Animated.View>
</View> </View>
) )
} }
@ -36,6 +48,6 @@ const styles = StyleSheet.create({
flexGrow: 1, flexGrow: 1,
}, },
detailPane: { detailPane: {
width: DETAIL_PANEL_WIDTH, overflow: 'hidden',
}, },
}) })

View File

@ -2,8 +2,8 @@
* *
*/ */
/** 楼层编号 */ /** 楼层编号4 = 楼顶) */
export type FloorNumber = 1 | 2 | 3 export type FloorNumber = 1 | 2 | 3 | 4
/** 地图标记点 */ /** 地图标记点 */
export interface Marker { export interface Marker {
@ -16,13 +16,8 @@ export interface Marker {
createdAt: string createdAt: string
} }
/** 物品类别 */ /** 物品类别(动态,由用户在设置中配置) */
export type ItemCategory = export type ItemCategory = string
| 'furniture'
| 'appliance'
| 'electronics'
| 'vehicle'
| 'other'
/** 登记物品 */ /** 登记物品 */
export interface Item { export interface Item {
@ -31,6 +26,8 @@ export interface Item {
name: string name: string
category: ItemCategory category: ItemCategory
quantity: number quantity: number
unit: string
spec: string
estimatedValue: number estimatedValue: number
referencePrice: number referencePrice: number
remark: string remark: string

View File

@ -14,6 +14,7 @@ const FLOOR_CANDIDATES = {
1: ['1101-1.svg', '1101.svg'], 1: ['1101-1.svg', '1101.svg'],
2: ['1102-1.svg', '1102.svg'], 2: ['1102-1.svg', '1102.svg'],
3: ['1103-1.svg', '1103.svg'], 3: ['1103-1.svg', '1103.svg'],
4: ['1104-1.svg', '1104.svg'],
} }
function findFile(candidates) { function findFile(candidates) {
@ -24,6 +25,93 @@ function findFile(candidates) {
return null return null
} }
/**
* 解析 SVG transform 字符串返回 matrix(a,b,c,d,e,f) 6 个分量
* 支持 rotate(angle) / rotate(angle,cx,cy) / matrix(a,b,c,d,e,f)
*/
function parseTransform(transformStr) {
if (!transformStr) return [1, 0, 0, 1, 0, 0]
const rotateM = transformStr.trim().match(/^rotate\(\s*([^,)]+)(?:\s*,\s*([^,)]+)\s*,\s*([^)]+))?\s*\)$/)
if (rotateM) {
const angle = parseFloat(rotateM[1]) * Math.PI / 180
const rcx = rotateM[2] ? parseFloat(rotateM[2]) : 0
const rcy = rotateM[3] ? parseFloat(rotateM[3]) : 0
const cosA = Math.cos(angle), sinA = Math.sin(angle)
return [
cosA, sinA, -sinA, cosA,
rcx * (1 - cosA) + rcy * sinA,
rcy * (1 - cosA) - rcx * sinA,
]
}
const matrixM = transformStr.trim().match(/^matrix\(\s*([^)]+)\s*\)$/)
if (matrixM) {
return matrixM[1].split(/[\s,]+/).map(parseFloat)
}
return [1, 0, 0, 1, 0, 0]
}
/**
* SVG 提取所有用户嵌入的 <image> 元素非背景图
* 返回结构化数据 FloorMap.tsx 用原生 Image 渲染
*/
function extractEmbeddedImages(svgText) {
const results = []
const regex = /<image([^>]*)\/>/gs
let m
while ((m = regex.exec(svgText)) !== null) {
const attrs = m[0]
const uriM = attrs.match(/(?:xlink:href|href)="(data:image\/[^"]+)"/)
if (!uriM) continue
const uri = uriM[1].replace(/&#10;|&#13;|\s/g, '')
const W = parseFloat(attrs.match(/width="([^"]*)"/)?.[1] || '0')
const H = parseFloat(attrs.match(/height="([^"]*)"/)?.[1] || '0')
const ox = parseFloat(attrs.match(/\sx="([^"]*)"/)?.[1] || '0')
const oy = parseFloat(attrs.match(/\sy="([^"]*)"/)?.[1] || '0')
const opacityStyle = attrs.match(/opacity:([^;}"]*)/)?.[1]
const opacityAttr = attrs.match(/\sopacity="([^"]*)"/)?.[1]
const opacity = parseFloat(opacityStyle ?? opacityAttr ?? '1')
const transformStr = attrs.match(/transform="([^"]*)"/)?.[1]
const [a, b, c, d, e, f] = parseTransform(transformStr)
// 图片在 SVG 坐标系下的中心点
const cx = a * (ox + W / 2) + c * (oy + H / 2) + e
const cy = b * (ox + W / 2) + d * (oy + H / 2) + f
// 旋转角度(度)
const angleDeg = Math.atan2(b, a) * (180 / Math.PI)
results.push({ uri, width: W, height: H, cx, cy, angleDeg, opacity })
}
return results
}
/**
* 移除 SVG 文本中的第一个 <image> 元素背景图
*/
function removeFirstImage(svg) {
const m = svg.match(/(?:xlink:href|href)="(data:image\/[^"]+)"/)
if (!m) return svg
const tagStart = svg.lastIndexOf('<image', m.index)
const gtPos = svg.indexOf('>', tagStart)
const tagEnd = svg[gtPos - 1] === '/' ? gtPos + 1 : (() => {
const close = svg.indexOf('</image>', gtPos)
return close !== -1 ? close + 8 : gtPos + 1
})()
return svg.slice(0, tagStart) + svg.slice(tagEnd)
}
/**
* 移除 SVG 文本中的所有 <image> 元素已提取到 embeddedImages 后清理
*/
function removeAllImages(svg) {
return svg.replace(/<image[^>]*\/>/gs, '')
}
const contents = {} const contents = {}
for (const [floor, candidates] of Object.entries(FLOOR_CANDIDATES)) { for (const [floor, candidates] of Object.entries(FLOOR_CANDIDATES)) {
const filePath = findFile(candidates) const filePath = findFile(candidates)
@ -32,46 +120,72 @@ for (const [floor, candidates] of Object.entries(FLOOR_CANDIDATES)) {
process.exit(1) process.exit(1)
} }
console.log(`读取 ${floor}F → ${path.basename(filePath)}`) console.log(`读取 ${floor}F → ${path.basename(filePath)}`)
let svg = fs.readFileSync(filePath, 'utf8') const svg = fs.readFileSync(filePath, 'utf8')
// 提取原始 width / height用于补全 viewBox // 1. 提取背景图 URI第一个 <image>
const wMatch = svg.match(/<svg[^>]*\swidth="([^"]+)"/) const bgMatch = svg.match(/(?:xlink:href|href)="(data:image\/[^"]+)"/)
const hMatch = svg.match(/<svg[^>]*\sheight="([^"]+)"/) const imageUri = bgMatch ? bgMatch[1].replace(/&#10;|&#13;|\s/g, '') : null
const svgW = wMatch ? wMatch[1] : null if (imageUri) console.log(` → 提取背景图 URI`)
const svgH = hMatch ? hMatch[1] : null
// 若 viewBox 缺失,用原始尺寸补充;已有则保留 // 2. 移除背景图后,提取剩余嵌入图片
const hasViewBox = /viewBox=/.test(svg) const svgNoBg = removeFirstImage(svg)
if (!hasViewBox && svgW && svgH) { const embeddedImages = extractEmbeddedImages(svgNoBg)
svg = svg.replace(/<svg/, `<svg viewBox="0 0 ${svgW} ${svgH}"`) console.log(` → 嵌入图片(用户添加): ${embeddedImages.length}`)
console.log(` → 补充 viewBox="0 0 ${svgW} ${svgH}"`)
// 3. 清理 SVG移除所有 <image>已单独提取、Inkscape 专属元素和属性
let svgXml = removeAllImages(svgNoBg)
svgXml = svgXml.replace(/<sodipodi:namedview[\s\S]*?\/>/g, '')
svgXml = svgXml.replace(/<sodipodi:namedview[\s\S]*?<\/sodipodi:namedview>/g, '')
svgXml = svgXml.replace(/\s+(?:inkscape|sodipodi):[a-z:A-Z-]+=(?:"[^"]*"|'[^']*')/g, '')
svgXml = svgXml.replace(/\s+xmlns:(?:inkscape|sodipodi)="[^"]*"/g, '')
svgXml = svgXml.replace(/\s+xmlns:svg="[^"]*"/g, '')
// 4. 确保 viewBox将 width/height 改为 100%
const hasViewBox = /viewBox=/.test(svgXml)
const wMatch = svgXml.match(/<svg[^>]*\swidth="([^"%][^"]*)"/)
const hMatch = svgXml.match(/<svg[^>]*\sheight="([^"%][^"]*)"/)
if (!hasViewBox && wMatch && hMatch) {
svgXml = svgXml.replace(/<svg/, `<svg viewBox="0 0 ${wMatch[1]} ${hMatch[1]}"`)
} }
svgXml = svgXml.replace(/(<svg[^>]*)\swidth="[^%][^"]*"/, '$1 width="100%"')
svgXml = svgXml.replace(/(<svg[^>]*)\sheight="[^%][^"]*"/, '$1 height="100%"')
svgXml = svgXml.replace(/xlink:href/g, 'href')
svgXml = svgXml.replace(/<\?xml[^?]*\?>/g, '').trim()
svgXml = svgXml.replace(/<!--[\s\S]*?-->/g, '')
svgXml = svgXml.replace(/\n\s*\n/g, '\n').trim()
// 将根 <svg> 的 width/height 改为 100%,让 SvgXml 的 props 完全控制渲染尺寸 contents[floor] = { imageUri, svgXml, embeddedImages }
svg = svg.replace(/(<svg[^>]*)\swidth="[^"]*"/, '$1 width="100%"')
svg = svg.replace(/(<svg[^>]*)\sheight="[^"]*"/, '$1 height="100%"')
// 将 xlink:href 替换为 href兼容 react-native-svg 的 <image> 渲染
svg = svg.replace(/xlink:href/g, 'href')
// 清除 base64 数据中的 XML 换行实体(&#10; &#13;和空白react-native-svg 不支持
svg = svg.replace(/href="(data:[^"]+)"/g, (_, dataUrl) => {
const cleaned = dataUrl.replace(/&#10;|&#13;|\s/g, '')
return `href="${cleaned}"`
})
contents[floor] = svg
} }
const output = `// 自动生成,请勿手动编辑 const output = `// 自动生成,请勿手动编辑
// 更新底图后运行pnpm generate-maps // 更新底图后运行pnpm generate-maps
import type { FloorNumber } from '@repo/types' import type { FloorNumber } from '@repo/types'
const f1 = ${JSON.stringify(contents[1])} export type EmbeddedImage = {
uri: string
width: number // SVG 单位
height: number // SVG 单位
cx: number // 图片中心 xSVG 坐标系0-3200
cy: number // 图片中心 ySVG 坐标系0-2133
angleDeg: number // 旋转角度(度)
opacity: number
}
const f2 = ${JSON.stringify(contents[2])} export type FloorMap = {
imageUri: string | null
svgXml: string
embeddedImages: EmbeddedImage[]
}
const f3 = ${JSON.stringify(contents[3])} const f1: FloorMap = ${JSON.stringify(contents[1])}
export const FLOOR_SVG: Record<FloorNumber, string> = { 1: f1, 2: f2, 3: f3 } const f2: FloorMap = ${JSON.stringify(contents[2])}
const f3: FloorMap = ${JSON.stringify(contents[3])}
const f4: FloorMap = ${JSON.stringify(contents[4])}
export const FLOOR_SVG: Record<FloorNumber, FloorMap> = { 1: f1, 2: f2, 3: f3, 4: f4 }
` `
fs.writeFileSync(outputFile, output, 'utf8') fs.writeFileSync(outputFile, output, 'utf8')