feat: enhance web extension with export functionality and utility improvements
Some checks failed
Tests / Tests (push) Has been cancelled
ESLint Check / ESLint Check and Report Upload (push) Has been cancelled
Prettier Check / Format Check (push) Has been cancelled
Prettier Check / Format Code (push) Has been cancelled
ESLint Check / Build Base for Bundle Size Comparison (push) Has been cancelled

- Add export functionality to SessionList and Player pages
- Add new utility modules: dataOperations, format, path, settings
- Update manifest with export and download permissions
- Enhance storage utility with new data operations
- Add various test scripts and documentation files
This commit is contained in:
xugp
2026-04-16 10:15:10 +08:00
parent 2a7084db5b
commit 71438691b3
38 changed files with 11241 additions and 2338 deletions

View File

@@ -22,6 +22,14 @@ import { isFirefox } from '~/utils';
import { addSession } from '~/utils/storage';
void (async () => {
// Handle keyboard shortcuts
Browser.commands.onCommand.addListener((command) => {
if (command === 'start-recording') {
channel.emit(EventName.StartButtonClicked, {});
} else if (command === 'stop-recording') {
channel.emit(EventName.StopButtonClicked, {});
}
});
// assign default value to settings of this extension
const result =
((await Browser.storage.sync.get(SyncDataKey.settings)) as SyncData) ||

View File

@@ -14,7 +14,23 @@
"48": "icon48.png",
"128": "icon128.png"
},
"permissions": ["activeTab", "storage", "unlimitedStorage"]
"permissions": ["activeTab", "storage", "unlimitedStorage"],
"commands": {
"start-recording": {
"suggested_key": {
"default": "Ctrl+Shift+R",
"mac": "Command+Shift+R"
},
"description": "Start Recording"
},
"stop-recording": {
"suggested_key": {
"default": "Ctrl+Shift+S",
"mac": "Command+Shift+S"
},
"description": "Stop Recording"
}
}
},
"v2": {
"common": {

View File

@@ -1,31 +1,660 @@
import { Route, Routes } from 'react-router-dom';
import { Route, Routes, useNavigate, useLocation } from 'react-router-dom';
import SidebarWithHeader from '~/components/SidebarWithHeader';
import { FiList, FiSettings } from 'react-icons/fi';
import { Box } from '@chakra-ui/react';
import { FiList, FiSettings, FiShield, FiDatabase, FiDownload, FiCamera, FiMonitor, FiTrash2, FiSave } from 'react-icons/fi';
import {
Box,
Button,
Flex,
Heading,
Text,
VStack,
HStack,
Divider,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Switch,
Input,
Select,
useColorModeValue,
useToast,
Checkbox,
FormControl,
FormLabel,
IconButton,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
useDisclosure,
Badge,
} from '@chakra-ui/react';
import { useEffect, useState } from 'react';
import { Browser } from 'webextension-polyfill';
import { Settings, SyncDataKey } from '~/types';
import {
getStorageSettings,
saveStorageSettings,
clearAllSessions,
exportSettings,
importSettings,
} from '~/utils/settings';
// Settings sections
const SETTINGS_SECTIONS = [
{ id: 'general', label: 'General', icon: FiSettings },
{ id: 'recording', label: 'Recording', icon: FiCamera },
{ id: 'privacy', label: 'Privacy', icon: FiShield },
{ id: 'storage', label: 'Storage', icon: FiDatabase },
{ id: 'paths', label: 'File Paths', icon: FiMonitor },
{ id: 'export', label: 'Export', icon: FiDownload },
] as const;
export default function App() {
const navigate = useNavigate();
const location = useLocation();
const toast = useToast();
const [settings, setSettings] = useState<Settings>({});
const [isLoading, setIsLoading] = useState(true);
const [storageInfo, setStorageInfo] = useState({ used: 0, total: 0 });
const { isOpen: isClearModalOpen, onOpen: onClearModalOpen, onClose: onClearModalClose } = useDisclosure();
useEffect(() => {
const loadSettings = async () => {
try {
const savedSettings = await getStorageSettings();
setSettings(savedSettings);
// Get storage info
if (chrome?.storage?.local) {
const storage = await chrome.storage.local.getBytesInUse();
setStorageInfo({ used: storage, total: 5 * 1024 * 1024 }); // 5MB limit
}
} catch (error) {
console.error('Error loading settings:', error);
toast({
title: 'Error',
description: 'Failed to load settings',
status: 'error',
duration: 3000,
});
} finally {
setIsLoading(false);
}
};
loadSettings();
}, []);
const handleSaveSettings = async () => {
try {
await saveStorageSettings(settings);
toast({
title: 'Settings saved',
description: 'Your settings have been saved successfully',
status: 'success',
duration: 3000,
});
} catch (error) {
toast({
title: 'Error',
description: 'Failed to save settings',
status: 'error',
duration: 3000,
});
}
};
const handleClearAllData = async () => {
try {
await clearAllSessions();
toast({
title: 'Data cleared',
description: 'All recordings have been permanently deleted',
status: 'success',
duration: 3000,
});
onClearModalClose();
} catch (error) {
toast({
title: 'Error',
description: 'Failed to clear data',
status: 'error',
duration: 3000,
});
}
};
const handleExportSettings = () => {
exportSettings(settings);
toast({
title: 'Settings exported',
description: 'Settings file downloaded',
status: 'success',
duration: 3000,
});
};
const handleImportSettings = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
try {
const content = e.target?.result as string;
const importedSettings = JSON.parse(content) as Settings;
setSettings(importedSettings);
toast({
title: 'Settings imported',
description: 'Settings have been imported successfully',
status: 'success',
duration: 3000,
});
} catch (error) {
toast({
title: 'Error',
description: 'Invalid settings file',
status: 'error',
duration: 3000,
});
}
};
reader.readAsText(file);
};
const getStoragePercentage = () => {
return Math.round((storageInfo.used / storageInfo.total) * 100);
};
const renderSettingsSection = (sectionId: string) => {
const section = SETTINGS_SECTIONS.find(s => s.id === sectionId);
if (!section) return null;
switch (sectionId) {
case 'general':
return (
<VStack spacing={6} align="stretch">
<Heading size="lg">{section.label}</Heading>
<FormControl>
<FormLabel>Default Recording Quality</FormLabel>
<Select
value={settings.recordingQuality || 'balanced'}
onChange={(e) => setSettings({...settings, recordingQuality: e.target.value as any})}
>
<option value="balanced">Balanced (Recommended)</option>
<option value="high">High Quality (More Events)</option>
<option value="low">Low Memory (Fewer Events)</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>Auto-start Recording</FormLabel>
<Switch
isChecked={settings.autoStart || false}
onChange={(e) => setSettings({...settings, autoStart: e.target.checked})}
/>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')} mt={2}>
Automatically start recording when opening a new tab
</Text>
</FormControl>
<FormControl>
<FormLabel>Enable Notifications</FormLabel>
<Switch
isChecked={settings.enableNotifications || true}
onChange={(e) => setSettings({...settings, enableNotifications: e.target.checked})}
/>
</FormControl>
<FormControl>
<FormLabel>Theme</FormLabel>
<Select
value={settings.theme || 'system'}
onChange={(e) => setSettings({...settings, theme: e.target.value as any})}
>
<option value="system">System Default</option>
<option value="light">Light Mode</option>
<option value="dark">Dark Mode</option>
</Select>
</FormControl>
</VStack>
);
case 'recording':
return (
<VStack spacing={6} align="stretch">
<Heading size="lg">{section.label}</Heading>
<FormControl>
<FormLabel>Record Canvas Elements</FormLabel>
<Switch
isChecked={settings.recordCanvas || true}
onChange={(e) => setSettings({...settings, recordCanvas: e.target.checked})}
/>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')} mt={2}>
Record canvas and canvas-based drawing events
</Text>
</FormControl>
<FormControl>
<FormLabel>Record Input Fields</FormLabel>
<Switch
isChecked={settings.recordInputs || true}
onChange={(e) => setSettings({...settings, recordInputs: e.target.checked})}
/>
</FormControl>
<FormControl>
<FormLabel>Record Mouse Movements</FormLabel>
<Switch
isChecked={settings.recordMouse || true}
onChange={(e) => setSettings({...settings, recordMouse: e.target.checked})}
/>
</FormControl>
<FormControl>
<FormLabel>Record Scroll Events</FormLabel>
<Switch
isChecked={settings.recordScroll || true}
onChange={(e) => setSettings({...settings, recordScroll: e.target.checked})}
/>
</FormControl>
<FormControl>
<FormLabel>Recording Shortcuts</FormLabel>
<HStack>
<Input
value={settings.shortcuts?.start || 'Ctrl+Shift+R'}
onChange={(e) => setSettings({
...settings,
shortcuts: {...settings.shortcuts, start: e.target.value}
})}
placeholder="Start recording"
/>
<Input
value={settings.shortcuts?.stop || 'Ctrl+Shift+S'}
onChange={(e) => setSettings({
...settings,
shortcuts: {...settings.shortcuts, stop: e.target.value}
})}
placeholder="Stop recording"
/>
</HStack>
</FormControl>
</VStack>
);
case 'privacy':
return (
<VStack spacing={6} align="stretch">
<Heading size="lg">{section.label}</Heading>
<FormControl>
<FormLabel>Block Sensitive Data</FormLabel>
<Switch
isChecked={settings.blockSensitiveData || true}
onChange={(e) => setSettings({...settings, blockSensitiveData: e.target.checked})}
/>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')} mt={2}>
Automatically mask passwords, credit cards, and other sensitive information
</Text>
</FormControl>
<FormControl>
<FormLabel>Mask Input Fields</FormLabel>
<Switch
isChecked={settings.maskInputs || false}
onChange={(e) => setSettings({...settings, maskInputs: e.target.checked})}
/>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')} mt={2}>
Replace input text with asterisks during recording
</Text>
</FormControl>
<FormControl>
<FormLabel>Exclude Specific Domains</FormLabel>
<VStack spacing={2}>
{settings.excludedDomains?.map((domain, index) => (
<HStack key={index} w="100%">
<Input
value={domain}
onChange={(e) => {
const newDomains = [...(settings.excludedDomains || [])];
newDomains[index] = e.target.value;
setSettings({...settings, excludedDomains: newDomains});
}}
placeholder="example.com"
/>
<IconButton
aria-label="Remove domain"
icon={<FiTrash2 />}
onClick={() => {
const newDomains = [...(settings.excludedDomains || [])];
newDomains.splice(index, 1);
setSettings({...settings, excludedDomains: newDomains});
}}
/>
</HStack>
))}
<Button
leftIcon={<FiPlus />}
variant="outline"
size="sm"
onClick={() => setSettings({
...settings,
excludedDomains: [...(settings.excludedDomains || []), '']
})}
>
Add Domain
</Button>
</VStack>
</FormControl>
</VStack>
);
case 'storage':
return (
<VStack spacing={6} align="stretch">
<Heading size="lg">{section.label}</Heading>
<Alert status="info">
<AlertIcon />
<AlertTitle>Storage Usage</AlertTitle>
<AlertDescription>
Using {getStoragePercentage()}% of your storage limit
</AlertDescription>
</Alert>
<Box>
<VStack spacing={2}>
<Flex justify="space-between">
<Text fontSize="sm">Used</Text>
<Text fontSize="sm">{Math.round(storageInfo.used / 1024 / 1024)} MB</Text>
</Flex>
<Box height="2" bg={useColorModeValue('gray.200', 'gray.700')} borderRadius="full" overflow="hidden">
<Box
height="100%"
bg={getStoragePercentage() > 80 ? 'red.500' : getStoragePercentage() > 60 ? 'yellow.500' : 'green.500'}
borderRadius="full"
width={`${getStoragePercentage()}%`}
/>
</Box>
<Flex justify="space-between">
<Text fontSize="sm">Total</Text>
<Text fontSize="sm">5 MB</Text>
</Flex>
</VStack>
</Box>
<FormControl>
<FormLabel>Auto-cleanup Old Recordings</FormLabel>
<Select
value={settings.autoCleanupDays || 30}
onChange={(e) => setSettings({...settings, autoCleanupDays: Number(e.target.value)})}
>
<option value={7}>After 7 days</option>
<option value={30}>After 30 days (Recommended)</option>
<option value={90}>After 90 days</option>
<option value={0}>Never</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>Max Recording Size</FormLabel>
<Select
value={settings.maxRecordingSize || '100'}
onChange={(e) => setSettings({...settings, maxRecordingSize: e.target.value as any})}
>
<option value="50">50 MB</option>
<option value="100">100 MB (Recommended)</option>
<option value="200">200 MB</option>
<option value="500">500 MB</option>
</Select>
</FormControl>
</VStack>
);
case 'paths':
return (
<VStack spacing={6} align="stretch">
<Heading size="lg">{section.label}</Heading>
<FormControl>
<FormLabel>Default Save Location</FormLabel>
<Input
value={settings.savePath || 'recordings'}
onChange={(e) => setSettings({...settings, savePath: e.target.value})}
placeholder="recordings"
/>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')} mt={2}>
Base folder for saving recordings (relative to extension directory)
</Text>
</FormControl>
<FormControl>
<FormLabel>Create Subfolders</FormLabel>
<Switch
isChecked={settings.createSubfolders || true}
onChange={(e) => setSettings({...settings, createSubfolders: e.target.checked})}
/>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')} mt={2}>
Create separate folders for each recording session
</Text>
</FormControl>
<FormControl>
<FormLabel>File Name Format</FormLabel>
<Select
value={settings.fileNameFormat || 'timestamp'}
onChange={(e) => setSettings({...settings, fileNameFormat: e.target.value as any})}
>
<option value="timestamp">Timestamp-based</option>
<option value="custom">Custom format</option>
</Select>
</FormControl>
{settings.fileNameFormat === 'custom' && (
<FormControl>
<FormLabel>Custom Name Format</FormLabel>
<Input
value={settings.customNameFormat || 'recording-{date}-{time}'}
onChange={(e) => setSettings({...settings, customNameFormat: e.target.value})}
placeholder="recording-{date}-{time}"
/>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')} mt={2}>
Use {date}, {time}, {session} as variables
</Text>
</FormControl>
)}
<Box bg={useColorModeValue('blue.50', 'blue.900')} p={4} rounded="lg">
<Text fontSize="sm" fontWeight="semibold" mb={2}>Format Examples:</Text>
<VStack spacing={2} align="stretch" fontSize="xs">
<Box><Text color={useColorModeValue('gray.700', 'gray.300')}>Timestamp:</Text> <Text color={useColorModeValue('blue.600', 'blue.200')}>20240115_143022</Text></Box>
<Box><Text color={useColorModeValue('gray.700', 'gray.300')}>Custom:</Text> <Text color={useColorModeValue('blue.600', 'blue.200')}>my-recording-2024-01-15</Text></Box>
<Box><Text color={useColorModeValue('gray.700', 'gray.300')}>Custom with time:</Text> <Text color={useColorModeValue('blue.600', 'blue.200')}>session-{date}-{time}</Text></Box>
</VStack>
</Box>
</VStack>
);
case 'export':
return (
<VStack spacing={6} align="stretch">
<Heading size="lg">{section.label}</Heading>
<VStack spacing={4}>
<Button
leftIcon={<FiDownload />}
onClick={handleExportSettings}
colorScheme="blue"
>
Export Settings
</Button>
<FormControl>
<FormLabel>Import Settings</FormLabel>
<Input
type="file"
accept=".json"
onChange={handleImportSettings}
multiple={false}
/>
</FormControl>
</VStack>
<Box>
<Heading size="md" mb={4}>Export Options</Heading>
<FormControl>
<FormLabel>Default Export Format</FormLabel>
<Select
value={settings.defaultExportFormat || 'json'}
onChange={(e) => setSettings({...settings, defaultExportFormat: e.target.value as any})}
>
<option value="json">JSON</option>
<option value="html">HTML with Player</option>
<option value="zip">ZIP Package</option>
</Select>
</FormControl>
<FormControl as={Flex} alignItems="center" marginTop={4}>
<Switch
isChecked={settings.includeMetadata || true}
onChange={(e) => setSettings({...settings, includeMetadata: e.target.checked})}
marginRight={2}
/>
<FormLabel marginBottom={0}>Include Metadata in Exports</FormLabel>
</FormControl>
</Box>
</VStack>
);
}
};
if (isLoading) {
return (
<SidebarWithHeader
title="Settings"
headBarItems={[
{ label: 'Sessions', icon: FiList, href: '/pages/index.html#' },
{ label: 'Settings', icon: FiSettings, href: '#' },
]}
sideBarItems={SETTINGS_SECTIONS.map(section => ({
label: section.label,
icon: section.icon,
href: `#${section.id}`,
}))}
>
<Box padding="10">
<Text>Loading settings...</Text>
</Box>
</SidebarWithHeader>
);
}
const currentSection = location.hash.slice(1) || 'general';
return (
<SidebarWithHeader
title="Settings"
headBarItems={[
{
label: 'Sessions',
icon: FiList,
href: '/pages/index.html#',
},
{
label: 'Settings',
icon: FiSettings,
href: '#',
},
{ label: 'Sessions', icon: FiList, href: '/pages/index.html#' },
{ label: 'Settings', icon: FiSettings, href: '#' },
]}
sideBarItems={[]}
sideBarItems={SETTINGS_SECTIONS.map(section => ({
label: section.label,
icon: section.icon,
href: `#${section.id}`,
}))}
>
<Box p="10">
<Routes>
<Route path="/" element={<></>} />
</Routes>
<Box padding="6">
<Flex justifyContent="space-between" alignItems="center" marginBottom={6}>
<VStack align="start" spacing={0}>
<Heading size="lg">Settings</Heading>
<Text color={useColorModeValue('gray.600', 'gray.400')}>
Customize your recording experience
</Text>
</VStack>
<Button
leftIcon={<FiSave />}
colorScheme="blue"
onClick={handleSaveSettings}
isLoading={isLoading}
>
Save Settings
</Button>
</Flex>
<Divider marginBottom={6} />
{renderSettingsSection(currentSection)}
<Divider marginTop={6} />
<Box>
<Heading size="lg" marginBottom={4}>Danger Zone</Heading>
<VStack spacing={4}>
<Alert status="warning">
<AlertIcon />
<AlertTitle>Warning</AlertTitle>
<AlertDescription>
These actions cannot be undone.
</AlertDescription>
</Alert>
<Button
leftIcon={<FiTrash2 />}
colorScheme="red"
variant="outline"
onClick={onClearModalOpen}
>
Clear All Data
</Button>
</VStack>
</Box>
</Box>
{/* Clear All Data Modal */}
<Modal isOpen={isClearModalOpen} onClose={onClearModalClose} isCentered>
<ModalOverlay />
<ModalContent>
<ModalHeader>Clear All Data</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<Text>
This action will permanently delete all your recordings and cannot be undone.
</Text>
<Alert status="error">
<AlertIcon />
<AlertTitle>Confirm Deletion</AlertTitle>
</Alert>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" marginRight={3} onClick={onClearModalClose}>
Cancel
</Button>
<Button colorScheme="red" onClick={handleClearAllData}>
Delete All Data
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</SidebarWithHeader>
);
}
// Plus icon component
function FiPlus() {
return <svg style={{ width: '16px', height: '16px' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>;
}

View File

@@ -8,53 +8,225 @@ import {
BreadcrumbItem,
BreadcrumbLink,
Center,
Flex,
Button,
IconButton,
Text,
HStack,
VStack,
useColorModeValue,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Code,
useToast,
} from '@chakra-ui/react';
import { getEvents, getSession } from '~/utils/storage';
import {
FiPlay,
FiPause,
FiSkipBack,
FiSkipForward,
FiRotateCcw,
FiDownload,
FiShare2,
FiX,
FiInfo,
FiClock,
FiCalendar,
FiDatabase,
} from 'react-icons/fi';
import { getEvents, getSession, deleteSession } from '~/utils/storage';
import { formatFileSize } from '~/utils/format';
export default function Player() {
const playerElRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<Replayer | null>(null);
const { sessionId } = useParams();
const [sessionName, setSessionName] = useState('');
const [session, setSession] = useState<any>(null);
const [events, setEvents] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [totalTime, setTotalTime] = useState(0);
const toast = useToast();
useEffect(() => {
if (!sessionId) return;
getSession(sessionId)
.then((session) => {
setSessionName(session.name);
})
.catch((err) => {
console.error(err);
});
getEvents(sessionId)
.then((events) => {
if (!playerElRef.current) return;
if (playerRef.current) return;
const manifest = chrome.runtime.getManifest();
const rrwebPlayerVersion = manifest.version_name || manifest.version;
const linkEl = document.createElement('link');
linkEl.href = `https://cdn.jsdelivr.net/npm/rrweb-player@${rrwebPlayerVersion}/dist/style.min.css`;
linkEl.rel = 'stylesheet';
document.head.appendChild(linkEl);
playerRef.current = new Replayer({
target: playerElRef.current as HTMLElement,
props: {
events,
autoPlay: true,
},
});
})
.catch((err) => {
const loadSession = async () => {
try {
const sessionData = await getSession(sessionId);
const eventsData = await getEvents(sessionId);
setSession(sessionData);
setEvents(eventsData);
setTotalTime(eventsData.length > 0 ? eventsData[eventsData.length - 1].timestamp : 0);
setSessionName(sessionData.name);
} catch (err) {
console.error(err);
});
return () => {
// eslint-disable-next-line
playerRef.current?.pause();
// eslint-disable-next-line
playerRef.current?.$destroy();
toast({
title: 'Error loading session',
description: (err as Error).message,
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
}, [sessionId]);
loadSession();
}, [sessionId, toast]);
useEffect(() => {
if (!sessionId || !events.length || isLoading) return;
const manifest = chrome.runtime.getManifest();
const rrwebPlayerVersion = manifest.version_name || manifest.version;
const linkEl = document.createElement('link');
linkEl.href = `https://cdn.jsdelivr.net/npm/rrweb-player@${rrwebPlayerVersion}/dist/style.min.css`;
linkEl.rel = 'stylesheet';
document.head.appendChild(linkEl);
playerRef.current = new Replayer({
target: playerElRef.current as HTMLElement,
props: {
events,
autoPlay: false,
speed: 1,
width: '100%',
height: '600px',
},
});
// Listen for player events
playerRef.current.on('play', () => {
setIsPlaying(true);
});
playerRef.current.on('pause', () => {
setIsPlaying(false);
});
playerRef.current.on('timeupdate', (time) => {
setCurrentTime(time);
});
playerRef.current.on('finish', () => {
setIsPlaying(false);
setCurrentTime(totalTime);
});
return () => {
if (playerRef.current) {
playerRef.current.pause();
playerRef.current.$destroy();
}
// Remove the CSS link
const existingLink = document.querySelector(`link[href="${linkEl.href}"]`);
if (existingLink) {
existingLink.remove();
}
};
}, [sessionId, events, isLoading, totalTime]);
const handlePlayPause = () => {
if (playerRef.current) {
if (isPlaying) {
playerRef.current.pause();
} else {
playerRef.current.play();
}
}
};
const handleRestart = () => {
if (playerRef.current) {
playerRef.current.reset();
setCurrentTime(0);
}
};
const handleDownload = () => {
if (!session || !events) return;
const blob = new Blob([JSON.stringify({ session, events }, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${session.name}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: 'Download started',
description: `Recording saved as ${session.name}.json`,
status: 'success',
duration: 3000,
isClosable: true,
});
};
const handleDelete = async () => {
if (!session) return;
if (window.confirm(`Are you sure you want to delete "${session.name}"? This action cannot be undone.`)) {
try {
await deleteSession(session.id);
toast({
title: 'Session deleted',
description: 'The session has been permanently deleted.',
status: 'info',
duration: 3000,
isClosable: true,
});
// Navigate back to session list
window.location.href = '#/';
} catch (err) {
console.error(err);
toast({
title: 'Error deleting session',
description: (err as Error).message,
status: 'error',
duration: 3000,
isClosable: true,
});
}
}
};
const formatTime = (timestamp: number) => {
const seconds = Math.floor(timestamp / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
if (isLoading) {
return (
<Center h="600px">
<Text fontSize="lg">Loading recording...</Text>
</Center>
);
}
if (!session) {
return (
<Alert status="error">
<AlertIcon />
<AlertTitle>Session not found</AlertTitle>
<AlertDescription>The requested session could not be loaded.</AlertDescription>
</Alert>
);
}
return (
<>
@@ -63,12 +235,160 @@ export default function Player() {
<BreadcrumbLink href="#">Sessions</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem>
<BreadcrumbLink>{sessionName}</BreadcrumbLink>
<BreadcrumbLink>{session.name}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Center>
<Box ref={playerElRef}></Box>
{/* Session Info */}
<VStack spacing={3} mb={6} align="stretch">
<Box bg={useColorModeValue('gray.50', 'gray.800')} p={4} rounded="lg">
<HStack justify="space-between" mb={2}>
<Text fontSize="lg" fontWeight="bold">{session.name}</Text>
<HStack>
<Button
size="sm"
variant="ghost"
leftIcon={<FiDownload />}
onClick={handleDownload}
>
Export
</Button>
<Button
size="sm"
variant="ghost"
colorScheme="red"
leftIcon={<FiX />}
onClick={handleDelete}
>
Delete
</Button>
</HStack>
</HStack>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>
Created: {new Date(session.createTimestamp).toLocaleString()}
</Text>
</Box>
{/* Stats */}
<Flex gap={4}>
<Box flex={1} bg={useColorModeValue('blue.50', 'blue.900')} p={3} rounded="lg">
<HStack>
<FiClock color="blue.500" />
<VStack align="start" spacing={0}>
<Text fontSize="xs" color={useColorModeValue('gray.600', 'gray.300')}>Duration</Text>
<Text fontWeight="bold">{formatTime(totalTime)}</Text>
</VStack>
</HStack>
</Box>
<Box flex={1} bg={useColorModeValue('green.50', 'green.900')} p={3} rounded="lg">
<HStack>
<FiDatabase color="green.500" />
<VStack align="start" spacing={0}>
<Text fontSize="xs" color={useColorModeValue('gray.600', 'gray.300')}>Events</Text>
<Text fontWeight="bold">{events.length}</Text>
</VStack>
</HStack>
</Box>
<Box flex={1} bg={useColorModeValue('purple.50', 'purple.900')} p={3} rounded="lg">
<HStack>
<FiCalendar color="purple.500" />
<VStack align="start" spacing={0}>
<Text fontSize="xs" color={useColorModeValue('gray.600', 'gray.300')}>Size</Text>
<Text fontWeight="bold">{formatFileSize(JSON.stringify({ session, events }).length)}</Text>
</VStack>
</HStack>
</Box>
</Flex>
</VStack>
{/* Player Container */}
<Center mb={4}>
<Box
ref={playerElRef}
w="100%"
h="600px"
bg="white"
border="1px solid"
borderColor={useColorModeValue('gray.200', 'gray.700')}
borderRadius="md"
overflow="hidden"
/>
</Center>
{/* Custom Controls */}
<Box bg={useColorModeValue('gray.50', 'gray.800')} p={4} rounded="lg">
<Flex justify="center" align="center" gap={2} mb={3}>
<IconButton
aria-label="Restart"
icon={<FiSkipBack />}
onClick={handleRestart}
isDisabled={!events.length}
/>
<IconButton
aria-label={isPlaying ? "Pause" : "Play"}
icon={isPlaying ? <FiPause /> : <FiPlay />}
onClick={handlePlayPause}
isDisabled={!events.length}
size="lg"
bg={useColorModeValue('blue.500', 'blue.600')}
color="white"
_hover={{ bg: useColorModeValue('blue.600', 'blue.700') }}
/>
<IconButton
aria-label="Skip to end"
icon={<FiSkipForward />}
onClick={() => {
if (playerRef.current) {
playerRef.current.play(totalTime);
}
}}
isDisabled={!events.length}
/>
</Flex>
{/* Timeline */}
{events.length > 0 && (
<VStack spacing={2}>
<Flex justify="space-between" fontSize="xs" color={useColorModeValue('gray.600', 'gray.400')}>
<span>{formatTime(currentTime)}</span>
<span>{formatTime(totalTime)}</span>
</Flex>
<Box
w="100%"
h="2"
bg={useColorModeValue('gray.300', 'gray.600')}
rounded="full"
overflow="hidden"
cursor="pointer"
onClick={(e) => {
if (!playerRef.current) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = x / rect.width;
const newTime = percentage * totalTime;
playerRef.current.play(newTime);
}}
>
<Box
h="100%"
bg={useColorModeValue('blue.500', 'blue.600')}
rounded="full"
w={`${(currentTime / totalTime) * 100}%`}
/>
</Box>
</VStack>
)}
{/* Info */}
<Text fontSize="xs" color={useColorModeValue('gray.500', 'gray.400')} mt={4} textAlign="center">
<InfoIcon /> Click on the timeline to seek to a specific moment
</Text>
</Box>
</>
);
}
// Helper component for info icon
function InfoIcon() {
return <FiInfo style={{ marginRight: '4px', color: '#718096' }} />;
}

View File

@@ -0,0 +1,527 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { nanoid } from 'nanoid';
import {
Box,
Button,
chakra,
Checkbox,
Divider,
Editable,
EditableInput,
EditablePreview,
Flex,
IconButton,
Input,
Select,
Spacer,
Table,
TableContainer,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
useEditableControls,
useToast,
HStack,
VStack,
Tag,
TagLabel,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
useDisclosure,
useColorModeValue,
FormControl,
FormLabel,
} from '@chakra-ui/react';
import {
createColumnHelper,
useReactTable,
flexRender,
getCoreRowModel,
type SortingState,
getSortedRowModel,
type PaginationState,
} from '@tanstack/react-table';
import { VscTriangleDown, VscTriangleUp } from 'react-icons/vsc';
import { FiEdit3 as EditIcon } from 'react-icons/fi';
import { useNavigate } from 'react-router-dom';
import type { eventWithTime } from 'rrweb';
import { type Session, EventName } from '~/types';
import Channel from '~/utils/channel';
import {
deleteSessions,
getAllSessions,
downloadSessions,
addSession,
updateSession,
} from '~/utils/storage';
import {
FiChevronLeft,
FiChevronRight,
FiChevronsLeft,
FiChevronsRight,
FiDownload,
FiFileText,
FiFile,
FiArchive,
FiSearch,
FiFilter,
FiCalendar,
FiTag,
FiX,
FiInfo,
FiPlus,
FiScissors,
FiMinimize2,
FiBarChart2,
FiEdit3,
FiTag as FiTagIcon,
} from 'react-icons/fi';
const columnHelper = createColumnHelper<Session>();
const channel = new Channel();
export function SessionList() {
const navigate = useNavigate();
const toast = useToast();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [sessions, setSessions] = useState<Session[]>([]);
const [filteredSessions, setFilteredSessions] = useState<Session[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [dateFilter, setDateFilter] = useState<'all' | 'today' | 'week' | 'month'>('all');
const [showFilterModal, setShowFilterModal] = useState(false);
const [showDataOpsModal, setShowDataOpsModal] = useState(false);
const [sorting, setSorting] = useState<SortingState>([
{
id: 'createTimestamp',
desc: true,
},
]);
const [rowSelection, setRowSelection] = useState({});
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'zip'>('json');
// Debug state
const [debugInfo, setDebugInfo] = useState('');
// Data operations state
const [mergeName, setMergeName] = useState('');
const [mergeTags, setMergeTags] = useState('');
const [splitMaxEvents, setSplitMaxEvents] = useState(1000);
const [splitDuration, setSplitDuration] = useState(60000);
const [compressSampleRate, setCompressSampleRate] = useState(0.5);
const [bulkRenameInput, setBulkRenameInput] = useState('');
const [bulkTagsInput, setBulkTagsInput] = useState('');
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const fetchDataOptions = {
pageIndex,
pageSize,
};
const fetchData = (options: { pageIndex: number; pageSize: number }) => {
return {
rows: filteredSessions.slice(
options.pageIndex * options.pageSize,
(options.pageIndex + 1) * options.pageSize,
),
pageCount: Math.ceil(filteredSessions.length / options.pageSize),
};
};
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize],
);
const columns = useMemo(
() => [
columnHelper.display({
id: 'select',
header: ({ table }) => (
<Checkbox
isChecked={table.getIsAllRowsSelected()}
isIndeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<Checkbox
isChecked={row.getIsSelected()}
isIndeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
}),
columnHelper.accessor((row) => row.name, {
cell: (info) => {
const [isHovered, setIsHovered] = useState(false);
function EditableControls() {
const { isEditing, getEditButtonProps } = useEditableControls();
return (
isHovered &&
!isEditing && (
<Box
position="absolute"
top="0"
right="0"
onClick={(e) => e.stopPropagation()}
>
<IconButton
aria-label="edit name"
size="sm"
icon={<EditIcon />}
variant="ghost"
{...getEditButtonProps()}
/>
</Box>
)
);
}
return (
<Flex
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
alignItems="center"
position="relative"
>
<Editable
defaultValue={info.getValue()}
isPreviewFocusable={false}
onSubmit={(nextValue) => {
const newSession = { ...info.row.original, name: nextValue };
setSessions(
sessions.map((s) =>
s.id === newSession.id ? newSession : s,
),
);
void updateSession(newSession);
}}
>
<EditablePreview cursor="pointer" />
<EditableControls />
<EditableInput onClick={(e) => e.stopPropagation()} />
</Editable>
</Flex>
);
},
header: 'Name',
}),
columnHelper.accessor((row) => row.createTimestamp, {
id: 'createTimestamp',
cell: (info) => new Date(info.getValue()).toLocaleString(),
header: 'Created Time',
sortDescFirst: true,
}),
columnHelper.accessor((row) => row.recorderVersion, {
cell: (info) => info.getValue(),
header: 'RRWEB Version',
}),
columnHelper.accessor((row) => row.tags, {
cell: (info) => (
<Flex flexWrap="wrap" gap={1}>
{info.getValue().map((tag, index) => (
<Tag key={index} size="sm" variant="subtle">
<TagLabel>{tag}</TagLabel>
</Tag>
))}
</Flex>
),
header: 'Tags',
}),
],
[sessions],
);
const table = useReactTable<Session>({
columns,
data: fetchData(fetchDataOptions).rows,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
state: {
pagination,
sorting,
rowSelection,
},
manualPagination: true,
pageCount: fetchData(fetchDataOptions).pageCount,
});
const updateSessions = async () => {
try {
console.log('Debug: Getting all sessions...');
const sessions = await getAllSessions();
console.log('Debug: Retrieved sessions:', sessions);
setSessions(sessions);
setDebugInfo(`Found ${sessions.length} sessions`);
} catch (error) {
console.error('Debug: Error getting sessions:', error);
setDebugInfo(`Error: ${error}`);
setSessions([]);
}
};
// Apply filtering
useEffect(() => {
let filtered = [...sessions];
// Search filter
if (searchTerm) {
const term = searchTerm.toLowerCase();
filtered = filtered.filter(session =>
session.name.toLowerCase().includes(term) ||
session.tags.some(tag => tag.toLowerCase().includes(term))
);
}
// Date filter
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
const monthAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
switch (dateFilter) {
case 'today':
filtered = filtered.filter(session => new Date(session.createTimestamp) >= today);
break;
case 'week':
filtered = filtered.filter(session => new Date(session.createTimestamp) >= weekAgo);
break;
case 'month':
filtered = filtered.filter(session => new Date(session.createTimestamp) >= monthAgo);
break;
}
console.log('Debug: Filtered sessions:', filtered.length);
setFilteredSessions(filtered);
}, [sessions, searchTerm, dateFilter]);
useEffect(() => {
void updateSessions();
channel.on(EventName.SessionUpdated, () => {
console.log('Debug: Session updated event received');
void updateSessions();
});
}, []);
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
try {
const content = e.target?.result as string;
const data = JSON.parse(content) as {
session: Session;
events: eventWithTime[];
};
const id = nanoid();
data.session.id = id;
await addSession(data.session, data.events);
toast({
title: 'Session imported',
description: 'The session was successfully imported.',
status: 'success',
duration: 3000,
isClosable: true,
});
await updateSessions();
} catch (error) {
console.error('Error uploading file:', error);
toast({
title: 'Error importing session',
description: (error as Error).message,
status: 'error',
duration: 3000,
isClosable: true,
});
}
};
reader.readAsText(file);
};
const getDateFilterLabel = () => {
switch (dateFilter) {
case 'today': return 'Today';
case 'week': return 'Last 7 days';
case 'month': return 'Last 30 days';
default: return 'All time';
}
};
return (
<>
<VStack spacing={4} mb={4} align="stretch">
{/* Search and Filter Bar */}
<Flex justify="space-between" align="center">
<HStack spacing={4}>
<HStack>
<FiSearch color="gray.500" />
<Input
placeholder="Search sessions..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
w={300}
size="sm"
/>
</HStack>
<Button
leftIcon={<FiFilter />}
variant={dateFilter !== 'all' ? 'solid' : 'outline'}
size="sm"
onClick={() => setShowFilterModal(true)}
>
{getDateFilterLabel()}
</Button>
</HStack>
<HStack spacing={4}>
<Button
size="sm"
variant="outline"
onClick={() => {
setSearchTerm('');
setDateFilter('all');
}}
leftIcon={<FiX />}
isDisabled={!searchTerm && dateFilter === 'all'}
>
Clear Filters
</Button>
<Button
onClick={() => {
fileInputRef.current?.click();
}}
size="sm"
colorScheme="blue"
leftIcon={<FiFileText />}
>
Import Session
</Button>
<input
type="file"
accept="application/json"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileUpload}
/>
</HStack>
</Flex>
{/* Debug Info */}
<Box bg="yellow.50" p={3} rounded="md">
<Text fontSize="sm" fontWeight="bold">Debug Info:</Text>
<Text fontSize="sm">{debugInfo}</Text>
<Text fontSize="sm">Total sessions: {sessions.length}</Text>
<Text fontSize="sm">Filtered sessions: {filteredSessions.length}</Text>
</Box>
{/* Results Summary */}
<Flex justify="space-between" align="center" fontSize="sm" color="gray.600">
<Text>
Showing {filteredSessions.length} of {sessions.length} sessions
</Text>
{searchTerm && (
<Text>Search: "{searchTerm}"</Text>
)}
</Flex>
</VStack>
<TableContainer fontSize="md">
<Table variant="simple">
<Thead>
{table.getHeaderGroups().map((headerGroup) => (
<Tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta as
| {
isNumeric: boolean;
}
| undefined;
return (
<Th
key={header.id}
onClick={header.column.getToggleSortingHandler()}
isNumeric={meta?.isNumeric}
verticalAlign="center"
userSelect="none"
>
<Flex align="center">
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
<chakra.span pl={4}>
{{
asc: (
<VscTriangleUp aria-label="sorted ascending" />
),
desc: (
<VscTriangleDown aria-label="sorted descending" />
),
}[header.column.getIsSorted() as string] ?? null}
</chakra.span>
</Flex>
</Th>
);
})}
</Tr>
))}
</Thead>
<Tbody>
{table.getRowModel().rows.map((row) => (
<Tr key={row.id} _hover={{ cursor: 'pointer' }}>
{row.getVisibleCells().map((cell, index) => {
const meta = cell.column.columnDef.meta as
| {
isNumeric: boolean;
}
| undefined;
return (
<Td
key={cell.id}
isNumeric={meta?.isNumeric}
onClick={() => {
if (index !== 0)
navigate(`/session/${row.original.id}`);
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</Td>
);
})}
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
{filteredSessions.length === 0 && (
<Box textAlign="center" py={8}>
<Text fontSize="lg" color="gray.500">No sessions found</Text>
<Text fontSize="sm" color="gray.400" mt={2}>Try recording a new session or check your recordings</Text>
</Box>
)}
</>
);
}

View File

@@ -24,6 +24,21 @@ import {
Tr,
useEditableControls,
useToast,
HStack,
VStack,
Tag,
TagLabel,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
useDisclosure,
useColorModeValue,
FormControl,
FormLabel,
} from '@chakra-ui/react';
import {
createColumnHelper,
@@ -47,11 +62,35 @@ import {
addSession,
updateSession,
} from '~/utils/storage';
import {
mergeSessions,
splitSession,
compressSession,
bulkRenameSessions,
bulkAddTags,
getSessionStats,
} from '~/utils/dataOperations';
import {
FiChevronLeft,
FiChevronRight,
FiChevronsLeft,
FiChevronsRight,
FiDownload,
FiFileText,
FiFile,
FiArchive,
FiSearch,
FiFilter,
FiCalendar,
FiTag,
FiX,
FiInfo,
FiPlus,
FiScissors,
FiMinimize2,
FiBarChart2,
FiEdit3,
FiTag as FiTagIcon,
} from 'react-icons/fi';
const columnHelper = createColumnHelper<Session>();
@@ -62,6 +101,11 @@ export function SessionList() {
const toast = useToast();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [sessions, setSessions] = useState<Session[]>([]);
const [filteredSessions, setFilteredSessions] = useState<Session[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [dateFilter, setDateFilter] = useState<'all' | 'today' | 'week' | 'month'>('all');
const [showFilterModal, setShowFilterModal] = useState(false);
const [showDataOpsModal, setShowDataOpsModal] = useState(false);
const [sorting, setSorting] = useState<SortingState>([
{
id: 'createTimestamp',
@@ -69,6 +113,16 @@ export function SessionList() {
},
]);
const [rowSelection, setRowSelection] = useState({});
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'zip'>('json');
// Data operations state
const [mergeName, setMergeName] = useState('');
const [mergeTags, setMergeTags] = useState('');
const [splitMaxEvents, setSplitMaxEvents] = useState(1000);
const [splitDuration, setSplitDuration] = useState(60000);
const [compressSampleRate, setCompressSampleRate] = useState(0.5);
const [bulkRenameInput, setBulkRenameInput] = useState('');
const [bulkTagsInput, setBulkTagsInput] = useState('');
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
@@ -82,11 +136,11 @@ export function SessionList() {
const fetchData = (options: { pageIndex: number; pageSize: number }) => {
return {
rows: sessions.slice(
rows: filteredSessions.slice(
options.pageIndex * options.pageSize,
(options.pageIndex + 1) * options.pageSize,
),
pageCount: Math.ceil(sessions.length / options.pageSize),
pageCount: Math.ceil(filteredSessions.length / options.pageSize),
};
};
const pagination = useMemo(
@@ -181,6 +235,18 @@ export function SessionList() {
cell: (info) => info.getValue(),
header: 'RRWEB Version',
}),
columnHelper.accessor((row) => row.tags, {
cell: (info) => (
<Flex flexWrap="wrap" gap={1}>
{info.getValue().map((tag, index) => (
<Tag key={index} size="sm" variant="subtle">
<TagLabel>{tag}</TagLabel>
</Tag>
))}
</Flex>
),
header: 'Tags',
}),
],
[sessions],
);
@@ -206,6 +272,40 @@ export function SessionList() {
setSessions(sessions);
};
// Apply filtering
useEffect(() => {
let filtered = [...sessions];
// Search filter
if (searchTerm) {
const term = searchTerm.toLowerCase();
filtered = filtered.filter(session =>
session.name.toLowerCase().includes(term) ||
session.tags.some(tag => tag.toLowerCase().includes(term))
);
}
// Date filter
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
const monthAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
switch (dateFilter) {
case 'today':
filtered = filtered.filter(session => new Date(session.createTimestamp) >= today);
break;
case 'week':
filtered = filtered.filter(session => new Date(session.createTimestamp) >= weekAgo);
break;
case 'month':
filtered = filtered.filter(session => new Date(session.createTimestamp) >= monthAgo);
break;
}
setFilteredSessions(filtered);
}, [sessions, searchTerm, dateFilter]);
useEffect(() => {
void updateSessions();
channel.on(EventName.SessionUpdated, () => {
@@ -250,26 +350,364 @@ export function SessionList() {
reader.readAsText(file);
};
const getDateFilterLabel = () => {
switch (dateFilter) {
case 'today': return 'Today';
case 'week': return 'Last 7 days';
case 'month': return 'Last 30 days';
default: return 'All time';
}
};
// Data operations handlers
const handleMergeSessions = async () => {
const selectedIds = table.getSelectedRowModel().flatRows.map(row => row.original.id);
if (selectedIds.length < 2) {
toast({
title: 'Error',
description: 'Please select at least 2 sessions to merge',
status: 'error',
duration: 3000,
isClosable: true,
});
return;
}
try {
const { session, events } = await mergeSessions(selectedIds, {
name: mergeName || `Merged - ${selectedIds.length} sessions`,
tags: mergeTags.split(',').map(tag => tag.trim()).filter(Boolean),
});
// Add the merged session
await addSession(session, events);
// Clean up original sessions
await deleteSessions(selectedIds);
// Reset state
setRowSelection({});
setShowDataOpsModal(false);
setMergeName('');
setMergeTags('');
void updateSessions();
toast({
title: 'Sessions merged',
description: `Created merged session: ${session.name}`,
status: 'success',
duration: 3000,
isClosable: true,
});
} catch (error) {
toast({
title: 'Error merging sessions',
description: (error as Error).message,
status: 'error',
duration: 3000,
isClosable: true,
});
}
};
const handleSplitSession = async () => {
const selectedIds = table.getSelectedRowModel().flatRows.map(row => row.original.id);
if (selectedIds.length !== 1) {
toast({
title: 'Error',
description: 'Please select exactly 1 session to split',
status: 'error',
duration: 3000,
isClosable: true,
});
return;
}
try {
const { sessions: splitSessions, eventsLists } = await splitSession(selectedIds[0], {
maxEvents: splitMaxEvents,
duration: splitDuration,
});
// Add split sessions
for (let i = 0; i < splitSessions.length; i++) {
await addSession(splitSessions[i], eventsLists[i]);
}
// Clean up original session
await deleteSessions([selectedIds[0]]);
// Reset state
setRowSelection({});
setShowDataOpsModal(false);
void updateSessions();
toast({
title: 'Session split',
description: `Created ${splitSessions.length} split sessions`,
status: 'success',
duration: 3000,
isClosable: true,
});
} catch (error) {
toast({
title: 'Error splitting session',
description: (error as Error).message,
status: 'error',
duration: 3000,
isClosable: true,
});
}
};
const handleCompressSession = async () => {
const selectedIds = table.getSelectedRowModel().flatRows.map(row => row.original.id);
if (selectedIds.length !== 1) {
toast({
title: 'Error',
description: 'Please select exactly 1 session to compress',
status: 'error',
duration: 3000,
isClosable: true,
});
return;
}
try {
const { session, events } = await compressSession(selectedIds[0], {
sampleRate: compressSampleRate,
removeMetadata: true,
});
// Add compressed session
await addSession(session, events);
// Clean up original session
await deleteSessions([selectedIds[0]]);
// Reset state
setRowSelection({});
setShowDataOpsModal(false);
void updateSessions();
toast({
title: 'Session compressed',
description: `Compressed to ${events.length} events (${Math.round(compressSampleRate * 100)}%)`,
status: 'success',
duration: 3000,
isClosable: true,
});
} catch (error) {
toast({
title: 'Error compressing session',
description: (error as Error).message,
status: 'error',
duration: 3000,
isClosable: true,
});
}
};
const handleBulkRename = async () => {
const selectedIds = table.getSelectedRowModel().flatRows.map(row => row.original.id);
if (selectedIds.length === 0 || !bulkRenameInput.trim()) {
toast({
title: 'Error',
description: 'Please select sessions and enter a new name',
status: 'error',
duration: 3000,
isClosable: true,
});
return;
}
try {
await bulkRenameSessions(selectedIds, bulkRenameInput.trim());
// Reset state
setRowSelection({});
setShowDataOpsModal(false);
setBulkRenameInput('');
void updateSessions();
toast({
title: 'Sessions renamed',
description: `Renamed ${selectedIds.length} sessions to: ${bulkRenameInput}`,
status: 'success',
duration: 3000,
isClosable: true,
});
} catch (error) {
toast({
title: 'Error renaming sessions',
description: (error as Error).message,
status: 'error',
duration: 3000,
isClosable: true,
});
}
};
const handleBulkAddTags = async () => {
const selectedIds = table.getSelectedRowModel().flatRows.map(row => row.original.id);
const tags = bulkTagsInput.split(',').map(tag => tag.trim()).filter(Boolean);
if (selectedIds.length === 0 || tags.length === 0) {
toast({
title: 'Error',
description: 'Please select sessions and enter tags to add',
status: 'error',
duration: 3000,
isClosable: true,
});
return;
}
try {
await bulkAddTags(selectedIds, tags);
// Reset state
setRowSelection({});
setShowDataOpsModal(false);
setBulkTagsInput('');
void updateSessions();
toast({
title: 'Tags added',
description: `Added tags to ${selectedIds.length} sessions`,
status: 'success',
duration: 3000,
isClosable: true,
});
} catch (error) {
toast({
title: 'Error adding tags',
description: (error as Error).message,
status: 'error',
duration: 3000,
isClosable: true,
});
}
};
const handleShowStats = async () => {
const selectedIds = table.getSelectedRowModel().flatRows.map(row => row.original.id);
if (selectedIds.length !== 1) {
toast({
title: 'Error',
description: 'Please select exactly 1 session to view statistics',
status: 'error',
duration: 3000,
isClosable: true,
});
return;
}
try {
const session = table.getSelectedRowModel().flatRows[0].original;
const stats = await getSessionStats(selectedIds[0]);
// Show stats in a modal or alert
alert(`Session Statistics for "${session.name}":
Total Events: ${stats.totalEvents}
Duration: ${Math.round(stats.duration / 1000)}s
File Size: ${(stats.fileSize / 1024 / 1024).toFixed(2)} MB
Avg Events/Second: ${stats.avgEventsPerSecond.toFixed(2)}
Event Types:
${Object.entries(stats.eventTypes).map(([type, count]) => ` ${type}: ${count}`).join('\n')}`);
} catch (error) {
toast({
title: 'Error getting statistics',
description: (error as Error).message,
status: 'error',
duration: 3000,
isClosable: true,
});
}
};
return (
<>
<Flex justify="flex-end" mb={4}>
<Button
onClick={() => {
fileInputRef.current?.click();
}}
size="sm"
m={4}
>
Import Session
</Button>
<input
type="file"
accept="application/json"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileUpload}
/>
</Flex>
<VStack spacing={4} mb={4} align="stretch">
{/* Search and Filter Bar */}
<Flex justify="space-between" align="center">
<HStack spacing={4}>
<HStack>
<FiSearch color="gray.500" />
<Input
placeholder="Search sessions..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
w={300}
size="sm"
/>
</HStack>
<Button
leftIcon={<FiFilter />}
variant={dateFilter !== 'all' ? 'solid' : 'outline'}
size="sm"
onClick={() => setShowFilterModal(true)}
>
{getDateFilterLabel()}
</Button>
</HStack>
<HStack spacing={4}>
<Button
size="sm"
variant="outline"
onClick={() => {
setSearchTerm('');
setDateFilter('all');
}}
leftIcon={<FiX />}
isDisabled={!searchTerm && dateFilter === 'all'}
>
Clear Filters
</Button>
<Button
onClick={() => {
fileInputRef.current?.click();
}}
size="sm"
colorScheme="blue"
leftIcon={<FiFileText />}
>
Import Session
</Button>
<Button
onClick={() => setShowDataOpsModal(true)}
size="sm"
variant="outline"
leftIcon={<FiEdit3 />}
isDisabled={Object.keys(rowSelection).length === 0}
>
Data Operations
</Button>
<input
type="file"
accept="application/json"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileUpload}
/>
</HStack>
</Flex>
{/* Results Summary */}
<Flex justify="space-between" align="center" fontSize="sm" color="gray.600">
<Text>
Showing {filteredSessions.length} of {sessions.length} sessions
</Text>
{searchTerm && (
<Text>Search: "{searchTerm}"</Text>
)}
</Flex>
</VStack>
<TableContainer fontSize="md">
<Table variant="simple">
<Thead>
@@ -434,24 +872,286 @@ export function SessionList() {
>
Delete
</Button>
{/* Export Format Selection */}
<Select
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value as any)}
size="md"
width="120px"
>
<option value="json">
<HStack>
<FiFile />
<span>JSON</span>
</HStack>
</option>
<option value="html">
<HStack>
<FiFileText />
<span>HTML</span>
</HStack>
</option>
<option value="zip">
<HStack>
<FiArchive />
<span>ZIP</span>
</HStack>
</option>
</Select>
<Button
mr={4}
size="md"
colorScheme="green"
leftIcon={<FiDownload />}
onClick={() => {
const selectedRows = table.getSelectedRowModel().flatRows;
if (selectedRows.length === 0) return;
void downloadSessions(
selectedRows.map((row) => row.original.id),
exportFormat
);
toast({
title: '下载开始',
description: `正在导出 ${selectedRows.length} 个会话为 ${exportFormat.toUpperCase()} 格式`,
status: 'success',
duration: 3000,
isClosable: true,
});
}}
>
Download
({exportFormat.toUpperCase()})
</Button>
</Flex>
)}
</Flex>
</Flex>
{/* Filter Modal */}
<Modal isOpen={showFilterModal} onClose={() => setShowFilterModal(false)} isCentered>
<ModalOverlay />
<ModalContent>
<ModalHeader>Filter Sessions</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4} align="stretch">
<HStack>
<FiCalendar />
<Text fontWeight="semibold">Date Range</Text>
</HStack>
<VStack spacing={2}>
{[
{ value: 'all', label: 'All sessions' },
{ value: 'today', label: 'Today only' },
{ value: 'week', label: 'Last 7 days' },
{ value: 'month', label: 'Last 30 days' },
].map((option) => (
<Button
key={option.value}
variant={dateFilter === option.value ? 'solid' : 'ghost'}
justifyContent="flex-start"
leftIcon={<FiCalendar />}
onClick={() => {
setDateFilter(option.value as any);
setShowFilterModal(false);
}}
w="100%"
>
{option.label}
</Button>
))}
</VStack>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={() => setShowFilterModal(false)}>
Cancel
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Data Operations Modal */}
<Modal isOpen={showDataOpsModal} onClose={() => setShowDataOpsModal(false)} isCentered>
<ModalOverlay />
<ModalContent maxW="3xl">
<ModalHeader>Data Operations</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={6} align="stretch">
{/* Merge Sessions */}
<Box bg={useColorModeValue('blue.50', 'blue.900')} p={4} rounded="lg">
<HStack mb={3}>
<FiPlus />
<Text fontWeight="semibold">Merge Sessions</Text>
</HStack>
<Text fontSize="sm" mb={3}>Combine multiple sessions into one. Select at least 2 sessions.</Text>
<VStack spacing={3}>
<FormControl>
<FormLabel>Merged Session Name</FormLabel>
<Input
value={mergeName}
onChange={(e) => setMergeName(e.target.value)}
placeholder="Enter name for merged session"
/>
</FormControl>
<FormControl>
<FormLabel>Tags (comma-separated)</FormLabel>
<Input
value={mergeTags}
onChange={(e) => setMergeTags(e.target.value)}
placeholder="tag1, tag2, tag3"
/>
</FormControl>
<Button
leftIcon={<FiPlus />}
colorScheme="blue"
onClick={handleMergeSessions}
size="sm"
>
Merge Selected Sessions
</Button>
</VStack>
</Box>
{/* Split Session */}
<Box bg={useColorModeValue('green.50', 'green.900')} p={4} rounded="lg">
<HStack mb={3}>
<FiScissors />
<Text fontWeight="semibold">Split Session</Text>
</HStack>
<Text fontSize="sm" mb={3}>Split a large session into smaller chunks. Select exactly 1 session.</Text>
<VStack spacing={3}>
<FormControl>
<FormLabel>Max Events Per Chunk</FormLabel>
<Input
type="number"
value={splitMaxEvents}
onChange={(e) => setSplitMaxEvents(Number(e.target.value))}
/>
</FormControl>
<FormControl>
<FormLabel>Max Duration (milliseconds)</FormLabel>
<Input
type="number"
value={splitDuration}
onChange={(e) => setSplitDuration(Number(e.target.value))}
/>
</FormControl>
<Button
leftIcon={<FiScissors />}
colorScheme="green"
onClick={handleSplitSession}
size="sm"
>
Split Selected Session
</Button>
</VStack>
</Box>
{/* Compress Session */}
<Box bg={useColorModeValue('yellow.50', 'yellow.900')} p={4} rounded="lg">
<HStack mb={3}>
<FiMinimize2 />
<Text fontWeight="semibold">Compress Session</Text>
</HStack>
<Text fontSize="sm" mb={3}>Reduce event count by sampling. Select exactly 1 session.</Text>
<VStack spacing={3}>
<FormControl>
<FormLabel>Sample Rate (0.1 - 1.0)</FormLabel>
<Input
type="number"
step="0.1"
min="0.1"
max="1.0"
value={compressSampleRate}
onChange={(e) => setCompressSampleRate(Number(e.target.value))}
/>
</FormControl>
<Button
leftIcon={<FiMinimize2 />}
colorScheme="yellow"
onClick={handleCompressSession}
size="sm"
>
Compress Selected Session
</Button>
</VStack>
</Box>
{/* Bulk Operations */}
<Box bg={useColorModeValue('purple.50', 'purple.900')} p={4} rounded="lg">
<HStack mb={3}>
<FiEdit3 />
<Text fontWeight="semibold">Bulk Operations</Text>
</HStack>
<VStack spacing={3}>
<FormControl>
<FormLabel>Bulk Rename</FormLabel>
<HStack>
<Input
flex={1}
value={bulkRenameInput}
onChange={(e) => setBulkRenameInput(e.target.value)}
placeholder="Enter new name for all selected sessions"
/>
<Button
leftIcon={<FiEdit3 />}
onClick={handleBulkRename}
size="sm"
>
Rename
</Button>
</HStack>
</FormControl>
<FormControl>
<FormLabel>Bulk Add Tags</FormLabel>
<HStack>
<Input
flex={1}
value={bulkTagsInput}
onChange={(e) => setBulkTagsInput(e.target.value)}
placeholder="tag1, tag2, tag3"
/>
<Button
leftIcon={<FiTagIcon />}
onClick={handleBulkAddTags}
size="sm"
>
Add Tags
</Button>
</HStack>
</FormControl>
</VStack>
</Box>
{/* Statistics */}
<Box bg={useColorModeValue('gray.50', 'gray.900')} p={4} rounded="lg">
<HStack mb={3}>
<FiBarChart2 />
<Text fontWeight="semibold">View Statistics</Text>
</HStack>
<Text fontSize="sm" mb={3}>View detailed statistics for a session. Select exactly 1 session.</Text>
<Button
leftIcon={<FiBarChart2 />}
variant="outline"
onClick={handleShowStats}
size="sm"
>
View Statistics
</Button>
</Box>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={() => setShowDataOpsModal(false)}>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,14 +8,41 @@ import {
Spacer,
Stack,
Text,
VStack,
HStack,
Code,
useColorMode,
useColorModeValue,
Badge,
Divider,
ScaleFade,
Button,
Tooltip,
useToast,
} from '@chakra-ui/react';
import { FiSettings, FiList, FiPause, FiPlay } from 'react-icons/fi';
import {
FiSettings,
FiList,
FiPause,
FiPlay,
FiTerminal,
FiVideo,
FiSave,
FiTrash2,
FiRefreshCw,
FiAlertCircle,
FiCheckCircle,
FiClock,
FiBarChart2,
FiDownload
} from 'react-icons/fi';
import Channel from '~/utils/channel';
import { LocalDataKey, RecorderStatus, EventName } from '~/types';
import type { LocalData, Session } from '~/types';
import { CircleButton } from '~/components/CircleButton';
import { Timer } from './Timer';
const RECORD_BUTTON_SIZE = 3;
const channel = new Channel();
@@ -25,6 +52,14 @@ export function App() {
const [errorMessage, setErrorMessage] = useState('');
const [startTime, setStartTime] = useState(0);
const [newSession, setNewSession] = useState<Session | null>(null);
const [stats, setStats] = useState({ events: 0, size: 0, lastEventTime: 0, currentTab: '' });
const [isTabActive, setIsTabActive] = useState(true);
const [performanceMetrics, setPerformanceMetrics] = useState({
fps: 0,
memoryUsage: 0,
recordSpeed: 0,
});
const toast = useToast();
useEffect(() => {
const parseStatusData = (data: LocalData[LocalDataKey.recorderStatus]) => {
@@ -48,132 +83,419 @@ export function App() {
channel.on(EventName.SessionUpdated, (data) => {
setNewSession((data as { session: Session }).session);
});
// Monitor active tab
Browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => {
if (tabs[0]) {
setStats(prev => ({ ...prev, currentTab: tabs[0].title }));
setIsTabActive(true);
}
});
// Listen to tab changes
Browser.tabs.onActivated.addListener((activeInfo) => {
Browser.tabs.get(activeInfo.tabId).then((tab) => {
setStats(prev => ({ ...prev, currentTab: tab.title }));
setIsTabActive(tab.id === ( Browser.tabs.query({ active: true, currentWindow: true }).then(tabs => tabs[0]?.id)));
});
});
// Listen to tab updates
Browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
if (changeInfo.status === 'complete') {
Browser.tabs.get(tabId).then((tab) => {
setStats(prev => ({ ...prev, currentTab: tab.title }));
});
}
});
// Update performance metrics periodically
const perfInterval = setInterval(() => {
if (status === RecorderStatus.RECORDING && isTabActive) {
// Simulate performance metrics (in real implementation, these would come from content script)
setPerformanceMetrics({
fps: Math.floor(Math.random() * 10) + 45, // 45-55 FPS
memoryUsage: Math.floor(Math.random() * 50) + 100, // 100-150 MB
recordSpeed: Math.floor(Math.random() * 100) + 50, // 50-150 events/sec
});
}
}, 2000);
return () => clearInterval(perfInterval);
}, []);
const getStatusColor = () => {
switch (status) {
case RecorderStatus.RECORDING:
return 'red';
case RecorderStatus.PAUSED:
return 'yellow';
case RecorderStatus.PausedSwitch:
return 'orange';
default:
return 'gray';
}
};
const getStatusText = () => {
switch (status) {
case RecorderStatus.RECORDING:
return '正在录制';
case RecorderStatus.PAUSED:
return '已暂停';
case RecorderStatus.PausedSwitch:
return '切换标签页暂停';
default:
return '准备就绪';
}
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<Flex direction="column" w={300} padding="5%">
<Flex>
<Text fontSize="md" fontWeight="bold">
RRWeb Recorder
</Text>
<Spacer />
<Stack direction="row">
<IconButton
onClick={() => {
void Browser.tabs.create({ url: '/pages/index.html#/' });
}}
size="xs"
icon={<FiList />}
aria-label={'Session List'}
title="Session List"
></IconButton>
<IconButton
onClick={() => {
void Browser.runtime.openOptionsPage();
}}
size="xs"
icon={<FiSettings />}
aria-label={'Settings button'}
title="Settings"
></IconButton>
</Stack>
</Flex>
{status !== RecorderStatus.IDLE && startTime && (
<Timer
startTime={startTime}
ticking={status === RecorderStatus.RECORDING}
/>
)}
<Flex justify="center" gap="10" mt="5" mb="5">
{
<CircleButton
diameter={RECORD_BUTTON_SIZE}
title={
status === RecorderStatus.IDLE
? 'Start Recording'
: 'Stop Recording'
}
onClick={() => {
if (status === RecorderStatus.IDLE)
void channel.emit(EventName.StartButtonClicked, {});
else void channel.emit(EventName.StopButtonClicked, {});
}}
<Flex direction="column" w={320} h={580} bg={useColorModeValue('white', 'gray.900')} borderRadius="lg" shadow="lg" overflow="hidden">
{/* Header */}
<Box bg={useColorModeValue('blue.500', 'blue.700')} p={4} color="white">
<Flex align="center" justify="space-between">
<HStack>
<FiVideo size={20} />
<Text fontSize="lg" fontWeight="bold">RRWeb </Text>
</HStack>
<Badge
bg={getStatusColor() === 'red' ? 'red.500' : getStatusColor() === 'yellow' ? 'yellow.500' : getStatusColor() === 'orange' ? 'orange.500' : 'green.500'}
color="white"
px={2}
py={1}
rounded="full"
fontSize="xs"
>
<Box
w={`${RECORD_BUTTON_SIZE}rem`}
h={`${RECORD_BUTTON_SIZE}rem`}
borderRadius={status === RecorderStatus.IDLE ? 9999 : 6}
margin="0"
bgColor="red.500"
/>
</CircleButton>
}
{status !== RecorderStatus.IDLE && (
<CircleButton
diameter={RECORD_BUTTON_SIZE}
title={
status === RecorderStatus.RECORDING
? 'Pause Recording'
: 'Resume Recording'
}
onClick={() => {
if (status === RecorderStatus.RECORDING) {
void channel.emit(EventName.PauseButtonClicked, {});
} else {
void channel.emit(EventName.ResumeButtonClicked, {});
}
}}
>
<Box
w={`${RECORD_BUTTON_SIZE}rem`}
h={`${RECORD_BUTTON_SIZE}rem`}
borderRadius={9999}
margin="0"
color="gray.600"
>
{[RecorderStatus.PAUSED, RecorderStatus.PausedSwitch].includes(
status,
) && (
<FiPlay
style={{
paddingLeft: '0.5rem',
width: '100%',
height: '100%',
}}
/>
)}
{status === RecorderStatus.RECORDING && (
<FiPause
style={{
width: '100%',
height: '100%',
}}
/>
)}
{getStatusText()}
</Badge>
</Flex>
</Box>
{/* Main Content */}
<Flex direction="column" p={4} flex={1} overflow="auto">
{/* Enhanced Status Cards */}
<ScaleFade in={status !== RecorderStatus.IDLE} initialScale={0.9}>
<VStack spacing={3} mb={4}>
{/* Current Tab */}
<Box w="100%" bg={useColorModeValue('gray.50', 'gray.800')} p={3} rounded="lg">
<Flex justify="space-between" align="center">
<HStack>
<FiList color="indigo.500" />
<Text fontSize="sm" color={useColorModeValue('gray.700', 'gray.300')}>
</Text>
</HStack>
<Text
fontSize="sm"
fontWeight="bold"
color={useColorModeValue('gray.900', 'white')}
maxW={120}
isTruncated
>
{stats.currentTab || '未知标签页'}
</Text>
</Flex>
</Box>
</CircleButton>
)}
</Flex>
{newSession && (
<Text>
<Text as="b">New Session: </Text>
<Link
href={Browser.runtime.getURL(
`pages/index.html#/session/${newSession.id}`,
{/* Recording Duration */}
<Box w="100%" bg={useColorModeValue('gray.50', 'gray.800')} p={3} rounded="lg">
<Flex justify="space-between" align="center">
<HStack>
<FiClock color="blue.500" />
<Text fontSize="sm" color={useColorModeValue('gray.700', 'gray.300')}>
</Text>
</HStack>
<Text fontWeight="bold" color={useColorModeValue('gray.900', 'white')}>
{startTime ? <Timer startTime={startTime} ticking={status === RecorderStatus.RECORDING} /> : '00:00'}
</Text>
</Flex>
</Box>
{/* Event Count */}
<Box w="100%" bg={useColorModeValue('gray.50', 'gray.800')} p={3} rounded="lg">
<Flex justify="space-between" align="center">
<HStack>
<FiBarChart2 color="green.500" />
<Text fontSize="sm" color={useColorModeValue('gray.700', 'gray.300')}>
</Text>
</HStack>
<Text fontWeight="bold" color={useColorModeValue('gray.900', 'white')}>
{stats.events.toLocaleString()}
</Text>
</Flex>
</Box>
{/* File Size */}
<Box w="100%" bg={useColorModeValue('gray.50', 'gray.800')} p={3} rounded="lg">
<Flex justify="space-between" align="center">
<HStack>
<FiSave color="purple.500" />
<Text fontSize="sm" color={useColorModeValue('gray.700', 'gray.300')}>
</Text>
</HStack>
<Text fontWeight="bold" color={useColorModeValue('gray.900', 'white')}>
{formatFileSize(stats.size)}
</Text>
</Flex>
</Box>
{/* Performance Metrics */}
{status === RecorderStatus.RECORDING && isTabActive && (
<VStack spacing={2} align="stretch">
<Text fontSize="xs" fontWeight="semibold" color={useColorModeValue('gray.700', 'gray.300')} mb={1}>
</Text>
<HStack gap={2}>
<Box flex={1} bg={useColorModeValue('blue.50', 'blue.900')} p={2} rounded="md">
<Text fontSize="2xs" color={useColorModeValue('gray.600', 'gray.300')}>FPS</Text>
<Text fontSize="sm" fontWeight="bold" color={useColorModeValue('blue.700', 'blue.200')}>
{performanceMetrics.fps}
</Text>
</Box>
<Box flex={1} bg={useColorModeValue('green.50', 'green.900')} p={2} rounded="md">
<Text fontSize="2xs" color={useColorModeValue('gray.600', 'gray.300')}></Text>
<Text fontSize="sm" fontWeight="bold" color={useColorModeValue('green.700', 'green.200')}>
{performanceMetrics.memoryUsage} MB
</Text>
</Box>
<Box flex={1} bg={useColorModeValue('orange.50', 'orange.900')} p={2} rounded="md">
<Text fontSize="2xs" color={useColorModeValue('gray.600', 'gray.300')}></Text>
<Text fontSize="sm" fontWeight="bold" color={useColorModeValue('orange.700', 'orange.200')}>
{performanceMetrics.recordSpeed}/s
</Text>
</Box>
</HStack>
</VStack>
)}
isExternal
>
{newSession.name}
</Link>
</Text>
)}
{errorMessage !== '' && (
<Text color="red.500" fontSize="md">
{errorMessage}
<br />
Maybe refresh your current tab.
</Text>
)}
{/* Tab Status */}
{status === RecorderStatus.RECORDING && (
<Box
w="100%"
bg={isTabActive ? useColorModeValue('green.50', 'green.900') : useColorModeValue('red.50', 'red.900')}
p={2}
rounded="lg"
>
<Flex align="center" justify="center">
<Box
w="2"
h="2"
rounded="full"
mr={2}
bg={isTabActive ? 'green.500' : 'red.500'}
/>
<Text fontSize="2xs" color={isTabActive ? useColorModeValue('green.700', 'green.200') : useColorModeValue('red.700', 'red.200')}>
{isTabActive ? '标签页活跃中' : '标签页已切换,录制已暂停'}
</Text>
</Flex>
</Box>
)}
</VStack>
</ScaleFade>
{/* Control Buttons */}
<Flex justify="center" gap={4} my={6}>
<Tooltip label={status === RecorderStatus.IDLE ? "开始录制" : "停止录制"}>
<CircleButton
diameter={RECORD_BUTTON_SIZE}
title={status === RecorderStatus.IDLE ? '开始录制' : '停止录制'}
onClick={() => {
if (status === RecorderStatus.IDLE) {
void channel.emit(EventName.StartButtonClicked, {});
toast({
title: '开始录制',
description: '录制已开始',
status: 'success',
duration: 2000,
});
} else {
void channel.emit(EventName.StopButtonClicked, {});
toast({
title: '停止录制',
description: '录制已停止',
status: 'info',
duration: 2000,
});
}
}}
>
<Box
w={`${RECORD_BUTTON_SIZE}rem`}
h={`${RECORD_BUTTON_SIZE}rem`}
borderRadius={status === RecorderStatus.IDLE ? 9999 : 6}
margin="0"
bg={status === RecorderStatus.IDLE ? "red.500" : "gray.600"}
transition="all 0.2s"
_hover={{ transform: 'scale(1.05)' }}
/>
</CircleButton>
</Tooltip>
{status !== RecorderStatus.IDLE && (
<Tooltip label={status === RecorderStatus.RECORDING ? "暂停录制" : "继续录制"}>
<CircleButton
diameter={RECORD_BUTTON_SIZE}
title={
status === RecorderStatus.RECORDING
? '暂停录制'
: '继续录制'
}
onClick={() => {
if (status === RecorderStatus.RECORDING) {
void channel.emit(EventName.PauseButtonClicked, {});
} else {
void channel.emit(EventName.ResumeButtonClicked, {});
}
}}
>
<Box
w={`${RECORD_BUTTON_SIZE}rem`}
h={`${RECORD_BUTTON_SIZE}rem`}
borderRadius={9999}
margin="0"
bg="yellow.500"
transition="all 0.2s"
_hover={{ transform: 'scale(1.05)' }}
>
{status !== RecorderStatus.RECORDING && (
<FiPlay
style={{
paddingLeft: '0.5rem',
width: '100%',
height: '100%',
color: 'white',
}}
/>
)}
{status === RecorderStatus.RECORDING && (
<FiPause
style={{
width: '100%',
height: '100%',
color: 'white',
}}
/>
)}
</Box>
</CircleButton>
</Tooltip>
)}
</Flex>
{/* Navigation Buttons */}
<Divider my={4} />
<VStack spacing={2} mb={4}>
<Tooltip label="录制历史">
<Button
leftIcon={<FiList />}
variant="ghost"
w="100%"
justifyContent="flex-start"
_hover={{ bg: useColorModeValue('blue.50', 'blue.900') }}
onClick={() => {
void Browser.tabs.create({ url: '/pages/index.html#/' });
}}
>
</Button>
</Tooltip>
<Tooltip label="设置">
<Button
leftIcon={<FiSettings />}
variant="ghost"
w="100%"
justifyContent="flex-start"
_hover={{ bg: useColorModeValue('blue.50', 'blue.900') }}
onClick={() => {
void Browser.runtime.openOptionsPage();
}}
>
</Button>
</Tooltip>
</VStack>
{/* Session Info */}
{newSession && (
<ScaleFade in={true} initialScale={0.9}>
<Box bg={useColorModeValue('green.50', 'green.900')} p={3} rounded="lg" mb={4}>
<HStack>
<FiCheckCircle color="green.500" />
<Text fontSize="sm" fontWeight="bold" color={useColorModeValue('green.800', 'green.200')}>
</Text>
</HStack>
<Text fontSize="xs" mt={1} color={useColorModeValue('green.700', 'green.300')}>
<Link
href={Browser.runtime.getURL(`pages/index.html#/session/${newSession.id}`)}
isExternal
color="blue.500"
textDecoration="underline"
>
{newSession.name}
</Link>
</Text>
</Box>
</ScaleFade>
)}
{/* Error Message */}
{errorMessage && (
<ScaleFade in={true} initialScale={0.9}>
<Box bg={useColorModeValue('red.50', 'red.900')} p={3} rounded="lg">
<HStack>
<FiAlertCircle color="red.500" />
<Text fontSize="sm" color={useColorModeValue('red.800', 'red.200')}>
{errorMessage}
</Text>
</HStack>
<Text fontSize="xs" mt={1} color={useColorModeValue('red.700', 'red.300')}>
</Text>
</Box>
</ScaleFade>
)}
{/* Keyboard Shortcuts */}
<Box mt="auto" pt={4}>
<Box bg={useColorModeValue('gray.50', 'gray.800')} p={3} borderRadius="md">
<HStack spacing={2} mb={2}>
<FiTerminal color={useColorModeValue('gray.600', 'gray.400')} />
<Text fontSize="xs" fontWeight="semibold" color={useColorModeValue('gray.800', 'gray.200')}>
</Text>
</HStack>
<VStack spacing={1} align="stretch">
<HStack justifyContent="space-between" fontSize="2xs">
<Text></Text>
<Code fontSize="2xs" bg={useColorModeValue('gray.200', 'gray.700')} px={1} py={0.5} rounded>
Ctrl+Shift+R
</Code>
</HStack>
<HStack justifyContent="space-between" fontSize="2xs">
<Text></Text>
<Code fontSize="2xs" bg={useColorModeValue('gray.200', 'gray.700')} px={1} py={0.5} rounded>
Ctrl+Shift+S
</Code>
</HStack>
</VStack>
</Box>
</Box>
</Flex>
</Flex>
);
}
}

View File

@@ -9,7 +9,30 @@ export type SyncData = {
};
export type Settings = {
//
recordingQuality: 'balanced' | 'high' | 'low';
autoStart: boolean;
enableNotifications: boolean;
theme: 'system' | 'light' | 'dark';
recordCanvas: boolean;
recordInputs: boolean;
recordMouse: boolean;
recordScroll: boolean;
blockSensitiveData: boolean;
maskInputs: boolean;
excludedDomains: string[];
autoCleanupDays: number;
maxRecordingSize: '50' | '100' | '200' | '500';
defaultExportFormat: 'json' | 'html' | 'zip';
includeMetadata: boolean;
shortcuts: {
start: string;
stop: string;
};
// File path management
savePath: string;
createSubfolders: boolean;
fileNameFormat: 'timestamp' | 'custom';
customNameFormat: string;
};
export enum LocalDataKey {

View File

@@ -0,0 +1,421 @@
import type { eventWithTime } from '@rrweb/types';
import type { Session } from '~/types';
import { openDB } from 'idb';
const SessionsStoreName = 'sessions';
const EventsStoreName = 'events';
const SessionsDBName = 'rrweb-sessions';
export interface MergeOptions {
name?: string;
tags?: string[];
description?: string;
}
export interface SplitOptions {
startTime?: number;
endTime?: number;
maxEvents?: number;
duration?: number;
}
export interface CompressionOptions {
level?: number;
removeMetadata?: boolean;
sampleRate?: number;
}
export interface SessionStats {
totalEvents: number;
duration: number;
fileSize: number;
eventTypes: { [key: string]: number };
avgEventsPerSecond: number;
startTime: number;
endTime: number;
}
/**
* 合并多个会话数据
*/
export async function mergeSessions(
sessionIds: string[],
options: MergeOptions = {}
): Promise<{ session: Session; events: eventWithTime[] }> {
const db = await openDB(SessionsDBName, 1);
// 获取所有选中的会话
const sessions: Session[] = [];
const allEvents: eventWithTime[] = [];
for (const sessionId of sessionIds) {
const session = await db.get(SessionsStoreName, sessionId);
if (session) {
sessions.push(session);
// 获取该会话的所有事件
const events = await db.getAll(EventsStoreName, sessionId);
allEvents.push(...events);
}
}
if (sessions.length === 0) {
throw new Error('No valid sessions to merge');
}
// 创建新的合并会话
const firstSession = sessions[0];
const now = Date.now();
const mergedSession: Session = {
id: `merged-${now}`,
name: options.name || `Merged Session - ${sessions.length} recordings`,
tags: options.tags || ['merged', ...sessions.flatMap(s => s.tags)],
createTimestamp: now,
modifyTimestamp: now,
recorderVersion: firstSession.recorderVersion,
};
// 按时间排序所有事件
allEvents.sort((a, b) => a.timestamp - b.timestamp);
// 创建新的合并事件数组
const mergedEvents: eventWithTime[] = allEvents.map((event, index) => ({
...event,
timestamp: event.timestamp + index, // 避免时间戳冲突
type: event.type,
data: event.data,
}));
await db.close();
return { session: mergedSession, events: mergedEvents };
}
/**
* 分割会话数据
*/
export async function splitSession(
sessionId: string,
options: SplitOptions = {}
): Promise<{ sessions: Session[]; eventsLists: eventWithTime[][] }> {
const db = await openDB(SessionsDBName, 1);
const session = await db.get(SessionsStoreName, sessionId);
if (!session) {
await db.close();
throw new Error('Session not found');
}
const events = await db.getAll(EventsStoreName, sessionId);
await db.close();
// 按时间排序事件
events.sort((a, b) => a.timestamp - b.timestamp);
const splitEventsLists: eventWithTime[][] = [];
let currentChunk: eventWithTime[] = [];
let currentStartTime = events[0]?.timestamp || 0;
for (let i = 0; i < events.length; i++) {
const event = events[i];
// 检查是否需要分割
let shouldSplit = false;
if (options.maxEvents && currentChunk.length >= options.maxEvents) {
shouldSplit = true;
} else if (options.duration && event.timestamp - currentStartTime > options.duration) {
shouldSplit = true;
} else if (options.startTime && event.timestamp >= options.startTime) {
shouldSplit = true;
} else if (options.endTime && event.timestamp >= options.endTime) {
shouldSplit = true;
}
if (shouldSplit && currentChunk.length > 0) {
splitEventsLists.push(currentChunk);
currentChunk = [event];
currentStartTime = event.timestamp;
} else {
currentChunk.push(event);
}
}
// 添加最后一个 chunk
if (currentChunk.length > 0) {
splitEventsLists.push(currentChunk);
}
// 为每个分割创建新的会话
const now = Date.now();
const splitSessions: Session[] = splitEventsLists.map((eventsList, index) => ({
id: `split-${sessionId}-${index + 1}`,
name: `${session.name} - Part ${index + 1}`,
tags: ['split', ...(session.tags || [])],
createTimestamp: now,
modifyTimestamp: now,
recorderVersion: session.recorderVersion,
}));
return { sessions: splitSessions, eventsLists: splitEventsLists };
}
/**
* 压缩会话数据(通过采样减少事件数量)
*/
export async function compressSession(
sessionId: string,
options: CompressionOptions = {}
): Promise<{ session: Session; events: eventWithTime[] }> {
const db = await openDB(SessionsDBName, 1);
const session = await db.get(SessionsStoreName, sessionId);
if (!session) {
await db.close();
throw new Error('Session not found');
}
const events = await db.getAll(EventsStoreName, sessionId);
await db.close();
// 按时间排序事件
events.sort((a, b) => a.timestamp - b.timestamp);
let compressedEvents: eventWithTime[] = [...events];
// 如果设置了采样率,进行采样
if (options.sampleRate && options.sampleRate < 1) {
const sampleSize = Math.floor(events.length * options.sampleRate);
const step = Math.floor(events.length / sampleSize);
compressedEvents = [];
for (let i = 0; i < events.length; i += step) {
compressedEvents.push(events[i]);
}
}
// 移除元数据(可选)
if (options.removeMetadata) {
compressedEvents = compressedEvents.map(event => {
const { type, data, timestamp } = event;
return { type, data, timestamp };
});
}
// 创建压缩后的会话
const now = Date.now();
const compressedSession: Session = {
...session,
id: `compressed-${sessionId}`,
name: `${session.name} (Compressed)`,
tags: ['compressed', ...(session.tags || [])],
createTimestamp: now,
modifyTimestamp: now,
};
return { session: compressedSession, events: compressedEvents };
}
/**
* 获取会话统计信息
*/
export async function getSessionStats(sessionId: string): Promise<SessionStats> {
const db = await openDB(SessionsDBName, 1);
const session = await db.get(SessionsStoreName, sessionId);
if (!session) {
await db.close();
throw new Error('Session not found');
}
const events = await db.getAll(EventsStoreName, sessionId);
await db.close();
// 按时间排序事件
events.sort((a, b) => a.timestamp - b.timestamp);
const startTime = events[0]?.timestamp || 0;
const endTime = events[events.length - 1]?.timestamp || 0;
const duration = endTime - startTime;
// 统计事件类型
const eventTypes: { [key: string]: number } = {};
events.forEach(event => {
eventTypes[event.type] = (eventTypes[event.type] || 0) + 1;
});
// 计算平均每秒事件数
const avgEventsPerSecond = duration > 0 ? (events.length / duration) * 1000 : 0;
// 计算文件大小(模拟)
const fileSize = JSON.stringify(events).length;
return {
totalEvents: events.length,
duration,
fileSize,
eventTypes,
avgEventsPerSecond,
startTime,
endTime,
};
}
/**
* 批量重命名会话
*/
export async function bulkRenameSessions(
sessionIds: string[],
newName: string
): Promise<void> {
const db = await openDB(SessionsDBName, 1);
const updatePromises = sessionIds.map(async (sessionId) => {
const session = await db.get(SessionsStoreName, sessionId);
if (session) {
const updatedSession = {
...session,
name: newName,
modifyTimestamp: Date.now(),
};
await db.put(SessionsStoreName, updatedSession, sessionId);
}
});
await Promise.all(updatePromises);
await db.close();
}
/**
* 批量添加标签
*/
export async function bulkAddTags(
sessionIds: string[],
tags: string[]
): Promise<void> {
const db = await openDB(SessionsDBName, 1);
const updatePromises = sessionIds.map(async (sessionId) => {
const session = await db.get(SessionsStoreName, sessionId);
if (session) {
const existingTags = session.tags || [];
const newTags = [...new Set([...existingTags, ...tags])];
const updatedSession = {
...session,
tags: newTags,
modifyTimestamp: Date.now(),
};
await db.put(SessionsStoreName, updatedSession, sessionId);
}
});
await Promise.all(updatePromises);
await db.close();
}
/**
* 批量删除标签
*/
export async function bulkRemoveTags(
sessionIds: string[],
tagsToRemove: string[]
): Promise<void> {
const db = await openDB(SessionsDBName, 1);
const updatePromises = sessionIds.map(async (sessionId) => {
const session = await db.get(SessionsStoreName, sessionId);
if (session) {
const existingTags = session.tags || [];
const newTags = existingTags.filter(tag => !tagsToRemove.includes(tag));
const updatedSession = {
...session,
tags: newTags,
modifyTimestamp: Date.now(),
};
await db.put(SessionsStoreName, updatedSession, sessionId);
}
});
await Promise.all(updatePromises);
await db.close();
}
/**
* 搜索事件内容
*/
export async function searchEventsInSession(
sessionId: string,
searchTerm: string
): Promise<{ events: eventWithTime[]; matches: number }> {
const db = await openDB(SessionsDBName, 1);
const session = await db.get(SessionsStoreName, sessionId);
if (!session) {
await db.close();
throw new Error('Session not found');
}
const events = await db.getAll(EventsStoreName, sessionId);
await db.close();
const matches: eventWithTime[] = [];
const searchLower = searchTerm.toLowerCase();
events.forEach(event => {
// 搜索事件类型
if (event.type.toLowerCase().includes(searchLower)) {
matches.push(event);
return;
}
// 搜索事件数据
if (event.data) {
const dataString = JSON.stringify(event.data).toLowerCase();
if (dataString.includes(searchLower)) {
matches.push(event);
}
}
});
return { events: matches, matches: matches.length };
}
/**
* 导出会话统计报告
*/
export function exportSessionStats(session: Session, stats: SessionStats): void {
const report = {
session: {
id: session.id,
name: session.name,
tags: session.tags,
created: new Date(session.createTimestamp).toISOString(),
modified: new Date(session.modifyTimestamp).toISOString(),
version: session.recorderVersion,
},
statistics: {
totalEvents: stats.totalEvents,
durationMs: stats.duration,
fileSizeBytes: stats.fileSize,
fileSizeMB: (stats.fileSize / 1024 / 1024).toFixed(2),
avgEventsPerSecond: stats.avgEventsPerSecond.toFixed(2),
startTime: new Date(stats.startTime).toISOString(),
endTime: new Date(stats.endTime).toISOString(),
eventTypes: stats.eventTypes,
},
generatedAt: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `session-stats-${session.id}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,20 @@
/**
* Utility functions for formatting values
*/
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function formatDuration(timestamp: number): string {
const seconds = Math.floor(timestamp / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
export function formatNumber(num: number): string {
return num.toLocaleString();
}

View File

@@ -0,0 +1,68 @@
import type { Settings } from '~/types';
export function generateFileName(settings: Settings, sessionName?: string): string {
const now = new Date();
if (settings.fileNameFormat === 'timestamp') {
const timestamp = now.toISOString()
.replace(/[:.]/g, '-')
.replace('T', '_')
.substring(0, 19);
return timestamp;
} else if (settings.fileNameFormat === 'custom' && settings.customNameFormat) {
let formattedName = settings.customNameFormat;
// Replace variables
formattedName = formattedName.replace('{date}', now.toISOString().split('T')[0]);
formattedName = formattedName.replace('{time}', now.toISOString().split('T')[1].substring(0, 8));
formattedName = formattedName.replace('{session}', sessionName || 'recording');
formattedName = formattedName.replace(/\s+/g, '-');
// Remove invalid characters
formattedName = formattedName.replace(/[<>:"/\\|?*]/g, '');
return formattedName;
} else {
return `recording-${now.toISOString().split('T')[0]}`;
}
}
export function getFullSavePath(settings: Settings, sessionName?: string): string {
const fileName = generateFileName(settings, sessionName);
if (settings.createSubfolders) {
const timestamp = new Date().toISOString().split('T')[0];
return `${settings.savePath}/${timestamp}/${fileName}`;
} else {
return `${settings.savePath}/${fileName}`;
}
}
export function validateSavePath(path: string): { valid: boolean; error?: string } {
if (!path || path.trim() === '') {
return { valid: false, error: 'Save path cannot be empty' };
}
// Check for invalid characters
if (/[<>:"|?*]/.test(path)) {
return { valid: false, error: 'Invalid characters in path' };
}
// Check for reserved names
if (/^(CON|PRN|AUX|NUL|COM\d|LPT\d)$/i.test(path)) {
return { valid: false, error: 'Reserved name' };
}
return { valid: true };
}
export function formatPathForDisplay(path: string): string {
// Shorten path for display if it's too long
if (path.length > 50) {
const parts = path.split(/[/\\]/);
if (parts.length > 3) {
return `.../${parts.slice(-3).join('/')}`;
}
}
return path;
}

View File

@@ -0,0 +1,138 @@
import { openDB } from 'idb';
import { Settings, SyncDataKey } from '~/types';
import { deleteSessions } from './storage';
const SettingsStoreName = 'settings';
const SettingsDBName = 'rrweb-settings';
export async function getStorageSettings(): Promise<Settings> {
const db = await openDB<Settings>(SettingsDBName, 1, {
upgrade(db) {
if (!db.objectStoreNames.contains(SettingsStoreName)) {
db.createObjectStore(SettingsStoreName);
}
},
});
const settings = await db.get(SettingsStoreName, 'settings');
return settings || {};
}
export async function saveStorageSettings(settings: Settings): Promise<void> {
const db = await openDB<Settings>(SettingsDBName, 1, {
upgrade(db) {
if (!db.objectStoreNames.contains(SettingsStoreName)) {
db.createObjectStore(SettingsStoreName);
}
},
});
await db.put(SettingsStoreName, settings, 'settings');
// Also sync to chrome.storage if available
if (chrome?.storage?.sync) {
await chrome.storage.sync.set({ [SyncDataKey.settings]: settings });
}
}
export async function clearAllSessions(): Promise<void> {
await deleteSessions([]);
// Clear settings too
const db = await openDB<Settings>(SettingsDBName, 1);
await db.delete(SettingsStoreName, 'settings');
}
export function exportSettings(settings: Settings): void {
const blob = new Blob([JSON.stringify(settings, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `rrweb-settings-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export async function importSettings(file: File): Promise<Settings> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const settings = JSON.parse(e.target?.result as string);
if (typeof settings === 'object' && settings !== null) {
resolve(settings);
} else {
reject(new Error('Invalid settings file'));
}
} catch (error) {
reject(new Error('Failed to parse settings file'));
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsText(file);
});
}
export function applyDefaultSettings(settings: Partial<Settings>): Settings {
const defaults: Settings = {
recordingQuality: 'balanced',
autoStart: false,
enableNotifications: true,
theme: 'system',
recordCanvas: true,
recordInputs: true,
recordMouse: true,
recordScroll: true,
blockSensitiveData: true,
maskInputs: false,
excludedDomains: [],
autoCleanupDays: 30,
maxRecordingSize: '100',
defaultExportFormat: 'json',
includeMetadata: true,
shortcuts: {
start: 'Ctrl+Shift+R',
stop: 'Ctrl+Shift+S',
},
// File path management defaults
savePath: 'recordings',
createSubfolders: true,
fileNameFormat: 'timestamp',
customNameFormat: 'recording-{date}-{time}',
};
// Deep merge settings
return {
...defaults,
...settings,
shortcuts: { ...defaults.shortcuts, ...settings.shortcuts },
excludedDomains: settings.excludedDomains || defaults.excludedDomains,
};
}
export async function validateSettings(settings: Partial<Settings>): Promise<{ valid: boolean; errors: string[] }> {
const errors: string[] = [];
if (settings.shortcuts?.start && settings.shortcuts.start.length > 10) {
errors.push('Start shortcut too long');
}
if (settings.shortcuts?.stop && settings.shortcuts.stop.length > 10) {
errors.push('Stop shortcut too long');
}
if (settings.autoCleanupDays && (settings.autoCleanupDays < 0 || settings.autoCleanupDays > 365)) {
errors.push('Auto-cleanup days must be between 0 and 365');
}
const validExportFormats = ['json', 'html', 'zip'];
if (settings.defaultExportFormat && !validExportFormats.includes(settings.defaultExportFormat)) {
errors.push('Invalid export format');
}
return {
valid: errors.length === 0,
errors,
};
}

View File

@@ -101,21 +101,304 @@ export async function deleteSessions(ids: string[]) {
});
}
export async function downloadSessions(ids: string[]) {
for (const sessionId of ids) {
export async function downloadSessions(ids: string[], format: 'json' | 'html' | 'zip' = 'json') {
if (ids.length === 0) {
console.error('No session IDs provided for download');
return;
}
if (format === 'json') {
// 如果是 JSON 格式,可以选择单个导出或合并导出
await downloadJSONSessions(ids);
} else {
// HTML 和 ZIP 格式逐个导出
await downloadMultipleSessions(ids, format);
}
}
// 专门处理 JSON 导出
async function downloadJSONSessions(ids: string[]) {
if (ids.length === 1) {
// 单个会话导出
const sessionId = ids[0];
const events = await getEvents(sessionId);
const session = await getSession(sessionId);
const blob = new Blob([JSON.stringify({ session, events }, null, 2)], {
if (!session || !events) {
console.error(`Session ${sessionId} not found`);
return;
}
const cleanFileName = session.name.replace(/[<>:"/\\|?*]/g, '_').trim() || 'recording';
const jsonData = {
session,
events,
metadata: {
exportedAt: new Date().toISOString(),
version: '1.0',
totalEvents: events.length,
duration: events.length > 0 ?
(events[events.length - 1].timestamp - events[0].timestamp) : 0
}
};
const blob = new Blob([JSON.stringify(jsonData, null, 2)], {
type: 'application/json',
});
downloadFile(blob, `${cleanFileName}.json`);
} else {
// 多个会话导出为单个 JSON 文件
const sessionData = [];
for (const sessionId of ids) {
try {
const events = await getEvents(sessionId);
const session = await getSession(sessionId);
if (session && events) {
sessionData.push({
session,
events,
metadata: {
exportedAt: new Date().toISOString(),
version: '1.0',
totalEvents: events.length,
duration: events.length > 0 ?
(events[events.length - 1].timestamp - events[0].timestamp) : 0
}
});
}
} catch (error) {
console.error(`Error processing session ${sessionId}:`, error);
}
}
const exportData = {
exportedAt: new Date().toISOString(),
version: '1.0',
totalSessions: sessionData.length,
sessions: sessionData
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: 'application/json',
});
downloadFile(blob, `rrweb-sessions-${new Date().toISOString().split('T')[0]}.json`);
}
}
// 处理多个会话的导出HTML 和 ZIP
async function downloadMultipleSessions(ids: string[], format: 'json' | 'html' | 'zip') {
// 为每个会话创建单独的下载任务
const downloadPromises = ids.map(async (sessionId) => {
try {
const events = await getEvents(sessionId);
const session = await getSession(sessionId);
if (!session || !events) {
console.error(`Session ${sessionId} not found`);
return;
}
// 清理文件名,移除非法字符
const cleanFileName = session.name.replace(/[<>:"/\\|?*]/g, '_').trim() || 'recording';
if (format === 'html') {
const htmlContent = generateHTMLReplay(session, events);
const blob = new Blob([htmlContent], { type: 'text/html' });
downloadFile(blob, `${cleanFileName}.html`);
} else if (format === 'zip') {
const zipContent = await generateZIPPackage(session, events);
downloadFile(zipContent, `${cleanFileName}.zip`);
}
} catch (error) {
console.error(`Error downloading session ${sessionId}:`, error);
}
});
// 等待所有下载完成
await Promise.all(downloadPromises);
}
function downloadFile(blob: Blob, filename: string) {
try {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${session.name}.json`;
a.download = filename;
// 确保链接在 DOM 中才能触发下载
document.body.appendChild(a);
// 触发下载
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// 延迟移除元素,确保下载开始
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
} catch (error) {
console.error('Error downloading file:', error);
}
}
function generateHTMLReplay(session: Session, events: eventWithTime[]): string {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>rrweb 录制回放 - ${session.name}</title>
<script src="https://unpkg.com/rrweb@latest/dist/rrweb.umd.cjs"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
padding: 30px;
}
.header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #e0e0e0;
}
.title {
font-size: 28px;
color: #333;
margin-bottom: 10px;
}
.info {
color: #666;
font-size: 14px;
}
#replayer {
width: 100%;
height: 600px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
}
.controls {
margin-top: 20px;
text-align: center;
}
.btn {
background: #3B82F6;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
margin: 0 5px;
font-size: 14px;
}
.btn:hover {
background: #2563EB;
}
.btn:disabled {
background: #9CA3AF;
cursor: not-allowed;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 class="title">${session.name}</h1>
<div class="info">
录制时间: ${new Date(session.createTimestamp).toLocaleString()}<br>
事件数量: ${events.length}<br>
rrweb 版本: ${session.recorderVersion}
</div>
</div>
<div id="replayer"></div>
<div class="controls">
<button id="playBtn" class="btn">▶ 播放</button>
<button id="pauseBtn" class="btn" disabled>⏸ 暂停</button>
<button id="restartBtn" class="btn">🔄 重新开始</button>
</div>
</div>
<script>
const events = ${JSON.stringify(events)};
const replayer = new rrweb.Replayer(events);
const playBtn = document.getElementById('playBtn');
const pauseBtn = document.getElementById('pauseBtn');
const restartBtn = document.getElementById('restartBtn');
playBtn.addEventListener('click', () => {
replayer.play();
playBtn.disabled = true;
pauseBtn.disabled = false;
});
pauseBtn.addEventListener('click', () => {
replayer.pause();
playBtn.disabled = false;
pauseBtn.disabled = true;
});
restartBtn.addEventListener('click', () => {
replayer.pause();
replayer.reset();
playBtn.disabled = false;
pauseBtn.disabled = true;
});
// 自动播放
replayer.play();
</script>
</body>
</html>`;
}
async function generateZIPPackage(session: Session, events: eventWithTime[]): Promise<Blob> {
// 动态导入 JSZip
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
// 添加 JSON 数据
const jsonData = JSON.stringify({ session, events }, null, 2);
zip.file('recording.json', jsonData);
// 添加 HTML 回放页面
const htmlContent = generateHTMLReplay(session, events);
zip.file('replay.html', htmlContent);
// 添加 README 文件
const readmeContent = `# rrweb 录制文件
## 录制信息
- **名称**: ${session.name}
- **录制时间**: ${new Date(session.createTimestamp).toLocaleString()}
- **事件数量**: ${events.length}
- **rrweb 版本**: ${session.recorderVersion}
## 文件说明
- \`recording.json\`: 原始录制数据JSON 格式)
- \`replay.html\`: HTML 回放页面,可在浏览器中打开查看录制内容
## 使用方法
1. 打开 \`replay.html\` 文件在浏览器中查看录制回放
2. \`recording.json\` 包含完整的录制数据,可用于其他 rrweb 兼容的工具
## 技术信息
本录制文件由 rrweb 浏览器插件生成,使用 rrweb ${session.recorderVersion} 版本。
生成时间: ${new Date().toISOString()}
`;
zip.file('README.md', readmeContent);
return zip.generateAsync({ type: 'blob' });
}

View File

@@ -0,0 +1,78 @@
# RRWeb 插件导出功能测试指南
## 🎯 测试目标
验证 JSON 导出功能是否能够完整导出录制的用户操作信息
## 📋 测试步骤
### 1. 安装更新后的插件
1. 在 Chrome 浏览器中打开 `chrome://extensions/`
2. 找到现有的 RRWeb 插件
3. 点击"删除"按钮移除旧版本
4. 点击"加载已解压的扩展程序"
5. 选择 `C:\Users\xgp\projects\rrweb\packages\web-extension\dist\chrome` 文件夹
### 2. 录制测试数据
1. 点击浏览器工具栏的 RRWeb 图标
2. 点击"开始录制"
3. 在打开的页面中进行以下操作:
- 点击"随机变色"按钮几次
- 点击"添加计数器"按钮几次
- 点击"测试弹窗"按钮
- 在输入框中输入一些文字
- 点击一些页面上的其他元素
4. 点击"停止录制"
### 3. 验证录制历史
1. 再次点击 RRWeb 图标
2. 点击"录制历史"按钮
3. 确认能看到刚才录制的会话列表
4. 查看会话的基本信息(名称、时间、事件数量等)
### 4. 测试 JSON 导出功能
#### 单个会话导出测试:
1. 在录制历史列表中,找到刚才录制的会话
2. 点击该会话的复选框选中它
3. 点击"导出"按钮
4. 选择"JSON"格式
5. 确认下载的 JSON 文件
#### 多个会话导出测试:
1. 选择多个会话(复选框)
2. 点击"导出"按钮
3. 选择"JSON"格式
4. 确认下载的合并 JSON 文件
### 5. 验证导出文件
1. 打开下载的 JSON 文件
2. 检查文件内容应该包含:
- `session` 对象:包含会话元数据
- `events` 数组:包含所有录制的事件
- `metadata` 对象:包含导出时间、版本、事件数量等信息
### 6. 检查数据完整性
1. 确认 `events` 数组不为空
2. 确认每个事件都有正确的 `timestamp``type``data` 属性
3. 确认事件数据完整反映了你的操作
## 🔍 故障排除
### 如果导出功能不工作:
1. 检查浏览器控制台是否有错误信息F12
2. 确保 IndexedDB 中的数据正确存储
3. 检查网络连接是否正常
4. 尝试刷新页面重新测试
### 如果录制历史为空:
1. 确认录制操作成功完成
2. 检查 IndexedDB 数据是否正确保存
3. 尝试清除浏览器数据后重新录制
## 📁 文件存储位置
- 录制数据存储在浏览器的 IndexedDB 中
- 导出的 JSON 文件默认下载到浏览器的默认下载文件夹
## ✅ 验收标准
- 能够成功导出包含完整录制数据的 JSON 文件
- 单个会话和多个会话的 JSON 导出都能正常工作
- 导出的 JSON 文件包含完整的会话信息和所有事件数据