import React, { useState, useEffect, useCallback } from 'react'; import { View, Text, FlatList, TouchableOpacity, StyleSheet, Alert, TextInput, Modal, ActivityIndicator, Platform, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useDataStore } from '../../src/stores/dataStore'; import { useDeviceStore } from '../../src/stores/deviceStore'; import * as StorageService from '../../src/services/StorageService'; import { shareFile } from '../../src/utils/export'; import type { ProjectInfo, SessionInfo } from '../../src/services/StorageService'; import * as DocumentPicker from 'expo-document-picker'; // ── Name input modal (reused for create project / rename) ──────────────────── function NameModal({ visible, title, placeholder, initial, onConfirm, onClose, }: { visible: boolean; title: string; placeholder: string; initial?: string; onConfirm: (name: string) => void; onClose: () => void; }) { const [text, setText] = useState(initial ?? ''); useEffect(() => { if (visible) setText(initial ?? ''); }, [visible, initial]); return ( {title} 取消 { if (text.trim()) { onConfirm(text.trim()); onClose(); } }} disabled={!text.trim()} > 确定 ); } const NM = StyleSheet.create({ backdrop: { ...StyleSheet.absoluteFill, backgroundColor: 'rgba(0,0,0,0.6)' }, box: { position: 'absolute', left: 24, right: 24, top: '35%', backgroundColor: '#111120', borderRadius: 16, padding: 20, borderWidth: 1, borderColor: '#22223a' }, title: { color: '#9090b8', fontSize: 14, fontWeight: '700', marginBottom: 14 }, input: { backgroundColor: '#0c0c18', borderRadius: 8, padding: 12, color: '#d0d0e8', fontSize: 14, borderWidth: 1, borderColor: '#22223a', marginBottom: 16 }, row: { flexDirection: 'row', gap: 10 }, cancelBtn: { flex: 1, paddingVertical: 10, borderRadius: 8, borderWidth: 1, borderColor: '#2a2a3a', alignItems: 'center' }, cancelTxt: { color: '#6a6a8a', fontWeight: '600' }, confirmBtn: { flex: 1, paddingVertical: 10, borderRadius: 8, backgroundColor: '#1a3a6e', borderWidth: 1, borderColor: '#4a9eff', alignItems: 'center' }, confirmTxt: { color: '#4a9eff', fontWeight: '700' }, btnDis: { opacity: 0.4 }, }); // ── Line list panel (shown when a project is expanded) ─────────────────────── function LineList({ project, currentSessionId, currentProjectId, onActivate, onExport, onDelete, }: { project: ProjectInfo; currentSessionId: string; currentProjectId: string | null; onActivate: (sessionId: string) => void; onExport: (sessionId: string) => void; onDelete: (sessionId: string) => void; }) { const [lines, setLines] = useState([]); const [loading, setLoading] = useState(true); const reload = useCallback(async () => { setLoading(true); const data = await StorageService.listSessionsByProject(project.projectId); setLines(data); setLoading(false); }, [project.projectId]); useEffect(() => { void reload(); }, [reload]); if (loading) return ; if (lines.length === 0) { return 暂无测线; } return ( {lines.map((line) => { const isActive = line.sessionId === currentSessionId; const date = new Date(line.createdAt); const dateStr = `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')} ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`; return ( {isActive && } {line.sessionId} {dateStr} · {line.frameCount} 测点 {!isActive && ( onActivate(line.sessionId)}> 激活 )} onExport(line.sessionId)}> 导出 {!isActive && ( onDelete(line.sessionId)}> 删除 )} ); })} ); } const LL = StyleSheet.create({ wrap: { backgroundColor: '#08080f', marginHorizontal: 12, marginBottom: 8, borderRadius: 10, overflow: 'hidden' }, empty: { color: '#2a2a4a', fontSize: 12, textAlign: 'center', paddingVertical: 14 }, row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#141422', gap: 8 }, rowActive: { backgroundColor: '#0d1a2e' }, rowLeft: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 8 }, activeDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: '#4a9eff' }, lineId: { color: '#6a6a9a', fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' }, lineSub: { color: '#2a2a4a', fontSize: 10, marginTop: 1 }, actions: { flexDirection: 'row', gap: 6 }, btn: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6, borderWidth: 1 }, actBtn: { borderColor: '#1a3a60', backgroundColor: '#0a1830' }, actTxt: { color: '#4a9eff', fontSize: 10, fontWeight: '700' }, expBtn: { borderColor: '#1a4a28', backgroundColor: '#0a1810' }, expTxt: { color: '#3ddc84', fontSize: 10, fontWeight: '700' }, delBtn: { borderColor: '#3a1520', backgroundColor: '#140a0c' }, delTxt: { color: '#ff5c6e', fontSize: 10, fontWeight: '600' }, }); // ── Main screen ────────────────────────────────────────────────────────────── export default function ProjectsScreen() { const insets = useSafeAreaInsets(); const { sessionId: currentSessionId, projectId: currentProjectId, newSession, resumeSession, setProject } = useDataStore(); const { config: deviceConfig, gateConfig } = useDeviceStore(); const [projects, setProjects] = useState([]); const [expanded, setExpanded] = useState(null); const [exporting, setExporting] = useState(null); const [importing, setImporting] = useState(false); const [progress, setProgress] = useState({ current: 0, total: 0 }); // Name modal state const [modal, setModal] = useState<{ visible: boolean; title: string; placeholder: string; initial?: string; onConfirm: (name: string) => void; }>({ visible: false, title: '', placeholder: '', onConfirm: () => {} }); const reload = useCallback(async () => { const data = await StorageService.listProjects(); setProjects(data); }, []); useEffect(() => { void reload(); }, [reload]); const openModal = (opts: typeof modal) => setModal({ ...opts, visible: true }); const closeModal = () => setModal((m) => ({ ...m, visible: false })); const handleCreateProject = () => { openModal({ visible: true, title: '新建工程', placeholder: '输入工程名称', onConfirm: async (name) => { const id = await StorageService.createProject(name); await reload(); setExpanded(id); }, }); }; const handleRenameProject = (project: ProjectInfo) => { openModal({ visible: true, title: '重命名工程', placeholder: '新名称', initial: project.name, onConfirm: async (name) => { await StorageService.renameProject(project.projectId, name); await reload(); }, }); }; const handleDeleteProject = (project: ProjectInfo) => { Alert.alert( '删除工程', `确定删除工程「${project.name}」?\n测线数据将保留,但与工程的关联会断开。`, [ { text: '取消', style: 'cancel' }, { text: '删除', style: 'destructive', onPress: async () => { await StorageService.deleteProject(project.projectId); if (currentProjectId === project.projectId) await setProject(null); await reload(); }, }, ], ); }; const handleNewLine = async (project: ProjectInfo) => { await newSession(project.projectId); await reload(); }; // Open project: resume latest line or create a new one. const handleOpenProject = async (project: ProjectInfo) => { const lines = await StorageService.listSessionsByProject(project.projectId); const latest = lines[0]; // sorted DESC by created_at if (!latest) { await handleNewLine(project); return; } Alert.alert( `打开工程「${project.name}」`, `最近测线: ${latest.sessionId}\n${latest.frameCount} 测点`, [ { text: '取消', style: 'cancel' }, { text: '新建测线', onPress: () => handleNewLine(project), }, { text: '继续最近测线', onPress: async () => { await resumeSession(latest.sessionId, project.projectId); await reload(); }, }, ], ); }; const handleActivateLine = (sessionId: string, projectId: string) => { Alert.alert( '打开测线', '将切换到该测线继续采集,当前数据已保存。', [ { text: '取消', style: 'cancel' }, { text: '打开', onPress: async () => { await resumeSession(sessionId, projectId); await reload(); }, }, ], ); }; const handleExportLine = async (sessionId: string) => { setExporting(sessionId); try { const path = await StorageService.exportSessionMetaCsv(sessionId); await shareFile(path); } catch (e: any) { Alert.alert('导出失败', e.message); } finally { setExporting(null); } }; const handleExportProject = async (project: ProjectInfo) => { setExporting(project.projectId); setProgress({ current: 0, total: 0 }); try { const path = await StorageService.exportProject( project.projectId, deviceConfig, gateConfig, (current, total) => setProgress({ current, total }), ); await shareFile(path); } catch (e: any) { Alert.alert('导出失败', e.message); } finally { setExporting(null); } }; const handleImportProject = async () => { try { const result = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true, }); if (result.canceled) return; const uri = result.assets[0].uri; setImporting(true); setProgress({ current: 0, total: 0 }); const imported = await StorageService.importProject( uri, (current, total) => setProgress({ current, total }), ); await reload(); Alert.alert( '导入成功', `工程「${imported.projectName}」已导入\n${imported.sessionCount} 条测线 · ${imported.frameCount} 测点`, ); } catch (e: any) { Alert.alert('导入失败', e.message); } finally { setImporting(false); } }; const handleDeleteLine = (sessionId: string) => { Alert.alert('删除测线', `确定删除测线 ${sessionId} 的所有数据?`, [ { text: '取消', style: 'cancel' }, { text: '删除', style: 'destructive', onPress: async () => { await StorageService.deleteSession(sessionId); await reload(); }, }, ]); }; return ( {/* Header */} 工程管理 {importing ? : 导入 .tem} + 新建 {/* Progress bar (export / import) */} {(importing || exporting !== null) && progress.total > 0 && ( {progress.current}/{progress.total} 帧 )} {/* Current context pill */} 当前测线 {currentSessionId} {currentProjectId ? ( {projects.find(p => p.projectId === currentProjectId)?.name ?? currentProjectId} ) : ( 未分配工程 )} {/* Project list */} p.projectId} contentContainerStyle={{ paddingBottom: 32 }} ListEmptyComponent={ 暂无工程 点击右上角「新建工程」开始 } renderItem={({ item: project }) => { const isExpanded = expanded === project.projectId; const isCurrentProject = project.projectId === currentProjectId; return ( {/* Project row */} setExpanded(isExpanded ? null : project.projectId)} activeOpacity={0.8} > {isExpanded ? '▾' : '▸'} {project.name} {isCurrentProject && ( 当前 )} {project.lineCount} 条测线 · {new Date(project.createdAt).toLocaleDateString('zh-CN')} handleOpenProject(project)}> 打开 handleExportProject(project)} disabled={exporting === project.projectId} > {exporting === project.projectId ? : .tem} handleRenameProject(project)}> 改名 handleDeleteProject(project)}> 删除 {/* Expanded line list */} {isExpanded && ( handleActivateLine(sid, project.projectId)} onExport={handleExportLine} onDelete={handleDeleteLine} /> )} ); }} /> ); } const S = StyleSheet.create({ root: { flex: 1, backgroundColor: '#090912' }, header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#1a1a2a' }, headerTitle:{ color: '#6a6a9a', fontSize: 14, fontWeight: '700', flex: 1, letterSpacing: 0.5 }, addBtn: { backgroundColor: '#1a3a6e', borderRadius: 8, paddingHorizontal: 14, paddingVertical: 7, borderWidth: 1, borderColor: '#4a9eff' }, addBtnTxt: { color: '#4a9eff', fontSize: 12, fontWeight: '700' }, contextBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 10, backgroundColor: '#0c0c18', borderBottomWidth: 1, borderBottomColor: '#141422', gap: 8 }, contextLabel: { color: '#2a2a4a', fontSize: 10, fontWeight: '600', letterSpacing: 1 }, contextSession:{ color: '#4a4a6a', fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', flex: 1 }, projectPill: { backgroundColor: '#0d2040', borderRadius: 4, paddingHorizontal: 7, paddingVertical: 2, borderWidth: 1, borderColor: '#1a3a6e' }, projectPillTxt:{ color: '#4a9eff', fontSize: 9, fontWeight: '700' }, projectPillNone: { backgroundColor: '#1a1a1a', borderColor: '#2a2a2a' }, projectPillTxtNone:{ color: '#3a3a5a' }, empty: { paddingTop: 80, alignItems: 'center', gap: 10 }, emptyIcon: { fontSize: 40, color: '#1a1a2a' }, emptyTxt: { color: '#2a2a4a', fontSize: 15 }, emptyHint: { color: '#1a1a2a', fontSize: 12 }, projectCard: { marginHorizontal: 12, marginTop: 10, borderRadius: 12, borderWidth: 1, borderColor: '#1a1a2a', overflow: 'hidden', backgroundColor: '#0e0e1c' }, projectRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12, gap: 10 }, projectRowLeft:{ flex: 1, flexDirection: 'row', alignItems: 'center', gap: 8 }, chevron: { color: '#3a3a5a', fontSize: 12, width: 12 }, projectNameRow:{ flexDirection: 'row', alignItems: 'center', gap: 8 }, projectName: { color: '#c0c0d8', fontSize: 14, fontWeight: '700' }, projectMeta: { color: '#3a3a5a', fontSize: 10, marginTop: 2 }, activePill: { backgroundColor: '#0d2040', borderRadius: 4, paddingHorizontal: 6, paddingVertical: 1, borderWidth: 1, borderColor: '#4a9eff55' }, activePillTxt: { color: '#4a9eff', fontSize: 8, fontWeight: '700' }, importBtn: { backgroundColor: '#1e1a08', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 7, borderWidth: 1, borderColor: '#ffd93d66', marginRight: 8, minWidth: 70, alignItems: 'center' }, importBtnTxt: { color: '#ffd93d', fontSize: 12, fontWeight: '700' }, btnDis: { opacity: 0.4 }, progressBar: { marginHorizontal: 16, marginVertical: 6, height: 20, backgroundColor: '#0c0c18', borderRadius: 10, overflow: 'hidden', borderWidth: 1, borderColor: '#1a1a2a', justifyContent: 'center' }, progressFill: { position: 'absolute', left: 0, top: 0, bottom: 0, backgroundColor: '#1a3a6e', borderRadius: 10 }, progressTxt: { color: '#4a9eff', fontSize: 10, fontWeight: '700', textAlign: 'center' }, projectActions:{ flexDirection: 'row', gap: 5 }, pBtn: { paddingHorizontal: 8, paddingVertical: 5, borderRadius: 6, borderWidth: 1, borderColor: '#2a2a3a', backgroundColor: '#111120', alignItems: 'center', justifyContent: 'center' }, pBtnTxt: { color: '#5a5a7a', fontSize: 10, fontWeight: '600' }, pBtnNew: { borderColor: '#1a4a28', backgroundColor: '#0a1810' }, pBtnNewTxt: { color: '#3ddc84', fontSize: 10, fontWeight: '700' }, pBtnExp: { borderColor: '#4a3a1a', backgroundColor: '#1a1408' }, pBtnExpTxt: { color: '#ffd93d', fontSize: 10, fontWeight: '700' }, pBtnDel: { borderColor: '#3a1520', backgroundColor: '#140a0c' }, pBtnDelTxt: { color: '#ff5c6e', fontSize: 10, fontWeight: '600' }, });