TriloopTem_App/app/(tabs)/projects.tsx
2026-06-19 22:08:10 +08:00

546 lines
22 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';
// ── 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 (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<TouchableOpacity style={NM.backdrop} activeOpacity={1} onPress={onClose} />
<View style={NM.box}>
<Text style={NM.title}>{title}</Text>
<TextInput
style={NM.input}
value={text}
onChangeText={setText}
placeholder={placeholder}
placeholderTextColor="#3a3a5a"
autoFocus
maxLength={40}
/>
<View style={NM.row}>
<TouchableOpacity style={NM.cancelBtn} onPress={onClose}>
<Text style={NM.cancelTxt}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[NM.confirmBtn, !text.trim() && NM.btnDis]}
onPress={() => { if (text.trim()) { onConfirm(text.trim()); onClose(); } }}
disabled={!text.trim()}
>
<Text style={NM.confirmTxt}></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%', 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<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]);
if (loading) return <ActivityIndicator style={{ padding: 16 }} color="#4a9eff" size="small" />;
if (lines.length === 0) {
return <Text style={LL.empty}>线</Text>;
}
return (
<View style={LL.wrap}>
{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, isActive && LL.rowActive]}>
<View style={LL.rowLeft}>
{isActive && <View style={LL.activeDot} />}
<View>
<Text style={LL.lineId} numberOfLines={1}>{line.sessionId}</Text>
<Text style={LL.lineSub}>{dateStr} · {line.frameCount} </Text>
</View>
</View>
<View style={LL.actions}>
{!isActive && (
<TouchableOpacity style={[LL.btn, LL.actBtn]} onPress={() => onActivate(line.sessionId)}>
<Text style={LL.actTxt}></Text>
</TouchableOpacity>
)}
<TouchableOpacity style={[LL.btn, LL.expBtn]} onPress={() => onExport(line.sessionId)}>
<Text style={LL.expTxt}></Text>
</TouchableOpacity>
{!isActive && (
<TouchableOpacity style={[LL.btn, LL.delBtn]} onPress={() => onDelete(line.sessionId)}>
<Text style={LL.delTxt}></Text>
</TouchableOpacity>
)}
</View>
</View>
);
})}
</View>
);
}
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<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);
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 (
<View style={[S.root, { paddingTop: insets.top }]}>
{/* Header */}
<View style={S.header}>
<Text style={S.headerTitle}></Text>
<TouchableOpacity
style={[S.importBtn, importing && S.btnDis]}
onPress={handleImportProject}
disabled={importing}
activeOpacity={0.8}
>
{importing
? <ActivityIndicator color="#ffd93d" size="small" />
: <Text style={S.importBtnTxt}> .tem</Text>}
</TouchableOpacity>
<TouchableOpacity style={S.addBtn} onPress={handleCreateProject} activeOpacity={0.8}>
<Text style={S.addBtnTxt}>+ </Text>
</TouchableOpacity>
</View>
{/* Progress bar (export / import) */}
{(importing || exporting !== null) && progress.total > 0 && (
<View style={S.progressBar}>
<View style={[S.progressFill, { width: `${Math.round(progress.current / progress.total * 100)}%` as any }]} />
<Text style={S.progressTxt}>{progress.current}/{progress.total} </Text>
</View>
)}
{/* Current context pill */}
<View style={S.contextBar}>
<Text style={S.contextLabel}>线</Text>
<Text style={S.contextSession} numberOfLines={1}>{currentSessionId}</Text>
{currentProjectId ? (
<View style={S.projectPill}>
<Text style={S.projectPillTxt}>
{projects.find(p => p.projectId === currentProjectId)?.name ?? currentProjectId}
</Text>
</View>
) : (
<View style={[S.projectPill, S.projectPillNone]}>
<Text style={[S.projectPillTxt, S.projectPillTxtNone]}></Text>
</View>
)}
</View>
{/* Project list */}
<FlatList
data={projects}
keyExtractor={(p) => p.projectId}
contentContainerStyle={{ paddingBottom: 32 }}
ListEmptyComponent={
<View style={S.empty}>
<Text style={S.emptyIcon}></Text>
<Text style={S.emptyTxt}></Text>
<Text style={S.emptyHint}></Text>
</View>
}
renderItem={({ item: project }) => {
const isExpanded = expanded === project.projectId;
const isCurrentProject = project.projectId === currentProjectId;
return (
<View style={S.projectCard}>
{/* Project row */}
<TouchableOpacity
style={S.projectRow}
onPress={() => setExpanded(isExpanded ? null : project.projectId)}
activeOpacity={0.8}
>
<View style={S.projectRowLeft}>
<Text style={S.chevron}>{isExpanded ? '▾' : '▸'}</Text>
<View>
<View style={S.projectNameRow}>
<Text style={S.projectName}>{project.name}</Text>
{isCurrentProject && (
<View style={S.activePill}>
<Text style={S.activePillTxt}></Text>
</View>
)}
</View>
<Text style={S.projectMeta}>
{project.lineCount} 线 · {new Date(project.createdAt).toLocaleDateString('zh-CN')}
</Text>
</View>
</View>
<View style={S.projectActions}>
<TouchableOpacity style={[S.pBtn, S.pBtnNew]} onPress={() => handleOpenProject(project)}>
<Text style={S.pBtnNewTxt}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[S.pBtn, S.pBtnExp, exporting === project.projectId && S.btnDis]}
onPress={() => handleExportProject(project)}
disabled={exporting === project.projectId}
>
{exporting === project.projectId
? <ActivityIndicator color="#ffd93d" size="small" style={{ width: 28 }} />
: <Text style={S.pBtnExpTxt}>.tem</Text>}
</TouchableOpacity>
<TouchableOpacity style={S.pBtn} onPress={() => handleRenameProject(project)}>
<Text style={S.pBtnTxt}></Text>
</TouchableOpacity>
<TouchableOpacity style={[S.pBtn, S.pBtnDel]} onPress={() => handleDeleteProject(project)}>
<Text style={S.pBtnDelTxt}></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}
/>
)}
</View>
);
}}
/>
<NameModal
{...modal}
onClose={closeModal}
/>
</View>
);
}
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' },
});