TriloopTem_App/app/(tabs)/projects.tsx
zhoujie e1ca0d8512 feat(projects): 更新删除工程提示并添加存储空间监控
- 修改删除工程确认提示,明确说明会永久删除所有测线和测点数据
- 在连续采集过程中每30秒检查一次磁盘空间,低于100MB时自动停止采集并弹窗提醒

📝 docs(user-manual): 添加完整用户手册

- 新增 TEM Receiver 用户手册,涵盖安装、连接、配置、采集、波形查看、测点管理、剖面分析、工程管理、数据导出导入、主题切换、存储管理及常见问题等全部功能说明
2026-06-21 16:58:20 +08:00

637 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<TouchableOpacity style={NM.backdrop} activeOpacity={1} onPress={onClose} />
<View style={[NM.box, {
backgroundColor: theme.bg.raised,
borderColor: theme.bg.border,
}]}>
<Text style={[NM.title, { color: theme.text.secondary }]}>{title}</Text>
<TextInput
style={[NM.input, {
backgroundColor: theme.bg.void,
color: theme.text.primary,
borderColor: theme.bg.border,
}]}
value={text}
onChangeText={setText}
placeholder={placeholder}
placeholderTextColor={theme.text.muted}
autoFocus
maxLength={40}
/>
<View style={NM.row}>
<TouchableOpacity style={[NM.cancelBtn, { borderColor: theme.bg.border }]} onPress={onClose}>
<Text style={[NM.cancelTxt, { color: theme.text.muted }]}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[NM.confirmBtn, {
backgroundColor: theme.blue.border,
borderColor: theme.blue.fg,
}, !text.trim() && NM.btnDis]}
onPress={() => { if (text.trim()) { onConfirm(text.trim()); onClose(); } }}
disabled={!text.trim()}
>
<Text style={[NM.confirmTxt, { color: theme.blue.fg }]}></Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
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<SessionInfo[]>([]);
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 <ActivityIndicator style={{ padding: 16 }} color={theme.blue.fg} size="small" />;
if (lines.length === 0) {
return <Text style={[LL.empty, { color: theme.text.ghost }]}>线</Text>;
}
return (
<View style={[LL.wrap, { backgroundColor: theme.bg.void }]}>
{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 (
<View key={line.sessionId} style={[LL.row, { borderBottomColor: theme.bg.divider }, isActive && { backgroundColor: theme.blue.bg }]}>
<View style={LL.rowLeft}>
{isActive && <View style={[LL.activeDot, { backgroundColor: theme.blue.fg }]} />}
<View>
<Text style={[LL.lineId, { color: theme.text.muted }]} numberOfLines={1}>{line.sessionId}</Text>
<Text style={[LL.lineSub, { color: theme.text.ghost }]}>{dateStr} · {line.frameCount} </Text>
</View>
</View>
<View style={LL.actions}>
{!isActive && (
<TouchableOpacity style={[LL.btn, {
borderColor: theme.blue.border,
backgroundColor: theme.blue.bg,
}]} onPress={() => onActivate(line.sessionId)}>
<Text style={[LL.actTxt, { color: theme.blue.fg }]}></Text>
</TouchableOpacity>
)}
<TouchableOpacity style={[LL.btn, {
borderColor: theme.green.border,
backgroundColor: theme.green.bg,
}]} onPress={() => onExport(line.sessionId)}>
<Text style={[LL.expTxt, { color: theme.green.fg }]}></Text>
</TouchableOpacity>
{!isActive && (
<TouchableOpacity style={[LL.btn, {
borderColor: theme.red.border,
backgroundColor: theme.red.bg,
}]} onPress={() => {
Alert.alert('删除测线', '确定删除该测线的所有数据?', [
{ text: '取消', style: 'cancel' },
{
text: '删除', style: 'destructive',
onPress: async () => {
await StorageService.deleteSession(line.sessionId);
await reload();
onReload();
},
},
]);
}}>
<Text style={[LL.delTxt, { color: theme.red.fg }]}></Text>
</TouchableOpacity>
)}
</View>
</View>
);
})}
<TouchableOpacity
style={[LL.addBtn, { borderColor: theme.blue.border }]}
onPress={async () => { onNewLine(); await reload(); }}
activeOpacity={0.7}
>
<Text style={[LL.addTxt, { color: theme.blue.fg }]}>+ 线</Text>
</TouchableOpacity>
</View>
);
}
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<ProjectInfo[]>([]);
const [expanded, setExpanded] = useState<string | null>(null);
const [exporting, setExporting] = useState<string | null>(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 (
<View style={[S.root, { paddingTop: insets.top, backgroundColor: theme.bg.base }]}>
{/* Header */}
<View style={[S.header, { borderBottomColor: theme.bg.border }]}>
<Text style={[S.headerTitle, { color: theme.text.muted }]}></Text>
<TouchableOpacity
style={[S.importBtn, {
backgroundColor: theme.amber.bg,
borderColor: theme.amber.fg + '66',
}, importing && S.btnDis]}
onPress={handleImportProject}
disabled={importing}
activeOpacity={0.8}
>
{importing
? <ActivityIndicator color={theme.amber.fg} size="small" />
: <Text style={[S.importBtnTxt, { color: theme.amber.fg }]}> .tem</Text>}
</TouchableOpacity>
<TouchableOpacity style={[S.addBtn, {
backgroundColor: theme.blue.border,
borderColor: theme.blue.fg,
}]} onPress={handleCreateProject} activeOpacity={0.8}>
<Text style={[S.addBtnTxt, { color: theme.blue.fg }]}>+ </Text>
</TouchableOpacity>
</View>
{/* Progress bar (export / import) */}
{(importing || exporting !== null) && progress.total > 0 && (
<View style={[S.progressBar, {
backgroundColor: theme.bg.void,
borderColor: theme.bg.border,
}]}>
<View style={[S.progressFill, { backgroundColor: theme.blue.border }]} />
<Text style={[S.progressTxt, { color: theme.blue.fg }]}>{progress.current}/{progress.total} </Text>
</View>
)}
{/* Current context pill */}
<View style={[S.contextBar, {
backgroundColor: theme.bg.void,
borderBottomColor: theme.bg.divider,
}]}>
<Text style={[S.contextLabel, { color: theme.text.ghost }]}>线</Text>
<Text style={[S.contextSession, { color: theme.text.muted }]} numberOfLines={1}>{currentSessionId}</Text>
{currentProjectId ? (
<View style={[S.projectPill, {
backgroundColor: theme.blue.bg,
borderColor: theme.blue.border,
}]}>
<Text style={[S.projectPillTxt, { color: theme.blue.fg }]}>
{projects.find(p => p.projectId === currentProjectId)?.name ?? currentProjectId}
</Text>
</View>
) : (
<View style={[S.projectPill, {
backgroundColor: theme.bg.base,
borderColor: theme.bg.border,
}]}>
<Text style={[S.projectPillTxt, { color: theme.text.muted }]}></Text>
</View>
)}
</View>
{/* Project list */}
<FlatList
data={projects}
keyExtractor={(p) => p.projectId}
contentContainerStyle={{ paddingBottom: 32 }}
ListEmptyComponent={
<View style={S.empty}>
<Text style={[S.emptyIcon, { color: theme.bg.border }]}></Text>
<Text style={[S.emptyTxt, { color: theme.text.ghost }]}></Text>
<Text style={[S.emptyHint, { color: theme.bg.border }]}></Text>
</View>
}
renderItem={({ item: project }) => {
const isExpanded = expanded === project.projectId;
const isCurrentProject = project.projectId === currentProjectId;
return (
<View style={[S.projectCard, {
borderColor: theme.bg.border,
backgroundColor: theme.bg.surface,
}]}>
{/* Project row */}
<TouchableOpacity
style={S.projectRow}
onPress={() => setExpanded(isExpanded ? null : project.projectId)}
activeOpacity={0.8}
>
<View style={S.projectRowLeft}>
<Text style={[S.chevron, { color: theme.text.muted }]}>{isExpanded ? '▾' : '▸'}</Text>
<View>
<View style={S.projectNameRow}>
<Text style={[S.projectName, { color: theme.text.primary }]}>{project.name}</Text>
{isCurrentProject && (
<View style={[S.activePill, {
backgroundColor: theme.blue.bg,
borderColor: theme.blue.fg + '55',
}]}>
<Text style={[S.activePillTxt, { color: theme.blue.fg }]}></Text>
</View>
)}
</View>
<Text style={[S.projectMeta, { color: theme.text.muted }]}>
{project.lineCount} 线 · {new Date(project.createdAt).toLocaleDateString('zh-CN')}
</Text>
</View>
</View>
<View style={S.projectActions}>
<TouchableOpacity style={[S.pBtn, {
borderColor: theme.green.border,
backgroundColor: theme.green.bg,
}]} onPress={() => handleOpenProject(project)}>
<Text style={[S.pBtnTxt, { color: theme.green.fg }]}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[S.pBtn, {
borderColor: theme.amber.border,
backgroundColor: theme.amber.bg,
}, exporting === project.projectId && S.btnDis]}
onPress={() => handleExportProject(project)}
disabled={exporting === project.projectId}
>
{exporting === project.projectId
? <ActivityIndicator color={theme.amber.fg} size="small" style={{ width: 28 }} />
: <Text style={[S.pBtnTxt, { color: theme.amber.fg }]}>.tem</Text>}
</TouchableOpacity>
<TouchableOpacity style={[S.pBtn, {
borderColor: theme.bg.border,
backgroundColor: theme.bg.raised,
}]} onPress={() => handleRenameProject(project)}>
<Text style={[S.pBtnTxt, { color: theme.text.muted }]}></Text>
</TouchableOpacity>
<TouchableOpacity style={[S.pBtn, {
borderColor: theme.red.border,
backgroundColor: theme.red.bg,
}]} onPress={() => handleDeleteProject(project)}>
<Text style={[S.pBtnTxt, { color: theme.red.fg }]}></Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
{/* Expanded line list */}
{isExpanded && (
<LineList
project={project}
currentSessionId={currentSessionId}
currentProjectId={currentProjectId}
onActivate={(sid) => handleActivateLine(sid, project.projectId)}
onExport={handleExportLine}
onDelete={handleDeleteLine}
onNewLine={() => handleNewLine(project)}
onReload={reload}
/>
)}
</View>
);
}}
/>
<NameModal
{...modal}
onClose={closeModal}
/>
</View>
);
}
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' },
});