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

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

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

View File

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

View File

@ -6,9 +6,9 @@ export * from 'expo-router';
declare module 'expo-router' {
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; };
}
}
}

View File

@ -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>
)
}

View File

@ -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,
},
})

View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

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

View File

@ -1,5 +1,5 @@
import * as SQLite from 'expo-sqlite'
import { 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 ''`)
}
}

View File

@ -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],
)
}
}

View File

@ -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,

View File

@ -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 },
// 分隔线

View File

@ -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' },
})

View File

@ -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 }
}

View File

@ -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}

View File

@ -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',
},
})

View File

@ -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

View File

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

View File

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

View File

@ -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>
)

View File

@ -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',
},
})

View File

@ -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

View File

@ -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(/&#10;|&#13;|\s/g, '')
const W = parseFloat(attrs.match(/width="([^"]*)"/)?.[1] || '0')
const H = parseFloat(attrs.match(/height="([^"]*)"/)?.[1] || '0')
const ox = parseFloat(attrs.match(/\sx="([^"]*)"/)?.[1] || '0')
const oy = parseFloat(attrs.match(/\sy="([^"]*)"/)?.[1] || '0')
const opacityStyle = attrs.match(/opacity:([^;}"]*)/)?.[1]
const opacityAttr = attrs.match(/\sopacity="([^"]*)"/)?.[1]
const opacity = parseFloat(opacityStyle ?? opacityAttr ?? '1')
const transformStr = attrs.match(/transform="([^"]*)"/)?.[1]
const [a, b, c, d, e, f] = parseTransform(transformStr)
// 图片在 SVG 坐标系下的中心点
const cx = a * (ox + W / 2) + c * (oy + H / 2) + e
const cy = b * (ox + W / 2) + d * (oy + H / 2) + f
// 旋转角度(度)
const angleDeg = Math.atan2(b, a) * (180 / Math.PI)
results.push({ uri, width: W, height: H, cx, cy, angleDeg, opacity })
}
return results
}
/**
* 移除 SVG 文本中的第一个 <image> 元素背景图
*/
function removeFirstImage(svg) {
const m = svg.match(/(?:xlink:href|href)="(data:image\/[^"]+)"/)
if (!m) return svg
const tagStart = svg.lastIndexOf('<image', m.index)
const gtPos = svg.indexOf('>', tagStart)
const tagEnd = svg[gtPos - 1] === '/' ? gtPos + 1 : (() => {
const close = svg.indexOf('</image>', gtPos)
return close !== -1 ? close + 8 : gtPos + 1
})()
return svg.slice(0, tagStart) + svg.slice(tagEnd)
}
/**
* 移除 SVG 文本中的所有 <image> 元素已提取到 embeddedImages 后清理
*/
function removeAllImages(svg) {
return svg.replace(/<image[^>]*\/>/gs, '')
}
const contents = {}
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(/&#10;|&#13;|\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 换行实体(&#10; &#13;和空白react-native-svg 不支持
svg = svg.replace(/href="(data:[^"]+)"/g, (_, dataUrl) => {
const cleaned = dataUrl.replace(/&#10;|&#13;|\s/g, '')
return `href="${cleaned}"`
})
contents[floor] = svg
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 // 图片中心 xSVG 坐标系0-3200
cy: number // 图片中心 ySVG 坐标系0-2133
angleDeg: number // 旋转角度(度)
opacity: number
}
const f2 = ${JSON.stringify(contents[2])}
export type FloorMap = {
imageUri: string | null
svgXml: string
embeddedImages: EmbeddedImage[]
}
const f3 = ${JSON.stringify(contents[3])}
const f1: FloorMap = ${JSON.stringify(contents[1])}
export const FLOOR_SVG: Record<FloorNumber, string> = { 1: f1, 2: f2, 3: f3 }
const f2: FloorMap = ${JSON.stringify(contents[2])}
const f3: FloorMap = ${JSON.stringify(contents[3])}
const f4: FloorMap = ${JSON.stringify(contents[4])}
export const FLOOR_SVG: Record<FloorNumber, FloorMap> = { 1: f1, 2: f2, 3: f3, 4: f4 }
`
fs.writeFileSync(outputFile, output, 'utf8')