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';
import { useTheme, type ThemeColors } from '../../src/design/tokens';
// ── 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 theme = useTheme();
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%', borderRadius: 16, padding: 20, borderWidth: 1 },
title: { fontSize: 14, fontWeight: '700', marginBottom: 14 },
input: { borderRadius: 8, padding: 12, fontSize: 14, borderWidth: 1, marginBottom: 16 },
row: { flexDirection: 'row', gap: 10 },
cancelBtn: { flex: 1, paddingVertical: 10, borderRadius: 8, borderWidth: 1, alignItems: 'center' },
cancelTxt: { fontWeight: '600' },
confirmBtn: { flex: 1, paddingVertical: 10, borderRadius: 8, borderWidth: 1, alignItems: 'center' },
confirmTxt: { fontWeight: '700' },
btnDis: { opacity: 0.4 },
});
// ── Line list panel (shown when a project is expanded) ───────────────────────
function LineList({
project,
currentSessionId,
currentProjectId,
onActivate,
onExport,
onDelete,
onNewLine,
onReload,
}: {
project: ProjectInfo;
currentSessionId: string;
currentProjectId: string | null;
onActivate: (sessionId: string) => void;
onExport: (sessionId: string) => void;
onDelete: (sessionId: string) => void;
onNewLine: () => void;
onReload: () => void;
}) {
const theme = useTheme();
const latestFrameId = useDataStore((s) => s.currentFrame?.frameId ?? -1);
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, latestFrameId]);
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 && (
{
Alert.alert('删除测线', '确定删除该测线的所有数据?', [
{ text: '取消', style: 'cancel' },
{
text: '删除', style: 'destructive',
onPress: async () => {
await StorageService.deleteSession(line.sessionId);
await reload();
onReload();
},
},
]);
}}>
删除
)}
);
})}
{ onNewLine(); await reload(); }}
activeOpacity={0.7}
>
+ 新建测线
);
}
const LL = StyleSheet.create({
wrap: { marginHorizontal: 12, marginBottom: 8, borderRadius: 10, overflow: 'hidden' },
empty: { fontSize: 12, textAlign: 'center', paddingVertical: 14 },
row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, gap: 4 },
rowLeft: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 6 },
activeDot: { width: 6, height: 6, borderRadius: 3 },
lineId: { fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
lineSub: { fontSize: 10, marginTop: 1 },
actions: { flexDirection: 'row', gap: 6 },
btn: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6, borderWidth: 1 },
actTxt: { fontSize: 10, fontWeight: '700' },
expTxt: { fontSize: 10, fontWeight: '700' },
delTxt: { fontSize: 10, fontWeight: '600' },
addBtn: { alignItems: 'center', paddingVertical: 10, borderTopWidth: StyleSheet.hairlineWidth, borderStyle: 'dashed' },
addTxt: { fontSize: 11, fontWeight: '700' },
});
// ── Main screen ──────────────────────────────────────────────────────────────
export default function ProjectsScreen() {
const theme = useTheme();
const insets = useSafeAreaInsets();
const { sessionId: currentSessionId, projectId: currentProjectId, newSession, resumeSession, setProject, resetToNoProject, hasProject } = 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);
// First project — auto-create initial session
if (!useDataStore.getState().hasProject) {
await newSession(id);
}
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);
const remaining = await StorageService.listProjects();
if (remaining.length === 0) {
resetToNoProject();
} else if (currentProjectId === project.projectId) {
// Switch to another project's latest session
const next = remaining[0];
const sessions = await StorageService.listSessionsByProject(next.projectId);
if (sessions.length > 0) {
await resumeSession(sessions[0].sessionId, next.projectId);
} else {
await newSession(next.projectId);
}
}
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}
onNewLine={() => handleNewLine(project)}
onReload={reload}
/>
)}
);
}}
/>
);
}
const S = StyleSheet.create({
root: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1 },
headerTitle:{ fontSize: 14, fontWeight: '700', flex: 1, letterSpacing: 0.5 },
addBtn: { borderRadius: 8, paddingHorizontal: 14, paddingVertical: 7, borderWidth: 1 },
addBtnTxt: { fontSize: 12, fontWeight: '700' },
contextBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1, gap: 8 },
contextLabel: { fontSize: 10, fontWeight: '600', letterSpacing: 1 },
contextSession:{ fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', flex: 1 },
projectPill: { borderRadius: 4, paddingHorizontal: 7, paddingVertical: 2, borderWidth: 1 },
projectPillTxt:{ fontSize: 9, fontWeight: '700' },
empty: { paddingTop: 80, alignItems: 'center', gap: 10 },
emptyIcon: { fontSize: 40 },
emptyTxt: { fontSize: 15 },
emptyHint: { fontSize: 12 },
projectCard: { marginHorizontal: 12, marginTop: 10, borderRadius: 12, borderWidth: 1, overflow: 'hidden' },
projectRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12, gap: 10 },
projectRowLeft:{ flex: 1, flexDirection: 'row', alignItems: 'center', gap: 8 },
chevron: { fontSize: 12, width: 12 },
projectNameRow:{ flexDirection: 'row', alignItems: 'center', gap: 8 },
projectName: { fontSize: 14, fontWeight: '700' },
projectMeta: { fontSize: 10, marginTop: 2 },
activePill: { borderRadius: 4, paddingHorizontal: 6, paddingVertical: 1, borderWidth: 1 },
activePillTxt: { fontSize: 8, fontWeight: '700' },
importBtn: { borderRadius: 8, paddingHorizontal: 12, paddingVertical: 7, borderWidth: 1, marginRight: 8, minWidth: 70, alignItems: 'center' },
importBtnTxt: { fontSize: 12, fontWeight: '700' },
btnDis: { opacity: 0.4 },
progressBar: { marginHorizontal: 16, marginVertical: 6, height: 20, borderRadius: 10, overflow: 'hidden', borderWidth: 1, justifyContent: 'center' },
progressFill: { position: 'absolute', left: 0, top: 0, bottom: 0, borderRadius: 10 },
progressTxt: { fontSize: 10, fontWeight: '700', textAlign: 'center' },
projectActions:{ flexDirection: 'row', gap: 5 },
pBtn: { paddingHorizontal: 8, paddingVertical: 5, borderRadius: 6, borderWidth: 1, alignItems: 'center', justifyContent: 'center' },
pBtnTxt: { fontSize: 10, fontWeight: '700' },
});