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:
parent
4e1d92264d
commit
eab08d8c96
8
apps/property-survey/.expo/devices.json
Normal file
8
apps/property-survey/.expo/devices.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"installationId": "626cc0bf-3d4a-47d1-9fd9-50b31e88db04",
|
||||||
|
"lastUsed": 1775741394159
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
6
apps/property-survey/.expo/types/router.d.ts
vendored
6
apps/property-survey/.expo/types/router.d.ts
vendored
@ -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; };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,28 +29,37 @@ export default function RootLayout() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="auto" />
|
||||||
<Tabs
|
<CategoriesProvider>
|
||||||
screenOptions={{
|
<Tabs
|
||||||
headerShown: true,
|
screenOptions={{
|
||||||
tabBarActiveTintColor: '#10B981',
|
headerShown: true,
|
||||||
}}
|
tabBarActiveTintColor: '#10B981',
|
||||||
>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="index"
|
|
||||||
options={{
|
|
||||||
title: '登记',
|
|
||||||
tabBarLabel: '登记',
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="summary"
|
name="index"
|
||||||
options={{
|
options={{
|
||||||
title: '汇总',
|
title: '登记',
|
||||||
tabBarLabel: '汇总',
|
tabBarLabel: '登记',
|
||||||
}}
|
headerShown: false,
|
||||||
/>
|
}}
|
||||||
</Tabs>
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="summary"
|
||||||
|
options={{
|
||||||
|
title: '汇总',
|
||||||
|
tabBarLabel: '汇总',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="settings"
|
||||||
|
options={{
|
||||||
|
title: '设置',
|
||||||
|
tabBarLabel: '设置',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
</CategoriesProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
211
apps/property-survey/app/settings.tsx
Normal file
211
apps/property-survey/app/settings.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
})
|
||||||
143
apps/property-survey/img/1104.svg
Normal file
143
apps/property-survey/img/1104.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 2.7 MiB |
37
apps/property-survey/src/db/categories.ts
Normal file
37
apps/property-survey/src/db/categories.ts
Normal 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])
|
||||||
|
}
|
||||||
@ -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 ''`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|
||||||
// 分隔线
|
// 分隔线
|
||||||
|
|||||||
@ -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)}>
|
<TouchableOpacity
|
||||||
<Text style={styles.name} numberOfLines={1}>
|
style={styles.card}
|
||||||
{item.name}
|
onPress={() => onEditItem(item)}
|
||||||
</Text>
|
onLongPress={() => handleDelete(item)}
|
||||||
<Text style={styles.meta}>×{item.quantity}</Text>
|
delayLongPress={500}
|
||||||
{item.estimatedValue > 0 && (
|
>
|
||||||
<Text style={styles.meta}>¥{item.estimatedValue}</Text>
|
<Text style={styles.name} numberOfLines={1}>{item.name}</Text>
|
||||||
)}
|
|
||||||
{item.photoCount > 0 && (
|
|
||||||
<Text style={styles.meta}>{item.photoCount}张</Text>
|
|
||||||
)}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.deleteBtn}
|
|
||||||
onPress={() => handleDelete(item)}
|
|
||||||
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
|
||||||
>
|
|
||||||
<Ionicons name="trash-outline" size={14} color="#EF4444" />
|
|
||||||
</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' },
|
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* 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"
|
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}
|
||||||
|
|||||||
@ -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',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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
@ -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)
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(/ | |\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(/ | |\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 换行实体( )和空白,react-native-svg 不支持
|
|
||||||
svg = svg.replace(/href="(data:[^"]+)"/g, (_, dataUrl) => {
|
|
||||||
const cleaned = dataUrl.replace(/ | |\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 // 图片中心 x(SVG 坐标系,0-3200)
|
||||||
|
cy: number // 图片中心 y(SVG 坐标系,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')
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user