- 修改删除工程确认提示,明确说明会永久删除所有测线和测点数据
- 在连续采集过程中每30秒检查一次磁盘空间,低于100MB时自动停止采集并弹窗提醒
📝 docs(user-manual): 添加完整用户手册
- 新增 TEM Receiver 用户手册,涵盖安装、连接、配置、采集、波形查看、测点管理、剖面分析、工程管理、数据导出导入、主题切换、存储管理及常见问题等全部功能说明
637 lines
25 KiB
TypeScript
637 lines
25 KiB
TypeScript
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' },
|
||
});
|