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' {
|
||||
export namespace ExpoRouter {
|
||||
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; };
|
||||
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; };
|
||||
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; };
|
||||
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: `/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}` | ''}` | `/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 { SafeAreaProvider } from 'react-native-safe-area-context'
|
||||
import { initDatabase } from '../src/db/database'
|
||||
import { CategoriesProvider } from '../src/features/settings/CategoriesContext'
|
||||
|
||||
export default function RootLayout() {
|
||||
const [dbReady, setDbReady] = useState(false)
|
||||
@ -28,6 +29,7 @@ export default function RootLayout() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<StatusBar style="auto" />
|
||||
<CategoriesProvider>
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: true,
|
||||
@ -49,7 +51,15 @@ export default function RootLayout() {
|
||||
tabBarLabel: '汇总',
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: '设置',
|
||||
tabBarLabel: '设置',
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
</CategoriesProvider>
|
||||
</SafeAreaProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ import { useState } from 'react'
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { FoldableLayout } from '../src/layout/FoldableLayout'
|
||||
import { MarkerList } from '../src/features/map/MarkerList'
|
||||
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'
|
||||
|
||||
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() {
|
||||
const insets = useSafeAreaInsets()
|
||||
@ -28,7 +28,6 @@ export default function RegisterScreen() {
|
||||
setSelectedMarker(null)
|
||||
}
|
||||
|
||||
// 右栏:有标记选中时显示内容,否则显示引导占位
|
||||
const rightPanel = selectedMarker
|
||||
? mode === 'view'
|
||||
? <MarkerPhotoPanel marker={selectedMarker} onClose={() => setSelectedMarker(null)} />
|
||||
@ -37,7 +36,7 @@ export default function RegisterScreen() {
|
||||
onClose={() => setSelectedMarker(null)}
|
||||
onMarkerUpdated={() => setMarkersRefreshKey((k) => k + 1)}
|
||||
/>
|
||||
: <RightPlaceholder mode={mode} />
|
||||
: null
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
@ -67,7 +66,7 @@ export default function RegisterScreen() {
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.floorBtnText, currentFloor === f && styles.floorBtnTextActive]}>
|
||||
{f}F
|
||||
{FLOOR_LABEL[f]}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
@ -85,26 +84,12 @@ export default function RegisterScreen() {
|
||||
/>
|
||||
}
|
||||
right={rightPanel}
|
||||
showDetail={selectedMarker !== null}
|
||||
/>
|
||||
</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({
|
||||
root: { flex: 1 },
|
||||
@ -151,20 +136,3 @@ const styles = StyleSheet.create({
|
||||
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 { 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
|
||||
|
||||
@ -18,7 +18,43 @@ export async function initDatabase(): Promise<void> {
|
||||
${CREATE_MARKERS_TABLE};
|
||||
${CREATE_ITEMS_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 列
|
||||
const markerCols = await database.getAllAsync<{ name: string }>(`PRAGMA table_info(markers)`)
|
||||
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')) {
|
||||
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> {
|
||||
const db = getDatabase()
|
||||
const result = await db.runAsync(
|
||||
`INSERT INTO items (marker_id, name, category, quantity, estimated_value, reference_price, remark)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO items (marker_id, name, category, quantity, unit, spec, estimated_value, reference_price, remark)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
params.markerId,
|
||||
params.name,
|
||||
params.category,
|
||||
params.quantity,
|
||||
params.unit,
|
||||
params.spec,
|
||||
params.estimatedValue,
|
||||
params.referencePrice,
|
||||
params.remark,
|
||||
],
|
||||
)
|
||||
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,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM items WHERE id = ?`,
|
||||
@ -36,7 +38,7 @@ export async function getItemsByMarker(
|
||||
const ids = Array.isArray(markerIdOrIds) ? markerIdOrIds : [markerIdOrIds]
|
||||
const placeholders = ids.map(() => '?').join(', ')
|
||||
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.created_at as createdAt, i.updated_at as updatedAt,
|
||||
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.category !== undefined) { fields.push('category = ?'); values.push(params.category) }
|
||||
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.referencePrice !== undefined) { fields.push('reference_price = ?'); values.push(params.referencePrice) }
|
||||
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> {
|
||||
const db = getDatabase()
|
||||
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
|
||||
}>(
|
||||
`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],
|
||||
)
|
||||
for (const item of items) {
|
||||
await db.runAsync(
|
||||
`INSERT INTO items (marker_id, name, category, quantity, estimated_value, reference_price, remark)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[toMarkerId, item.name, item.category, item.quantity, item.estimated_value, item.reference_price, item.remark],
|
||||
`INSERT INTO items (marker_id, name, category, quantity, unit, spec, estimated_value, reference_price, remark)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[toMarkerId, item.name, item.category, item.quantity, item.unit, item.spec, item.estimated_value, item.reference_price, item.remark],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
export const CREATE_MARKERS_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS markers (
|
||||
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,
|
||||
y REAL NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
@ -17,6 +17,8 @@ export const CREATE_ITEMS_TABLE = `
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'other',
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
unit TEXT NOT NULL DEFAULT '',
|
||||
spec TEXT NOT NULL DEFAULT '',
|
||||
estimated_value REAL NOT NULL DEFAULT 0,
|
||||
reference_price REAL NOT NULL DEFAULT 0,
|
||||
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 = `
|
||||
CREATE TABLE IF NOT EXISTS photos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@ -9,26 +9,19 @@ import {
|
||||
View,
|
||||
} from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import type { ItemCategory } from '@repo/types'
|
||||
import type { useItemForm } from './useItemForm'
|
||||
import { PhotoGrid } from '../photo/PhotoGrid'
|
||||
import { useCategories } from '../settings/CategoriesContext'
|
||||
|
||||
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 {
|
||||
hook: FormHook
|
||||
}
|
||||
|
||||
export function ItemFields({ hook }: ItemFieldsProps) {
|
||||
const { form, setField, nameError, photoItemId, save } = hook
|
||||
const { form, setField, nameError, photoItemId } = hook
|
||||
const { categories } = useCategories()
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
@ -57,20 +50,20 @@ export function ItemFields({ hook }: ItemFieldsProps) {
|
||||
|
||||
{/* 类别 */}
|
||||
<View style={styles.categoryRow}>
|
||||
{CATEGORIES.map((cat) => (
|
||||
{categories.map((cat) => (
|
||||
<TouchableOpacity
|
||||
key={cat.value}
|
||||
style={[styles.chip, form.category === cat.value && styles.chipActive]}
|
||||
onPress={() => setField('category', cat.value)}
|
||||
key={cat.id}
|
||||
style={[styles.chip, form.category === cat.name && styles.chipActive]}
|
||||
onPress={() => setField('category', cat.name)}
|
||||
>
|
||||
<Text style={[styles.chipLabel, form.category === cat.value && styles.chipLabelActive]}>
|
||||
{cat.label}
|
||||
<Text style={[styles.chipLabel, form.category === cat.name && styles.chipLabelActive]}>
|
||||
{cat.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 数量 / 单价 / 参考价 */}
|
||||
{/* 数量 / 规格 / 单价 / 参考价 */}
|
||||
<View style={styles.card}>
|
||||
<View style={styles.fieldRow}>
|
||||
<Text style={styles.fieldLabel}>数量</Text>
|
||||
@ -84,6 +77,19 @@ export function ItemFields({ hook }: ItemFieldsProps) {
|
||||
/>
|
||||
</View>
|
||||
<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}>
|
||||
<Text style={styles.fieldLabel}>单价</Text>
|
||||
<TextInput
|
||||
@ -113,6 +119,19 @@ export function ItemFields({ hook }: ItemFieldsProps) {
|
||||
</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}>
|
||||
<TextInput
|
||||
@ -126,13 +145,6 @@ export function ItemFields({ hook }: ItemFieldsProps) {
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 保存 */}
|
||||
<View style={styles.saveRow}>
|
||||
<TouchableOpacity style={styles.saveBtn} onPress={save}>
|
||||
<Text style={styles.saveBtnText}>保存</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.bottomSpacer} />
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
@ -214,15 +226,6 @@ const styles = StyleSheet.create({
|
||||
|
||||
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 },
|
||||
|
||||
// 分隔线
|
||||
|
||||
@ -54,30 +54,18 @@ export function ItemList({ markerId, onEditItem, onAddNew, refreshKey }: ItemLis
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.listContent}
|
||||
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
|
||||
style={styles.deleteBtn}
|
||||
onPress={() => handleDelete(item)}
|
||||
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
||||
style={styles.card}
|
||||
onPress={() => onEditItem(item)}
|
||||
onLongPress={() => handleDelete(item)}
|
||||
delayLongPress={500}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={14} color="#EF4444" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.name} numberOfLines={1}>{item.name}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
ListFooterComponent={
|
||||
ListHeaderComponent={
|
||||
<TouchableOpacity style={styles.addBtn} onPress={onAddNew}>
|
||||
<Ionicons name="add-circle-outline" size={18} color="#10B981" />
|
||||
<Text style={styles.addBtnText}>新增</Text>
|
||||
<Ionicons name="add-circle-outline" size={22} color="#10B981" />
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
@ -93,21 +81,13 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 8,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 10,
|
||||
minWidth: 72,
|
||||
maxWidth: 110,
|
||||
position: 'relative',
|
||||
},
|
||||
name: { fontSize: 13, fontWeight: '600', color: '#111827', marginBottom: 2 },
|
||||
meta: { fontSize: 11, color: '#6B7280', lineHeight: 16 },
|
||||
deleteBtn: { position: 'absolute', top: 4, right: 4 },
|
||||
name: { fontSize: 13, fontWeight: '600', color: '#111827' },
|
||||
addBtn: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 10,
|
||||
minWidth: 52,
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
addBtnText: { color: '#10B981', fontSize: 12, fontWeight: '600' },
|
||||
})
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
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'
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
category: ItemCategory
|
||||
category: string
|
||||
quantity: string
|
||||
unit: string
|
||||
spec: string
|
||||
estimatedValue: string
|
||||
referencePrice: string
|
||||
remark: string
|
||||
@ -13,8 +15,10 @@ interface FormState {
|
||||
|
||||
const DEFAULT_FORM: FormState = {
|
||||
name: '',
|
||||
category: 'other',
|
||||
category: '',
|
||||
quantity: '1',
|
||||
unit: '',
|
||||
spec: '',
|
||||
estimatedValue: '',
|
||||
referencePrice: '',
|
||||
remark: '',
|
||||
@ -24,11 +28,11 @@ export function useItemForm(markerId: number, onSaved: () => void) {
|
||||
const [form, setForm] = useState<FormState>(DEFAULT_FORM)
|
||||
const [editingId, setEditingId] = 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]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
if (key === 'name') setNameError('')
|
||||
setIsDirty(true)
|
||||
}
|
||||
|
||||
function startEdit(item: Item) {
|
||||
@ -38,31 +42,33 @@ export function useItemForm(markerId: number, onSaved: () => void) {
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
quantity: String(item.quantity),
|
||||
unit: item.unit,
|
||||
spec: item.spec,
|
||||
estimatedValue: item.estimatedValue ? String(item.estimatedValue) : '',
|
||||
referencePrice: item.referencePrice ? String(item.referencePrice) : '',
|
||||
remark: item.remark,
|
||||
})
|
||||
setNameError('')
|
||||
setIsDirty(false)
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setEditingId(null)
|
||||
setPhotoItemId(null)
|
||||
setForm(DEFAULT_FORM)
|
||||
setNameError('')
|
||||
setIsDirty(false)
|
||||
}
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!form.name.trim()) {
|
||||
setNameError('物品名称不能为空')
|
||||
return
|
||||
}
|
||||
// 自动保存:有名称且有改动时才执行,不弹错误
|
||||
const autoSave = useCallback(async () => {
|
||||
if (!form.name.trim() || !isDirty) return
|
||||
|
||||
const params: InsertItemParams = {
|
||||
markerId,
|
||||
name: form.name.trim(),
|
||||
category: form.category,
|
||||
quantity: Math.max(1, parseInt(form.quantity, 10) || 1),
|
||||
unit: form.unit.trim(),
|
||||
spec: form.spec.trim(),
|
||||
estimatedValue: parseFloat(form.estimatedValue) || 0,
|
||||
referencePrice: parseFloat(form.referencePrice) || 0,
|
||||
remark: form.remark.trim(),
|
||||
@ -71,19 +77,17 @@ export function useItemForm(markerId: number, onSaved: () => void) {
|
||||
try {
|
||||
if (editingId !== null) {
|
||||
await updateItem(editingId, params)
|
||||
resetForm()
|
||||
} else {
|
||||
const saved = await insertItem(params)
|
||||
setEditingId(saved.id)
|
||||
setPhotoItemId(saved.id)
|
||||
setForm(DEFAULT_FORM)
|
||||
setNameError('')
|
||||
}
|
||||
setIsDirty(false)
|
||||
onSaved()
|
||||
} 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 { PixelRatio, StyleSheet, View, type LayoutChangeEvent } from 'react-native'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Image, PixelRatio, StyleSheet, View, type LayoutChangeEvent } from 'react-native'
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
@ -14,9 +14,23 @@ import { FLOOR_SVG } from './floorMaps.generated'
|
||||
|
||||
/** SVG 原始尺寸 3200×2133,宽高比约 3:2 */
|
||||
const ASPECT_RATIO = 3200 / 2133
|
||||
const SVG_WIDTH = 3200
|
||||
const MIN_SCALE = 1
|
||||
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 {
|
||||
width: number
|
||||
height: number
|
||||
@ -42,6 +56,10 @@ export function FloorMap({
|
||||
onMarkerAdded,
|
||||
}: FloorMapProps) {
|
||||
const [mapSize, setMapSize] = useState<MapSize | null>(null)
|
||||
const svgOverdraw = useMemo(
|
||||
() => (mapSize ? calcSafeOverdraw(mapSize.width) : 1),
|
||||
[mapSize],
|
||||
)
|
||||
|
||||
// 地图尺寸存为 SharedValue,供 worklet 手势直接读取
|
||||
const mapWidth = useSharedValue(0)
|
||||
@ -132,12 +150,59 @@ export function FloorMap({
|
||||
<Animated.View
|
||||
style={[styles.map, { width: mapSize.width, height: mapSize.height }, animatedStyle]}
|
||||
>
|
||||
<SvgXml
|
||||
xml={FLOOR_SVG[floor]}
|
||||
width={mapSize.width}
|
||||
height={mapSize.height}
|
||||
{FLOOR_SVG[floor].imageUri && (
|
||||
<Image
|
||||
source={{ uri: FLOOR_SVG[floor].imageUri }}
|
||||
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"
|
||||
>
|
||||
<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) => (
|
||||
<MapMarker
|
||||
key={marker.id}
|
||||
|
||||
@ -16,9 +16,9 @@ import { Ionicons } from '@expo/vector-icons'
|
||||
import type { Marker } from '@repo/types'
|
||||
import type { MapSize } from './FloorMap'
|
||||
|
||||
const ICON_SIZE = 28
|
||||
const BADGE_OFFSET = -6
|
||||
const RING_SIZE = 52
|
||||
const ICON_SIZE = 20
|
||||
const BADGE_OFFSET = -5
|
||||
const RING_SIZE = 38
|
||||
|
||||
interface MapMarkerProps {
|
||||
marker: Marker
|
||||
@ -163,17 +163,17 @@ const styles = StyleSheet.create({
|
||||
position: 'absolute',
|
||||
top: BADGE_OFFSET,
|
||||
right: BADGE_OFFSET,
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
minWidth: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#EF4444',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 3,
|
||||
paddingHorizontal: 2,
|
||||
},
|
||||
badgeText: {
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
fontSize: 8,
|
||||
fontWeight: '700',
|
||||
},
|
||||
})
|
||||
|
||||
@ -8,6 +8,7 @@ import { FloorMap } from './FloorMap'
|
||||
interface MarkerWithItems extends Marker {
|
||||
itemCount: number
|
||||
itemNames: string[]
|
||||
itemSummaries: { name: string; quantity: number }[]
|
||||
}
|
||||
|
||||
interface MarkerListProps {
|
||||
@ -17,30 +18,25 @@ interface MarkerListProps {
|
||||
refreshKey?: number
|
||||
}
|
||||
|
||||
function formatDateTime(raw: string): string {
|
||||
const d = new Date(raw.replace(' ', 'T') + 'Z')
|
||||
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}`
|
||||
}
|
||||
|
||||
type SidebarTab = 'detail' | 'aggregate'
|
||||
|
||||
export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey }: MarkerListProps) {
|
||||
const [markers, setMarkers] = useState<MarkerWithItems[]>([])
|
||||
const [activeTab, setActiveTab] = useState<SidebarTab>('detail')
|
||||
|
||||
const loadMarkers = useCallback(async () => {
|
||||
try {
|
||||
const rows = await getMarkersByFloor(floor)
|
||||
if (rows.length === 0) { setMarkers([]); return }
|
||||
const allItems = await getItemsByMarker(rows.map((m) => m.id))
|
||||
if (!rows || rows.length === 0) { setMarkers([]); return }
|
||||
const allItems = (await getItemsByMarker(rows.map((m) => m.id))) ?? []
|
||||
const withItems = rows.map((m) => {
|
||||
const markerItems = allItems.filter((i) => i.markerId === m.id)
|
||||
return {
|
||||
...m,
|
||||
itemCount: markerItems.length,
|
||||
itemNames: markerItems.map((i) => i.name),
|
||||
itemSummaries: markerItems.map((i) => ({ name: i.name, quantity: i.quantity })),
|
||||
}
|
||||
})
|
||||
setMarkers(withItems)
|
||||
@ -63,12 +59,27 @@ export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.sidebar}>
|
||||
<View style={styles.sidebarHeader}>
|
||||
<Text style={styles.headerLabel}>标记</Text>
|
||||
<Text style={styles.headerNum}>{markers.length}</Text>
|
||||
<Text style={styles.headerLabel}>个</Text>
|
||||
{/* 页签 */}
|
||||
<View style={styles.tabs}>
|
||||
{(['detail', 'aggregate'] as SidebarTab[]).map((tab) => (
|
||||
<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>
|
||||
|
||||
{activeTab === 'aggregate' ? (
|
||||
<View style={styles.aggregatePlaceholder}>
|
||||
<Text style={styles.emptyHint}>聚合视图{'\n'}开发中</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView style={styles.sidebarScroll} showsVerticalScrollIndicator={false}>
|
||||
{markers.length === 0 && (
|
||||
<Text style={styles.emptyHint}>长按地图{'\n'}添加标记</Text>
|
||||
@ -93,14 +104,14 @@ export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey
|
||||
style={[styles.itemLabel, isSelected && styles.itemLabelSelected]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{m.label || '未命名'}
|
||||
{m.label}
|
||||
</Text>
|
||||
{m.itemNames.length > 0 && (
|
||||
<Text style={styles.itemNames} numberOfLines={2}>
|
||||
{m.itemNames.join('、')}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={styles.itemDate}>{formatDateTime(m.createdAt)}</Text>
|
||||
{(m.itemSummaries?.length ?? 0) > 0 && m.itemSummaries.map((s, i) => (
|
||||
<View key={i} style={styles.itemSummaryRow}>
|
||||
<Text style={styles.itemNames} numberOfLines={1}>{s.name}</Text>
|
||||
<Text style={styles.itemQty}>×{s.quantity}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{m.itemCount > 0 && (
|
||||
@ -112,6 +123,7 @@ export function MarkerList({ floor, onMarkerPress, selectedMarkerId, refreshKey
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.mapArea}>
|
||||
@ -134,23 +146,29 @@ const styles = StyleSheet.create({
|
||||
|
||||
// ── 侧栏 ──────────────────────────────────────────
|
||||
sidebar: {
|
||||
width: 110,
|
||||
width: 120,
|
||||
borderRightWidth: StyleSheet.hairlineWidth,
|
||||
borderRightColor: '#E5E7EB',
|
||||
backgroundColor: '#FAFAFA',
|
||||
},
|
||||
sidebarHeader: {
|
||||
tabs: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: '#E5E7EB',
|
||||
gap: 2,
|
||||
},
|
||||
headerLabel: { fontSize: 11, color: '#6B7280' },
|
||||
headerNum: { fontSize: 16, fontWeight: '700', color: '#111827' },
|
||||
tab: {
|
||||
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 },
|
||||
aggregatePlaceholder: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
|
||||
emptyHint: {
|
||||
fontSize: 11,
|
||||
@ -170,7 +188,7 @@ const styles = StyleSheet.create({
|
||||
borderBottomColor: '#F3F4F6',
|
||||
gap: 6,
|
||||
},
|
||||
itemSelected: { backgroundColor: '#F0FDF4' },
|
||||
itemSelected: { backgroundColor: '#D1FAE5' },
|
||||
|
||||
// 左侧选中指示条
|
||||
activeBar: {
|
||||
@ -191,10 +209,10 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
itemContent: { flex: 1 },
|
||||
itemLabel: { fontSize: 12, fontWeight: '600', color: '#374151' },
|
||||
itemLabelSelected: { color: '#15803D' },
|
||||
itemNames: { fontSize: 11, color: '#6B7280', marginTop: 2, lineHeight: 15 },
|
||||
itemDate: { fontSize: 10, color: '#9CA3AF', marginTop: 3 },
|
||||
|
||||
itemLabelSelected: { color: '#065F46' },
|
||||
itemSummaryRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 2 },
|
||||
itemNames: { fontSize: 11, color: '#6B7280', flex: 1, lineHeight: 15 },
|
||||
itemQty: { fontSize: 11, color: '#9CA3AF', lineHeight: 15, marginLeft: 4 },
|
||||
countBadge: {
|
||||
minWidth: 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: '2F', value: 2 },
|
||||
{ label: '3F', value: 3 },
|
||||
{ label: '楼顶', value: 4 },
|
||||
]
|
||||
|
||||
interface FilterBarProps {
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'
|
||||
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 {
|
||||
currentFloor: FloorNumber
|
||||
@ -21,7 +22,7 @@ export function FloorTabs({ currentFloor, onChange }: FloorTabsProps) {
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.label, active && styles.activeLabel]}>
|
||||
{floor}F
|
||||
{FLOOR_LABEL[floor]}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
import { type ReactNode, useEffect, useRef } from 'react'
|
||||
import { Animated, StyleSheet, View } from 'react-native'
|
||||
import { useFoldState } from './useFoldState'
|
||||
|
||||
const DETAIL_PANEL_WIDTH = 260
|
||||
@ -7,10 +7,20 @@ const DETAIL_PANEL_WIDTH = 260
|
||||
interface FoldableLayoutProps {
|
||||
left: ReactNode
|
||||
right: ReactNode
|
||||
showDetail?: boolean
|
||||
}
|
||||
|
||||
export function FoldableLayout({ left, right }: FoldableLayoutProps) {
|
||||
export function FoldableLayout({ left, right, showDetail = false }: FoldableLayoutProps) {
|
||||
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) {
|
||||
return <View style={styles.single}>{left}</View>
|
||||
@ -19,7 +29,9 @@ export function FoldableLayout({ left, right }: FoldableLayoutProps) {
|
||||
return (
|
||||
<View style={styles.dual}>
|
||||
<View style={styles.mapPane}>{left}</View>
|
||||
<View style={styles.detailPane}>{right}</View>
|
||||
<Animated.View style={[styles.detailPane, { width: panelWidth }]}>
|
||||
{right}
|
||||
</Animated.View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@ -36,6 +48,6 @@ const styles = StyleSheet.create({
|
||||
flexGrow: 1,
|
||||
},
|
||||
detailPane: {
|
||||
width: DETAIL_PANEL_WIDTH,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
})
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
* 不动产摸底业务类型定义
|
||||
*/
|
||||
|
||||
/** 楼层编号 */
|
||||
export type FloorNumber = 1 | 2 | 3
|
||||
/** 楼层编号(4 = 楼顶) */
|
||||
export type FloorNumber = 1 | 2 | 3 | 4
|
||||
|
||||
/** 地图标记点 */
|
||||
export interface Marker {
|
||||
@ -16,13 +16,8 @@ export interface Marker {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 物品类别 */
|
||||
export type ItemCategory =
|
||||
| 'furniture'
|
||||
| 'appliance'
|
||||
| 'electronics'
|
||||
| 'vehicle'
|
||||
| 'other'
|
||||
/** 物品类别(动态,由用户在设置中配置) */
|
||||
export type ItemCategory = string
|
||||
|
||||
/** 登记物品 */
|
||||
export interface Item {
|
||||
@ -31,6 +26,8 @@ export interface Item {
|
||||
name: string
|
||||
category: ItemCategory
|
||||
quantity: number
|
||||
unit: string
|
||||
spec: string
|
||||
estimatedValue: number
|
||||
referencePrice: number
|
||||
remark: string
|
||||
|
||||
@ -14,6 +14,7 @@ const FLOOR_CANDIDATES = {
|
||||
1: ['1101-1.svg', '1101.svg'],
|
||||
2: ['1102-1.svg', '1102.svg'],
|
||||
3: ['1103-1.svg', '1103.svg'],
|
||||
4: ['1104-1.svg', '1104.svg'],
|
||||
}
|
||||
|
||||
function findFile(candidates) {
|
||||
@ -24,6 +25,93 @@ function findFile(candidates) {
|
||||
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 = {}
|
||||
for (const [floor, candidates] of Object.entries(FLOOR_CANDIDATES)) {
|
||||
const filePath = findFile(candidates)
|
||||
@ -32,46 +120,72 @@ for (const [floor, candidates] of Object.entries(FLOOR_CANDIDATES)) {
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`读取 ${floor}F → ${path.basename(filePath)}`)
|
||||
let svg = fs.readFileSync(filePath, 'utf8')
|
||||
const svg = fs.readFileSync(filePath, 'utf8')
|
||||
|
||||
// 提取原始 width / height,用于补全 viewBox
|
||||
const wMatch = svg.match(/<svg[^>]*\swidth="([^"]+)"/)
|
||||
const hMatch = svg.match(/<svg[^>]*\sheight="([^"]+)"/)
|
||||
const svgW = wMatch ? wMatch[1] : null
|
||||
const svgH = hMatch ? hMatch[1] : null
|
||||
// 1. 提取背景图 URI(第一个 <image>)
|
||||
const bgMatch = svg.match(/(?:xlink:href|href)="(data:image\/[^"]+)"/)
|
||||
const imageUri = bgMatch ? bgMatch[1].replace(/ | |\s/g, '') : null
|
||||
if (imageUri) console.log(` → 提取背景图 URI`)
|
||||
|
||||
// 若 viewBox 缺失,用原始尺寸补充;已有则保留
|
||||
const hasViewBox = /viewBox=/.test(svg)
|
||||
if (!hasViewBox && svgW && svgH) {
|
||||
svg = svg.replace(/<svg/, `<svg viewBox="0 0 ${svgW} ${svgH}"`)
|
||||
console.log(` → 补充 viewBox="0 0 ${svgW} ${svgH}"`)
|
||||
// 2. 移除背景图后,提取剩余嵌入图片
|
||||
const svgNoBg = removeFirstImage(svg)
|
||||
const embeddedImages = extractEmbeddedImages(svgNoBg)
|
||||
console.log(` → 嵌入图片(用户添加): ${embeddedImages.length} 张`)
|
||||
|
||||
// 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 完全控制渲染尺寸
|
||||
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
|
||||
contents[floor] = { imageUri, svgXml, embeddedImages }
|
||||
}
|
||||
|
||||
const output = `// 自动生成,请勿手动编辑
|
||||
// 更新底图后运行:pnpm generate-maps
|
||||
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')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user