|
|
@@ -1,185 +1,1035 @@
|
|
|
-import React, { useState } from 'react';
|
|
|
-import { X, Play, Pause, FileText, Camera, Bookmark, Mic, Plus } from 'lucide-react';
|
|
|
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
+import {
|
|
|
+ ArrowLeft,
|
|
|
+ BarChart3,
|
|
|
+ Camera,
|
|
|
+ CheckCircle2,
|
|
|
+ ChevronRight,
|
|
|
+ CircleAlert,
|
|
|
+ Cloud,
|
|
|
+ FileText,
|
|
|
+ Info,
|
|
|
+ LoaderCircle,
|
|
|
+ MapPin,
|
|
|
+ Pause,
|
|
|
+ Play,
|
|
|
+ Radio,
|
|
|
+ RefreshCw,
|
|
|
+ RotateCcw,
|
|
|
+ RotateCw,
|
|
|
+ Share2,
|
|
|
+ Sparkles,
|
|
|
+ Wand2,
|
|
|
+ X
|
|
|
+} from 'lucide-react';
|
|
|
import { sessionAPI } from '../api';
|
|
|
+import { analyzeAudioBlob, concatenateAudioBlobs } from './record-detail/audioAnalysis';
|
|
|
+import { MultiTrackTimeline } from './record-detail/MultiTrackTimeline';
|
|
|
+import {
|
|
|
+ ContinuationRecorderDialog,
|
|
|
+ MarkerDetailDialog,
|
|
|
+ NoteEditorDialog,
|
|
|
+ PhotoDetailDialog,
|
|
|
+ PhotoEditorDialog,
|
|
|
+ SessionInfoPopover,
|
|
|
+ SyncInfoPopover,
|
|
|
+ TitleEditorDialog
|
|
|
+} from './record-detail/RecordDetailDialogs';
|
|
|
+import {
|
|
|
+ applyLocation,
|
|
|
+ buildChronoFeedItems,
|
|
|
+ buildSessionPayload,
|
|
|
+ countSessionEvents,
|
|
|
+ createEvent,
|
|
|
+ errorMessage,
|
|
|
+ findPhotoAsset,
|
|
|
+ formatDuration,
|
|
|
+ isContinuationEvent,
|
|
|
+ makeLocation,
|
|
|
+ photoAssetClientId,
|
|
|
+ sanitizeAudioFileName,
|
|
|
+ selectLatestAudioAsset
|
|
|
+} from './record-detail/recordDetailUtils';
|
|
|
|
|
|
-export const RecordDetailModal = ({ session, onClose, onEventAdded }) => {
|
|
|
+const MAX_UPLOAD_BYTES = 70 * 1024 * 1024;
|
|
|
+
|
|
|
+const fileExtension = (asset, blob) => {
|
|
|
+ const byName = String(asset?.fileName || '').split('.').pop();
|
|
|
+ if (byName && byName !== asset?.fileName) return byName.toLowerCase();
|
|
|
+ const subtype = String(blob?.type || '').split('/')[1]?.split(';')[0];
|
|
|
+ return subtype === 'mp4' ? 'm4a' : subtype || 'm4a';
|
|
|
+};
|
|
|
+
|
|
|
+const preparePhotoFile = async (file) => {
|
|
|
+ if (!window.createImageBitmap || file.type === 'image/gif') return file;
|
|
|
+ const bitmap = await createImageBitmap(file);
|
|
|
+ const maxDimension = 4096;
|
|
|
+ const ratio = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height));
|
|
|
+ const canvas = document.createElement('canvas');
|
|
|
+ canvas.width = Math.max(1, Math.round(bitmap.width * ratio));
|
|
|
+ canvas.height = Math.max(1, Math.round(bitmap.height * ratio));
|
|
|
+ canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
|
+ bitmap.close();
|
|
|
+ const blob = await new Promise((resolve, reject) => {
|
|
|
+ canvas.toBlob(
|
|
|
+ (result) => result ? resolve(result) : reject(new Error('照片压缩失败。')),
|
|
|
+ 'image/jpeg',
|
|
|
+ 0.85
|
|
|
+ );
|
|
|
+ });
|
|
|
+ return new File([blob], `${file.name.replace(/\.[^.]+$/, '') || 'photo'}.jpg`, {
|
|
|
+ type: 'image/jpeg',
|
|
|
+ lastModified: file.lastModified
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const getResponseData = (response, fallbackMessage) => {
|
|
|
+ if (response?.code !== 0 || !response?.data) {
|
|
|
+ throw new Error(response?.message || fallbackMessage);
|
|
|
+ }
|
|
|
+ return response.data;
|
|
|
+};
|
|
|
+
|
|
|
+const EventCard = ({ item, imageUrls, onClick }) => {
|
|
|
+ const { event, photoEvents } = item;
|
|
|
+ const isPhotos = photoEvents.length > 0;
|
|
|
+ const continuation = isContinuationEvent(event);
|
|
|
+ const Icon = isPhotos ? Camera : continuation ? Radio : FileText;
|
|
|
+ const typeLabel = isPhotos
|
|
|
+ ? `${photoEvents.length} 张照片`
|
|
|
+ : continuation
|
|
|
+ ? '续录'
|
|
|
+ : event.eventType === 'MARKER'
|
|
|
+ ? '标记'
|
|
|
+ : '笔记';
|
|
|
+ const eventWithLocation = (isPhotos ? photoEvents : [event]).find((candidate) => makeLocation(candidate));
|
|
|
+
|
|
|
+ return (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={onClick}
|
|
|
+ className="group flex w-full items-start gap-3 border-b border-black/[0.07] dark:border-white/[0.07] px-1 py-3.5 text-left last:border-b-0"
|
|
|
+ >
|
|
|
+ <span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-black/10 dark:border-white/10 bg-white dark:bg-white/[0.04]">
|
|
|
+ <Icon className="h-3.5 w-3.5 text-slate-600 dark:text-slate-300" />
|
|
|
+ </span>
|
|
|
+ <span className="min-w-0 flex-1">
|
|
|
+ <span className="flex items-center gap-2">
|
|
|
+ <span className="font-mono text-[11px] font-semibold">{formatDuration(event.relativeTimeMs)}</span>
|
|
|
+ <span className="text-[10px] text-slate-500">{typeLabel}</span>
|
|
|
+ </span>
|
|
|
+ {isPhotos && (
|
|
|
+ <span className="mt-2 flex h-16 gap-1.5 overflow-hidden">
|
|
|
+ {photoEvents.slice(0, 3).map((photo) => (
|
|
|
+ imageUrls[photo.id]
|
|
|
+ ? <img key={photo.id} src={imageUrls[photo.id]} alt={photo.textContent || '现场记录照片'} className="h-16 w-20 rounded-md object-cover" />
|
|
|
+ : <span key={photo.id} className="flex h-16 w-20 items-center justify-center rounded-md bg-slate-100 dark:bg-white/5"><Camera className="h-4 w-4 text-slate-400" /></span>
|
|
|
+ ))}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ {event.textContent?.trim() && (
|
|
|
+ <span className="mt-1.5 block whitespace-pre-wrap text-[13px] leading-5 text-slate-700 dark:text-slate-300">
|
|
|
+ {event.textContent.trim()}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ {eventWithLocation && (
|
|
|
+ <span className="mt-1.5 flex min-w-0 items-center gap-1 text-[10px] text-slate-500">
|
|
|
+ <MapPin className="h-3 w-3 shrink-0" />
|
|
|
+ <span className="truncate">{eventWithLocation.locationName || eventWithLocation.locationAddress}</span>
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </span>
|
|
|
+ <ChevronRight className="mt-2 h-3.5 w-3.5 shrink-0 text-slate-300 transition-transform group-hover:translate-x-0.5 group-hover:text-slate-600 dark:text-slate-700 dark:group-hover:text-slate-300" />
|
|
|
+ </button>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+const AIReservedPanel = () => (
|
|
|
+ <section className="flex flex-col overflow-y-auto p-6 space-y-6 bg-slate-50/50 dark:bg-slate-950 relative">
|
|
|
+ {/* Section Header */}
|
|
|
+ <div className="flex items-center justify-between border-b border-black/10 dark:border-white/10 pb-3 z-10">
|
|
|
+ <div className="flex items-center space-x-2">
|
|
|
+ <Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
|
|
+ <h2 className="text-xs font-bold text-slate-900 dark:text-white tracking-wider uppercase">AI 智算与分析中心</h2>
|
|
|
+ </div>
|
|
|
+ <span className="px-2.5 py-0.5 text-[11px] font-mono text-purple-700 dark:text-purple-300 bg-purple-50 dark:bg-purple-500/10 rounded-full border border-purple-200 dark:border-purple-500/20">
|
|
|
+ 预留 AI 拓展位
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Feature Card 1: AI Audio Transcript Placeholder */}
|
|
|
+ <div className="p-6 rounded-3xl bg-white dark:bg-slate-900/80 border border-black/10 dark:border-white/10 hover:border-purple-300 dark:hover:border-purple-500/30 transition-all space-y-4 z-10 shadow-sm relative overflow-hidden group">
|
|
|
+ <div className="flex items-start justify-between">
|
|
|
+ <div className="flex items-center space-x-3">
|
|
|
+ <div className="p-3 rounded-2xl bg-purple-50 dark:bg-purple-500/10 text-purple-600 dark:text-purple-400 border border-purple-100 dark:border-purple-500/20">
|
|
|
+ <FileText className="w-6 h-6" />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <div className="flex items-center space-x-2">
|
|
|
+ <h3 className="text-base font-bold text-slate-900 dark:text-white">AI 智能逐字稿</h3>
|
|
|
+ <span className="px-2 py-0.5 text-[10px] font-mono font-semibold text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-400/10 rounded border border-amber-200 dark:border-amber-400/20">
|
|
|
+ 即将在后续版本推出
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <p className="text-xs text-slate-500 dark:text-slate-400 mt-1 leading-relaxed">
|
|
|
+ 智能解析现场语音轨,自动识别多发言人角色,生成带高频词萃取与时间戳定位的完整对话文稿。
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Empty State / Preview Wireframe */}
|
|
|
+ <div className="p-4 rounded-2xl bg-slate-50 dark:bg-slate-950/70 border border-black/5 dark:border-white/5 space-y-2.5 font-mono text-xs text-slate-500">
|
|
|
+ <div className="flex items-center justify-between text-[11px] text-slate-400 pb-2 border-b border-black/5 dark:border-white/5">
|
|
|
+ <span>[发言人 A] 00:01 - 00:45</span>
|
|
|
+ <span className="text-purple-600 dark:text-purple-400 font-semibold">识别置信度 99%</span>
|
|
|
+ </div>
|
|
|
+ <p className="text-slate-600 dark:text-slate-400 line-clamp-2 text-xs italic">
|
|
|
+ “现场设备勘测完成,已同步声学波形与时间轴快照记录,接下来可直接一键生成结构化转录文稿……”
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Action Button */}
|
|
|
+ <div className="pt-1 flex items-center justify-between">
|
|
|
+ <span className="text-[11px] text-slate-400 font-mono">底层语音大模型与转写引擎对接中</span>
|
|
|
+ <button
|
|
|
+ disabled
|
|
|
+ className="px-4 py-2 rounded-xl bg-purple-50 dark:bg-purple-500/10 border border-purple-200 dark:border-purple-500/20 text-purple-600 dark:text-purple-300 text-xs font-semibold opacity-60 cursor-not-allowed flex items-center space-x-2"
|
|
|
+ >
|
|
|
+ <Sparkles className="w-3.5 h-3.5" />
|
|
|
+ <span>生成 AI 逐字稿 (即将推出)</span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Feature Card 2: AI Full Analysis Report Placeholder */}
|
|
|
+ <div className="p-6 rounded-3xl bg-white dark:bg-slate-900/80 border border-black/10 dark:border-white/10 hover:border-blue-300 dark:hover:border-blue-500/30 transition-all space-y-4 z-10 shadow-sm relative overflow-hidden group">
|
|
|
+ <div className="flex items-start justify-between">
|
|
|
+ <div className="flex items-center space-x-3">
|
|
|
+ <div className="p-3 rounded-2xl bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-100 dark:border-blue-500/20">
|
|
|
+ <BarChart3 className="w-6 h-6" />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <div className="flex items-center space-x-2">
|
|
|
+ <h3 className="text-base font-bold text-slate-900 dark:text-white">AI 现场完整分析报告</h3>
|
|
|
+ <span className="px-2 py-0.5 text-[10px] font-mono font-semibold text-blue-700 dark:text-blue-400 bg-blue-50 dark:bg-blue-400/10 rounded border border-blue-200 dark:border-blue-400/20">
|
|
|
+ 预留 AI 拓展位
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <p className="text-xs text-slate-500 dark:text-slate-400 mt-1 leading-relaxed">
|
|
|
+ 综合现场照片快照、标记随笔与逐字稿数据,自动构建可视化分析图表与专家级现场勘测总结报告。
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Empty State / Preview Wireframe */}
|
|
|
+ <div className="grid grid-cols-2 gap-3 text-xs font-mono">
|
|
|
+ <div className="p-3 rounded-2xl bg-slate-50 dark:bg-slate-950/70 border border-black/5 dark:border-white/5 space-y-1">
|
|
|
+ <span className="text-[10px] text-slate-400 block">智能结构化汇总</span>
|
|
|
+ <span className="text-slate-800 dark:text-slate-300 font-semibold text-xs">生成 PDF / MarkDown 报告</span>
|
|
|
+ </div>
|
|
|
+ <div className="p-3 rounded-2xl bg-slate-50 dark:bg-slate-950/70 border border-black/5 dark:border-white/5 space-y-1">
|
|
|
+ <span className="text-[10px] text-slate-400 block">勘测结论提炼</span>
|
|
|
+ <span className="text-blue-600 dark:text-blue-400 font-semibold text-xs">智能风险评估与建议</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Action Button */}
|
|
|
+ <div className="pt-1 flex items-center justify-between">
|
|
|
+ <span className="text-[11px] text-slate-400 font-mono">包含现场结论生成与自动化排版导出</span>
|
|
|
+ <button
|
|
|
+ disabled
|
|
|
+ className="px-4 py-2 rounded-xl bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20 text-blue-600 dark:text-blue-300 text-xs font-semibold opacity-60 cursor-not-allowed flex items-center space-x-2"
|
|
|
+ >
|
|
|
+ <Wand2 className="w-3.5 h-3.5" />
|
|
|
+ <span>生成完整分析报告 (即将推出)</span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Reserved Footer Tip */}
|
|
|
+ <div className="mt-auto p-4 rounded-2xl bg-white dark:bg-white/[0.02] border border-black/5 dark:border-white/5 text-center text-slate-500 text-xs font-mono space-y-1 z-10 shadow-sm">
|
|
|
+ <p>💡 此区域为未来 AI 模块全屏右侧联动空间</p>
|
|
|
+ <p className="text-[11px] text-slate-400">后端与 AI 引擎接入完成后,可实时生成逐字稿并与左侧多轨时间轴双向定位互动</p>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+);
|
|
|
+
|
|
|
+export const RecordDetailModal = ({ session, onClose, onSessionUpdated }) => {
|
|
|
+ const audioRef = useRef(null);
|
|
|
+ const audioObjectUrlRef = useRef(null);
|
|
|
+ const photoObjectUrlsRef = useRef([]);
|
|
|
+ const operationAbortRef = useRef(null);
|
|
|
+ const silenceJumpRef = useRef(-1);
|
|
|
+ const toastTimerRef = useRef(null);
|
|
|
+
|
|
|
+ const [detail, setDetail] = useState(null);
|
|
|
+ const [loading, setLoading] = useState(false);
|
|
|
+ const [loadError, setLoadError] = useState('');
|
|
|
+ const [audioBlob, setAudioBlob] = useState(null);
|
|
|
+ const [audioAsset, setAudioAsset] = useState(null);
|
|
|
+ const [audioLoading, setAudioLoading] = useState(false);
|
|
|
+ const [audioError, setAudioError] = useState('');
|
|
|
+ const [analysis, setAnalysis] = useState({ samples: [], silentRanges: [], durationMs: 0 });
|
|
|
+ const [currentTimeMs, setCurrentTimeMs] = useState(0);
|
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
|
- const [playbackTime, setPlaybackTime] = useState(0);
|
|
|
- const [newNoteText, setNewNoteText] = useState('');
|
|
|
- const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
+ const [skipSilence, setSkipSilence] = useState(true);
|
|
|
+ const [imageUrls, setImageUrls] = useState({});
|
|
|
+ const [editor, setEditor] = useState(null);
|
|
|
+ const [saving, setSaving] = useState(false);
|
|
|
+ const [syncState, setSyncState] = useState('synced');
|
|
|
+ const [syncProgress, setSyncProgress] = useState(null);
|
|
|
+ const [syncError, setSyncError] = useState('');
|
|
|
+ const [showSessionInfo, setShowSessionInfo] = useState(false);
|
|
|
+ const [showSyncInfo, setShowSyncInfo] = useState(false);
|
|
|
+ const [toast, setToast] = useState('');
|
|
|
|
|
|
- if (!session) return null;
|
|
|
+ const showToast = useCallback((message) => {
|
|
|
+ window.clearTimeout(toastTimerRef.current);
|
|
|
+ setToast(message);
|
|
|
+ toastTimerRef.current = window.setTimeout(() => setToast(''), 3000);
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const refreshDetail = useCallback(async (signal) => {
|
|
|
+ const response = await sessionAPI.getSessionDetail(session.id, signal);
|
|
|
+ const next = getResponseData(response, '记录详情加载失败。');
|
|
|
+ setDetail(next);
|
|
|
+ return next;
|
|
|
+ }, [session?.id]);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (!session?.id) return undefined;
|
|
|
+ const controller = new AbortController();
|
|
|
+ setDetail(null);
|
|
|
+ setLoading(true);
|
|
|
+ setLoadError('');
|
|
|
+ setEditor(null);
|
|
|
+ setCurrentTimeMs(0);
|
|
|
+ refreshDetail(controller.signal)
|
|
|
+ .catch((error) => {
|
|
|
+ if (error.name !== 'CanceledError' && error.name !== 'AbortError') {
|
|
|
+ setLoadError(errorMessage(error, '记录详情加载失败。'));
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .finally(() => setLoading(false));
|
|
|
+ return () => controller.abort();
|
|
|
+ }, [refreshDetail, session?.id]);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (!detail?.id) return undefined;
|
|
|
+ const controller = new AbortController();
|
|
|
+ const asset = selectLatestAudioAsset(detail.assets);
|
|
|
+ setAudioAsset(asset);
|
|
|
+ setAudioBlob(null);
|
|
|
+ setAnalysis({ samples: [], silentRanges: [], durationMs: 0 });
|
|
|
+ setAudioError('');
|
|
|
+ setAudioLoading(Boolean(asset));
|
|
|
+ if (audioObjectUrlRef.current) URL.revokeObjectURL(audioObjectUrlRef.current);
|
|
|
+ audioObjectUrlRef.current = null;
|
|
|
+ if (!asset) {
|
|
|
+ setAudioError('这条记录没有可播放的音频文件。');
|
|
|
+ setAudioLoading(false);
|
|
|
+ return () => controller.abort();
|
|
|
+ }
|
|
|
|
|
|
- const events = session.events || [];
|
|
|
+ sessionAPI.downloadAssetBlob(detail.id, asset.id, controller.signal)
|
|
|
+ .then(async (blob) => {
|
|
|
+ if (controller.signal.aborted) return;
|
|
|
+ const objectUrl = URL.createObjectURL(blob);
|
|
|
+ audioObjectUrlRef.current = objectUrl;
|
|
|
+ setAudioBlob(blob);
|
|
|
+ if (audioRef.current) {
|
|
|
+ audioRef.current.src = objectUrl;
|
|
|
+ audioRef.current.load();
|
|
|
+ }
|
|
|
+ const result = await analyzeAudioBlob(blob);
|
|
|
+ if (!controller.signal.aborted) setAnalysis(result);
|
|
|
+ })
|
|
|
+ .catch((error) => {
|
|
|
+ if (error.name !== 'CanceledError' && error.name !== 'AbortError') {
|
|
|
+ setAudioError(errorMessage(error, '音频下载或解析失败。'));
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .finally(() => {
|
|
|
+ if (!controller.signal.aborted) setAudioLoading(false);
|
|
|
+ });
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ controller.abort();
|
|
|
+ audioRef.current?.pause();
|
|
|
+ if (audioObjectUrlRef.current) URL.revokeObjectURL(audioObjectUrlRef.current);
|
|
|
+ audioObjectUrlRef.current = null;
|
|
|
+ };
|
|
|
+ }, [detail?.id, detail?.assets]);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (!detail?.id) return undefined;
|
|
|
+ const controller = new AbortController();
|
|
|
+ photoObjectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
|
|
+ photoObjectUrlsRef.current = [];
|
|
|
+ setImageUrls({});
|
|
|
+ const photoEvents = (detail.events || []).filter((event) => event.eventType === 'PHOTO');
|
|
|
+ Promise.all(photoEvents.map(async (event) => {
|
|
|
+ const asset = findPhotoAsset(event, detail.assets);
|
|
|
+ if (!asset) return null;
|
|
|
+ try {
|
|
|
+ const blob = await sessionAPI.downloadAssetBlob(detail.id, asset.id, controller.signal);
|
|
|
+ if (controller.signal.aborted) return null;
|
|
|
+ const url = URL.createObjectURL(blob);
|
|
|
+ photoObjectUrlsRef.current.push(url);
|
|
|
+ return [event.id, url];
|
|
|
+ } catch (error) {
|
|
|
+ if (error.name !== 'CanceledError' && error.name !== 'AbortError') {
|
|
|
+ console.warn('Photo download failed:', asset.id, error);
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ })).then((entries) => {
|
|
|
+ if (!controller.signal.aborted) setImageUrls(Object.fromEntries(entries.filter(Boolean)));
|
|
|
+ });
|
|
|
+ return () => {
|
|
|
+ controller.abort();
|
|
|
+ photoObjectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
|
|
+ photoObjectUrlsRef.current = [];
|
|
|
+ };
|
|
|
+ }, [detail?.id, detail?.events, detail?.assets]);
|
|
|
|
|
|
- const formatDuration = (ms) => {
|
|
|
- const totalSec = Math.floor((ms || 0) / 1000);
|
|
|
- const m = Math.floor(totalSec / 60);
|
|
|
- const s = totalSec % 60;
|
|
|
- return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
|
+ useEffect(() => () => {
|
|
|
+ window.clearTimeout(toastTimerRef.current);
|
|
|
+ operationAbortRef.current?.abort();
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const durationMs = Math.max(
|
|
|
+ Number(detail?.durationMs) || 0,
|
|
|
+ Number(analysis.durationMs) || 0
|
|
|
+ );
|
|
|
+ const events = useMemo(() => detail?.events || [], [detail?.events]);
|
|
|
+ const feedItems = useMemo(() => buildChronoFeedItems(events), [events]);
|
|
|
+ const counts = useMemo(() => countSessionEvents(events), [events]);
|
|
|
+
|
|
|
+ const seek = useCallback((timeMs) => {
|
|
|
+ const target = Math.min(Math.max(Number(timeMs) || 0, 0), durationMs);
|
|
|
+ setCurrentTimeMs(target);
|
|
|
+ if (audioRef.current && Number.isFinite(audioRef.current.duration)) {
|
|
|
+ audioRef.current.currentTime = target / 1000;
|
|
|
+ }
|
|
|
+ }, [durationMs]);
|
|
|
+
|
|
|
+ const persistSession = useCallback(async ({
|
|
|
+ source = detail,
|
|
|
+ title = source?.title,
|
|
|
+ nextEvents = source?.events || [],
|
|
|
+ nextDurationMs = source?.durationMs,
|
|
|
+ endTime = source?.endTime,
|
|
|
+ deletedEventClientIds = []
|
|
|
+ } = {}) => {
|
|
|
+ if (!source) throw new Error('记录详情尚未加载完成。');
|
|
|
+ operationAbortRef.current?.abort();
|
|
|
+ const controller = new AbortController();
|
|
|
+ operationAbortRef.current = controller;
|
|
|
+ setSaving(true);
|
|
|
+ setSyncState('syncing');
|
|
|
+ setSyncProgress(null);
|
|
|
+ setSyncError('');
|
|
|
+ try {
|
|
|
+ const payload = buildSessionPayload({
|
|
|
+ session: source,
|
|
|
+ title,
|
|
|
+ events: nextEvents,
|
|
|
+ durationMs: nextDurationMs,
|
|
|
+ endTime,
|
|
|
+ deletedEventClientIds
|
|
|
+ });
|
|
|
+ const response = await sessionAPI.syncSession(payload, controller.signal);
|
|
|
+ const next = getResponseData(response, '记录同步失败。');
|
|
|
+ setDetail(next);
|
|
|
+ setSyncState('synced');
|
|
|
+ onSessionUpdated?.(next);
|
|
|
+ return next;
|
|
|
+ } catch (error) {
|
|
|
+ if (error.name === 'CanceledError' || error.name === 'AbortError') {
|
|
|
+ setSyncState('unsynced');
|
|
|
+ } else {
|
|
|
+ const message = error?.response?.status === 409
|
|
|
+ ? '云端记录已经更新,请刷新后再试。'
|
|
|
+ : errorMessage(error, '同步失败,请重试。');
|
|
|
+ setSyncState('error');
|
|
|
+ setSyncError(message);
|
|
|
+ }
|
|
|
+ throw error;
|
|
|
+ } finally {
|
|
|
+ if (operationAbortRef.current === controller) operationAbortRef.current = null;
|
|
|
+ setSaving(false);
|
|
|
+ }
|
|
|
+ }, [detail, onSessionUpdated]);
|
|
|
+
|
|
|
+ const uploadAsset = async (source, { clientId, kind, file }) => {
|
|
|
+ const controller = operationAbortRef.current || new AbortController();
|
|
|
+ if (!operationAbortRef.current) operationAbortRef.current = controller;
|
|
|
+ setSyncState('syncing');
|
|
|
+ setSyncProgress(0);
|
|
|
+ const response = await sessionAPI.uploadAsset(source.id, { clientId, kind, file }, {
|
|
|
+ signal: controller.signal,
|
|
|
+ onUploadProgress: (progress) => {
|
|
|
+ if (progress.total) setSyncProgress(Math.round(progress.loaded / progress.total * 100));
|
|
|
+ }
|
|
|
+ });
|
|
|
+ getResponseData(response, '资源上传失败。');
|
|
|
+ setSyncProgress(null);
|
|
|
};
|
|
|
|
|
|
- const handleAddNote = async (e) => {
|
|
|
- e.preventDefault();
|
|
|
- if (!newNoteText.trim()) return;
|
|
|
- setIsSubmitting(true);
|
|
|
+ const deleteEvent = async (target) => {
|
|
|
+ if (!window.confirm('确定删除这条记录吗?删除后将同步到云端。')) return;
|
|
|
+ const clientId = target.clientId || target.id;
|
|
|
try {
|
|
|
- const res = await sessionAPI.addEvent(session.id, {
|
|
|
- clientId: crypto.randomUUID(),
|
|
|
- relativeTimeMs: playbackTime * 1000,
|
|
|
- eventType: 'NOTE',
|
|
|
- textContent: newNoteText
|
|
|
+ await persistSession({
|
|
|
+ nextEvents: events.filter((event) => (event.clientId || event.id) !== clientId),
|
|
|
+ deletedEventClientIds: [clientId]
|
|
|
});
|
|
|
- if (res.code === 0 && onEventAdded) {
|
|
|
- onEventAdded(session.id, res.data);
|
|
|
+ setEditor(null);
|
|
|
+ showToast('记录已删除');
|
|
|
+ } catch {
|
|
|
+ // Sync state already exposes the error.
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const openEvent = (event) => {
|
|
|
+ audioRef.current?.pause();
|
|
|
+ seek(event.relativeTimeMs);
|
|
|
+ if (event.eventType === 'PHOTO') {
|
|
|
+ const photoEvents = events.filter((candidate) => (
|
|
|
+ candidate.eventType === 'PHOTO'
|
|
|
+ && Number(candidate.relativeTimeMs) === Number(event.relativeTimeMs)
|
|
|
+ ));
|
|
|
+ setEditor({ type: 'photoDetail', events: photoEvents });
|
|
|
+ } else if (event.eventType === 'NOTE') {
|
|
|
+ setEditor({ type: 'noteDetail', event });
|
|
|
+ } else {
|
|
|
+ setEditor({ type: 'markerDetail', event });
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const saveNewPhotos = async (drafts, location) => {
|
|
|
+ setSaving(true);
|
|
|
+ const newEvents = drafts.map((draft) => createEvent({
|
|
|
+ relativeTimeMs: editor.timeMs,
|
|
|
+ eventType: 'PHOTO',
|
|
|
+ textContent: draft.note.trim(),
|
|
|
+ location
|
|
|
+ }));
|
|
|
+ try {
|
|
|
+ const prepared = [];
|
|
|
+ for (const draft of drafts) prepared.push(await preparePhotoFile(draft.file));
|
|
|
+ const totalBytes = prepared.reduce((sum, file) => sum + file.size, 0);
|
|
|
+ if (totalBytes > MAX_UPLOAD_BYTES && !window.confirm(`这些照片共 ${(totalBytes / 1024 / 1024).toFixed(1)} MB,仍要继续上传吗?`)) {
|
|
|
+ setSaving(false);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const synced = await persistSession({ nextEvents: [...events, ...newEvents] });
|
|
|
+ setSaving(true);
|
|
|
+ for (let index = 0; index < newEvents.length; index += 1) {
|
|
|
+ await uploadAsset(synced, {
|
|
|
+ clientId: photoAssetClientId(newEvents[index]),
|
|
|
+ kind: 'PHOTO',
|
|
|
+ file: prepared[index]
|
|
|
+ });
|
|
|
}
|
|
|
- setNewNoteText('');
|
|
|
- } catch (err) {
|
|
|
- window.alert(err?.message || '添加笔记失败');
|
|
|
+ const refreshed = await refreshDetail();
|
|
|
+ setSyncState('synced');
|
|
|
+ onSessionUpdated?.(refreshed);
|
|
|
+ setEditor(null);
|
|
|
+ showToast(`已添加 ${newEvents.length} 张照片`);
|
|
|
+ } catch (error) {
|
|
|
+ setSyncState('error');
|
|
|
+ setSyncError(errorMessage(error, '照片保存失败。'));
|
|
|
} finally {
|
|
|
- setIsSubmitting(false);
|
|
|
+ setSaving(false);
|
|
|
+ setSyncProgress(null);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const savePhotoDetails = async (notes, location) => {
|
|
|
+ const targets = new Set(editor.events.map((event) => event.clientId || event.id));
|
|
|
+ try {
|
|
|
+ await persistSession({
|
|
|
+ nextEvents: events.map((event) => {
|
|
|
+ if (!targets.has(event.clientId || event.id)) return event;
|
|
|
+ return applyLocation({ ...event, textContent: (notes[event.id] || '').trim() }, location);
|
|
|
+ })
|
|
|
+ });
|
|
|
+ setEditor(null);
|
|
|
+ showToast('照片信息已保存');
|
|
|
+ } catch {
|
|
|
+ // Sync state already exposes the error.
|
|
|
}
|
|
|
};
|
|
|
|
|
|
+ const finishContinuation = async (recordedBlob) => {
|
|
|
+ if (!detail) throw new Error('记录详情尚未加载完成。');
|
|
|
+ setSaving(true);
|
|
|
+ const originalDurationMs = durationMs;
|
|
|
+ const mergedBlob = audioBlob
|
|
|
+ ? await concatenateAudioBlobs(audioBlob, recordedBlob)
|
|
|
+ : recordedBlob;
|
|
|
+ if (mergedBlob.size > MAX_UPLOAD_BYTES && !window.confirm(`合并后的音频约 ${(mergedBlob.size / 1024 / 1024).toFixed(1)} MB,仍要同步吗?`)) {
|
|
|
+ setSaving(false);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const mergedAnalysis = await analyzeAudioBlob(mergedBlob);
|
|
|
+ const continuation = createEvent({
|
|
|
+ relativeTimeMs: originalDurationMs,
|
|
|
+ eventType: 'MARKER',
|
|
|
+ textContent: `续录时间:${new Intl.DateTimeFormat('zh-CN', {
|
|
|
+ year: 'numeric',
|
|
|
+ month: '2-digit',
|
|
|
+ day: '2-digit',
|
|
|
+ hour: '2-digit',
|
|
|
+ minute: '2-digit',
|
|
|
+ second: '2-digit',
|
|
|
+ hour12: false
|
|
|
+ }).format(new Date())}`
|
|
|
+ });
|
|
|
+ const endTime = new Date(new Date(detail.startTime).getTime() + mergedAnalysis.durationMs).toISOString();
|
|
|
+ try {
|
|
|
+ const synced = await persistSession({
|
|
|
+ nextEvents: [...events, continuation],
|
|
|
+ nextDurationMs: mergedAnalysis.durationMs,
|
|
|
+ endTime
|
|
|
+ });
|
|
|
+ setSaving(true);
|
|
|
+ const file = new File([mergedBlob], `${synced.clientId || synced.id}-audio.wav`, {
|
|
|
+ type: mergedBlob.type || 'audio/wav'
|
|
|
+ });
|
|
|
+ await uploadAsset(synced, {
|
|
|
+ clientId: `${synced.clientId || synced.id}-audio-web-${crypto.randomUUID()}`,
|
|
|
+ kind: 'AUDIO',
|
|
|
+ file
|
|
|
+ });
|
|
|
+ const refreshed = await refreshDetail();
|
|
|
+ setSyncState('synced');
|
|
|
+ onSessionUpdated?.(refreshed);
|
|
|
+ setEditor(null);
|
|
|
+ showToast('续录已合并并同步');
|
|
|
+ } catch (error) {
|
|
|
+ setSyncState('error');
|
|
|
+ setSyncError(errorMessage(error, '续录同步失败。'));
|
|
|
+ throw error;
|
|
|
+ } finally {
|
|
|
+ setSaving(false);
|
|
|
+ setSyncProgress(null);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const shareAudio = async () => {
|
|
|
+ if (!audioBlob || !audioAsset) {
|
|
|
+ showToast('当前没有可分享的音频');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const file = new File(
|
|
|
+ [audioBlob],
|
|
|
+ sanitizeAudioFileName(detail.title, fileExtension(audioAsset, audioBlob)),
|
|
|
+ { type: audioBlob.type || audioAsset.mimeType || 'audio/mp4' }
|
|
|
+ );
|
|
|
+ try {
|
|
|
+ if (navigator.share && navigator.canShare?.({ files: [file] })) {
|
|
|
+ await navigator.share({ files: [file], title: detail.title });
|
|
|
+ } else {
|
|
|
+ const url = URL.createObjectURL(file);
|
|
|
+ const link = document.createElement('a');
|
|
|
+ link.href = url;
|
|
|
+ link.download = file.name;
|
|
|
+ link.click();
|
|
|
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
|
+ showToast('音频已开始下载');
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ if (error.name !== 'AbortError') showToast(errorMessage(error, '音频分享失败。'));
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const togglePlayback = () => {
|
|
|
+ if (!audioRef.current || audioError) return;
|
|
|
+ if (audioRef.current.paused) {
|
|
|
+ audioRef.current.play().catch((error) => setAudioError(errorMessage(error, '音频无法播放。')));
|
|
|
+ } else {
|
|
|
+ audioRef.current.pause();
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleAudioTime = () => {
|
|
|
+ const nextTimeMs = (audioRef.current?.currentTime || 0) * 1000;
|
|
|
+ if (skipSilence) {
|
|
|
+ const silent = analysis.silentRanges.find((range) => (
|
|
|
+ nextTimeMs >= range.start * 1000
|
|
|
+ && nextTimeMs < range.end * 1000
|
|
|
+ && Math.abs(silenceJumpRef.current - range.end) > 0.01
|
|
|
+ ));
|
|
|
+ if (silent && audioRef.current) {
|
|
|
+ silenceJumpRef.current = silent.end;
|
|
|
+ audioRef.current.currentTime = Math.min(silent.end, audioRef.current.duration);
|
|
|
+ setCurrentTimeMs(silent.end * 1000);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ setCurrentTimeMs(nextTimeMs);
|
|
|
+ };
|
|
|
+
|
|
|
+ const syncLabel = syncState === 'syncing'
|
|
|
+ ? (syncProgress == null ? '正在同步' : `同步 ${syncProgress}%`)
|
|
|
+ : syncState === 'error'
|
|
|
+ ? '同步失败'
|
|
|
+ : syncState === 'unsynced'
|
|
|
+ ? '未同步'
|
|
|
+ : '已同步';
|
|
|
+
|
|
|
+ if (!session) return null;
|
|
|
+
|
|
|
return (
|
|
|
- <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in">
|
|
|
- <div className="w-full max-w-2xl business-card rounded-3xl overflow-hidden shadow-2xl flex flex-col max-h-[85vh]">
|
|
|
- {/* Modal Header */}
|
|
|
- <div className="p-5 border-b border-black/10 dark:border-white/10 flex items-start justify-between">
|
|
|
- <div>
|
|
|
- <div className="flex items-center space-x-2 mb-1">
|
|
|
- <span className="px-2 py-0.5 text-[10px] font-mono font-semibold text-slate-600 dark:text-slate-300 bg-black/5 dark:bg-white/10 rounded">
|
|
|
- 现场记录 Timeline
|
|
|
- </span>
|
|
|
- <span className="text-xs text-slate-400 font-mono">
|
|
|
- {new Date(session.startTime).toLocaleString('zh-CN')}
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- <h2 className="text-base font-bold text-slate-900 dark:text-white">{session.title}</h2>
|
|
|
- </div>
|
|
|
+ <div className="fixed inset-0 z-50 flex min-h-screen select-none flex-col overflow-hidden bg-[#F8F9FA] font-sans text-slate-900 dark:bg-slate-950 dark:text-slate-100">
|
|
|
+ <audio
|
|
|
+ ref={audioRef}
|
|
|
+ onTimeUpdate={handleAudioTime}
|
|
|
+ onPlay={() => setIsPlaying(true)}
|
|
|
+ onPause={() => setIsPlaying(false)}
|
|
|
+ onEnded={() => {
|
|
|
+ setIsPlaying(false);
|
|
|
+ setCurrentTimeMs(durationMs);
|
|
|
+ }}
|
|
|
+ />
|
|
|
|
|
|
+ <header className="z-20 flex h-14 shrink-0 items-center justify-between border-b border-black/10 dark:border-white/10 bg-white/92 dark:bg-slate-900/92 px-3 sm:px-5 backdrop-blur-md">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={onClose}
|
|
|
+ className="flex h-9 items-center gap-1.5 rounded-lg px-2 text-xs font-medium hover:bg-slate-100 dark:hover:bg-white/5"
|
|
|
+ >
|
|
|
+ <ArrowLeft className="h-4 w-4" />
|
|
|
+ <span className="hidden sm:inline">现场记录</span>
|
|
|
+ </button>
|
|
|
+ <div className="flex items-center gap-1">
|
|
|
+ <div className="relative">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => {
|
|
|
+ setShowSessionInfo((value) => !value);
|
|
|
+ setShowSyncInfo(false);
|
|
|
+ }}
|
|
|
+ className="flex h-9 w-9 items-center justify-center rounded-lg hover:bg-slate-100 dark:hover:bg-white/5"
|
|
|
+ aria-label="记录信息"
|
|
|
+ >
|
|
|
+ <Info className="h-4 w-4" />
|
|
|
+ </button>
|
|
|
+ {showSessionInfo && detail && (
|
|
|
+ <SessionInfoPopover
|
|
|
+ session={detail}
|
|
|
+ durationMs={durationMs}
|
|
|
+ photoCount={counts.photoCount}
|
|
|
+ noteCount={counts.noteCount}
|
|
|
+ onClose={() => setShowSessionInfo(false)}
|
|
|
+ onContinue={() => {
|
|
|
+ setShowSessionInfo(false);
|
|
|
+ setEditor({ type: 'continuation' });
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={shareAudio}
|
|
|
+ className="flex h-9 w-9 items-center justify-center rounded-lg hover:bg-slate-100 dark:hover:bg-white/5 disabled:opacity-35"
|
|
|
+ disabled={!audioBlob}
|
|
|
+ aria-label="分享音频"
|
|
|
+ >
|
|
|
+ <Share2 className="h-4 w-4" />
|
|
|
+ </button>
|
|
|
<button
|
|
|
+ type="button"
|
|
|
onClick={onClose}
|
|
|
- className="p-1 text-slate-400 hover:text-slate-900 dark:hover:text-white transition-colors"
|
|
|
+ className="ml-1 flex h-9 w-9 items-center justify-center rounded-lg hover:bg-slate-100 dark:hover:bg-white/5"
|
|
|
+ aria-label="关闭详情"
|
|
|
>
|
|
|
- <X className="w-5 h-5" />
|
|
|
+ <X className="h-4 w-4" />
|
|
|
</button>
|
|
|
</div>
|
|
|
+ </header>
|
|
|
|
|
|
- {/* Modal Content */}
|
|
|
- <div className="p-6 overflow-y-auto space-y-6 flex-1">
|
|
|
- {/* Audio Player Card */}
|
|
|
- <div className="p-4 rounded-2xl bg-black/[0.02] dark:bg-white/[0.03] border border-black/10 dark:border-white/10 space-y-3">
|
|
|
- <div className="flex items-center justify-between">
|
|
|
- <div className="flex items-center space-x-3">
|
|
|
+ {toast && (
|
|
|
+ <div className="fixed right-5 top-[68px] z-[110] flex items-center gap-2 rounded-lg border border-[#E6C687]/70 bg-white dark:bg-slate-900 px-3.5 py-2.5 text-xs shadow-xl">
|
|
|
+ <CheckCircle2 className="h-4 w-4 text-[#B88C3A] dark:text-[#E6C687]" />
|
|
|
+ {toast}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <main className="flex min-h-0 flex-1 flex-col overflow-hidden lg:flex-row">
|
|
|
+ <section className="overflow-y-auto border-r border-black/10 dark:border-white/10 bg-white/55 dark:bg-slate-900/40 px-4 py-5 sm:px-6 lg:w-[46%] lg:max-w-[760px] lg:shrink-0">
|
|
|
+ {loading && (
|
|
|
+ <div className="flex min-h-[50vh] items-center justify-center gap-2 text-sm text-slate-500">
|
|
|
+ <LoaderCircle className="h-4 w-4 animate-spin" />正在加载现场记录…
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {!loading && loadError && (
|
|
|
+ <div className="mx-auto mt-20 max-w-sm rounded-xl border border-red-500/20 bg-red-500/5 p-5 text-center">
|
|
|
+ <CircleAlert className="mx-auto mb-2 h-5 w-5 text-red-500" />
|
|
|
+ <p className="text-sm">{loadError}</p>
|
|
|
+ <button type="button" onClick={() => window.location.reload()} className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold">
|
|
|
+ <RefreshCw className="h-3.5 w-3.5" />重新加载
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {!loading && detail && (
|
|
|
+ <div className="mx-auto max-w-3xl space-y-6">
|
|
|
+ <div className="flex items-start justify-between gap-4">
|
|
|
<button
|
|
|
- onClick={() => setIsPlaying(!isPlaying)}
|
|
|
- className="w-10 h-10 rounded-full bg-slate-900 text-white dark:bg-white dark:text-slate-900 flex items-center justify-center shadow-md transition-transform active:scale-95"
|
|
|
+ type="button"
|
|
|
+ onClick={() => setEditor({ type: 'title' })}
|
|
|
+ className="min-w-0 text-left"
|
|
|
+ title="点击修改记录名称"
|
|
|
>
|
|
|
- {isPlaying ? <Pause className="w-5 h-5 fill-current" /> : <Play className="w-5 h-5 fill-current ml-0.5" />}
|
|
|
+ <h1 className="truncate text-[20px] font-semibold leading-tight tracking-tight hover:text-[#B88C3A] dark:hover:text-[#E6C687]">
|
|
|
+ {detail.title || '现场记录'}
|
|
|
+ </h1>
|
|
|
+ <p className="mt-1 text-[10px] text-slate-500">点击名称修改</p>
|
|
|
</button>
|
|
|
- <div>
|
|
|
- <div className="text-xs font-bold text-slate-900 dark:text-white">现场音频录像</div>
|
|
|
- <div className="text-[10px] font-mono text-slate-400">已加密备份至云端</div>
|
|
|
+ <div className="relative shrink-0">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => {
|
|
|
+ if (syncState === 'syncing') {
|
|
|
+ operationAbortRef.current?.abort();
|
|
|
+ } else if (syncState === 'error' || syncState === 'unsynced') {
|
|
|
+ persistSession().catch(() => {});
|
|
|
+ } else {
|
|
|
+ setShowSyncInfo((value) => !value);
|
|
|
+ setShowSessionInfo(false);
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ className={`flex h-8 items-center gap-1.5 rounded-lg border px-2.5 text-[10px] font-semibold ${
|
|
|
+ syncState === 'error'
|
|
|
+ ? 'border-red-500/30 text-red-600 dark:text-red-400'
|
|
|
+ : 'border-[#E6C687]/35 bg-[#E6C687]/10 text-[#9B742B] dark:text-[#E6C687]'
|
|
|
+ }`}
|
|
|
+ title={syncState === 'syncing' ? '点击取消同步' : syncError || '查看同步信息'}
|
|
|
+ >
|
|
|
+ {syncState === 'syncing'
|
|
|
+ ? <LoaderCircle className="h-3 w-3 animate-spin" />
|
|
|
+ : syncState === 'error'
|
|
|
+ ? <CircleAlert className="h-3 w-3" />
|
|
|
+ : <Cloud className="h-3 w-3" />}
|
|
|
+ {syncLabel}
|
|
|
+ </button>
|
|
|
+ {showSyncInfo && (
|
|
|
+ <SyncInfoPopover session={detail} onClose={() => setShowSyncInfo(false)} />
|
|
|
+ )}
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <div className="text-right font-mono text-xs font-semibold text-slate-900 dark:text-white">
|
|
|
- {formatDuration(playbackTime * 1000)} / {session.durationFormatted || formatDuration(session.durationMs)}
|
|
|
+ <div>
|
|
|
+ <MultiTrackTimeline
|
|
|
+ events={events}
|
|
|
+ currentTimeMs={currentTimeMs}
|
|
|
+ durationMs={durationMs}
|
|
|
+ waveformSamples={analysis.samples}
|
|
|
+ silentRanges={analysis.silentRanges}
|
|
|
+ onSeek={seek}
|
|
|
+ onEmptyTrackTap={(track, timeMs) => {
|
|
|
+ audioRef.current?.pause();
|
|
|
+ seek(timeMs);
|
|
|
+ setEditor({ type: track === 'photo' ? 'newPhoto' : 'newNote', timeMs });
|
|
|
+ }}
|
|
|
+ onEventTap={openEvent}
|
|
|
+ />
|
|
|
+ {audioLoading && (
|
|
|
+ <div className="mt-2 flex items-center gap-1.5 text-[10px] text-slate-500">
|
|
|
+ <LoaderCircle className="h-3 w-3 animate-spin" />正在读取真实音频波形与静音区间…
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {audioError && (
|
|
|
+ <div className="mt-2 flex items-start gap-1.5 text-[10px] text-amber-700 dark:text-amber-400">
|
|
|
+ <CircleAlert className="mt-px h-3 w-3 shrink-0" />{audioError}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
- {/* Waveform Bar */}
|
|
|
- <div className="space-y-1 pt-1">
|
|
|
- <div className="h-8 w-full flex items-center justify-between gap-1">
|
|
|
- {Array.from({ length: 40 }).map((_, i) => (
|
|
|
- <div
|
|
|
- key={i}
|
|
|
- style={{ height: `${Math.max(20, Math.sin(i * 0.4) * 80 + 30)}%` }}
|
|
|
- className={`w-1 rounded-full transition-all ${
|
|
|
- i < 16 ? 'bg-slate-900 dark:bg-white' : 'bg-slate-300 dark:bg-slate-700'
|
|
|
- }`}
|
|
|
- />
|
|
|
- ))}
|
|
|
+ <div className="rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-slate-900/70 p-4">
|
|
|
+ <div className="mb-3 flex items-center justify-between font-mono text-[11px]">
|
|
|
+ <span className="font-semibold">{formatDuration(currentTimeMs)}</span>
|
|
|
+ <span className="text-slate-500">{formatDuration(durationMs)}</span>
|
|
|
+ </div>
|
|
|
+ <input
|
|
|
+ type="range"
|
|
|
+ min="0"
|
|
|
+ max={Math.max(durationMs, 1)}
|
|
|
+ step="100"
|
|
|
+ value={Math.min(currentTimeMs, Math.max(durationMs, 1))}
|
|
|
+ onChange={(event) => seek(Number(event.target.value))}
|
|
|
+ className="h-1 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-slate-900 dark:bg-slate-700 dark:accent-white"
|
|
|
+ aria-label="播放进度"
|
|
|
+ />
|
|
|
+ <div className="mt-4 flex items-center justify-between">
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
+ <button type="button" onClick={() => seek(currentTimeMs - 10000)} className="flex h-9 w-9 items-center justify-center rounded-full hover:bg-slate-100 dark:hover:bg-white/5" aria-label="快退 10 秒">
|
|
|
+ <RotateCcw className="h-4 w-4" />
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={togglePlayback}
|
|
|
+ disabled={audioLoading || Boolean(audioError)}
|
|
|
+ className="flex h-11 w-11 items-center justify-center rounded-full bg-slate-900 text-white dark:bg-white dark:text-slate-950 disabled:opacity-30"
|
|
|
+ aria-label={isPlaying ? '暂停' : '播放'}
|
|
|
+ >
|
|
|
+ {isPlaying ? <Pause className="h-4 w-4 fill-current" /> : <Play className="ml-0.5 h-4 w-4 fill-current" />}
|
|
|
+ </button>
|
|
|
+ <button type="button" onClick={() => seek(currentTimeMs + 10000)} className="flex h-9 w-9 items-center justify-center rounded-full hover:bg-slate-100 dark:hover:bg-white/5" aria-label="快进 10 秒">
|
|
|
+ <RotateCw className="h-4 w-4" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <label className="flex cursor-pointer items-center gap-2 text-[11px] font-medium">
|
|
|
+ <span>跳过静音</span>
|
|
|
+ <input
|
|
|
+ type="checkbox"
|
|
|
+ checked={skipSilence}
|
|
|
+ onChange={(event) => setSkipSilence(event.target.checked)}
|
|
|
+ className="peer sr-only"
|
|
|
+ />
|
|
|
+ <span className="relative h-5 w-9 rounded-full bg-slate-300 transition-colors peer-checked:bg-slate-900 dark:bg-slate-700 dark:peer-checked:bg-white">
|
|
|
+ <span className="absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform peer-checked:translate-x-4 dark:peer-checked:bg-slate-950" />
|
|
|
+ </span>
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <input
|
|
|
- type="range"
|
|
|
- min="0"
|
|
|
- max={Math.floor((session.durationMs || 300000) / 1000)}
|
|
|
- value={playbackTime}
|
|
|
- onChange={(e) => setPlaybackTime(Number(e.target.value))}
|
|
|
- className="w-full h-1 bg-slate-200 dark:bg-slate-800 rounded appearance-none cursor-pointer accent-slate-900 dark:accent-white"
|
|
|
- />
|
|
|
- </div>
|
|
|
- </div>
|
|
|
|
|
|
- {/* Timeline Node List */}
|
|
|
- <div>
|
|
|
- <h3 className="text-[10px] font-mono font-semibold uppercase tracking-widest text-slate-400 mb-3">
|
|
|
- 事件时间轴 ({events.length})
|
|
|
- </h3>
|
|
|
-
|
|
|
- <div className="space-y-3">
|
|
|
- {events.length === 0 ? (
|
|
|
- <div className="text-xs font-mono text-slate-400 text-center py-4">
|
|
|
- 暂无附加事件,可使用下方表单添加随笔
|
|
|
+ <div>
|
|
|
+ <div className="mb-1 flex items-center justify-between">
|
|
|
+ <h2 className="text-[11px] font-semibold tracking-[0.12em] text-slate-500">现场事件</h2>
|
|
|
+ <span className="font-mono text-[10px] text-slate-400">{feedItems.length} 个时间点</span>
|
|
|
</div>
|
|
|
- ) : (
|
|
|
- events.map((ev) => (
|
|
|
- <div key={ev.id} className="p-3 rounded-xl bg-black/[0.02] dark:bg-white/[0.03] border border-black/5 dark:border-white/5 space-y-1">
|
|
|
- <div className="flex items-center justify-between text-xs">
|
|
|
- <span className="font-mono text-slate-900 dark:text-white font-semibold">
|
|
|
- {formatDuration(ev.relativeTimeMs)}
|
|
|
- </span>
|
|
|
- <span className="text-[10px] font-mono text-slate-400 uppercase">
|
|
|
- {ev.eventType}
|
|
|
- </span>
|
|
|
- </div>
|
|
|
-
|
|
|
- {ev.textContent && (
|
|
|
- <p className="text-xs text-slate-700 dark:text-slate-200 leading-relaxed">{ev.textContent}</p>
|
|
|
- )}
|
|
|
+ {feedItems.length ? (
|
|
|
+ <div>
|
|
|
+ {feedItems.map((item) => (
|
|
|
+ <EventCard
|
|
|
+ key={`${item.event.eventType}-${item.event.id}`}
|
|
|
+ item={item}
|
|
|
+ imageUrls={imageUrls}
|
|
|
+ onClick={() => openEvent(item.event)}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
</div>
|
|
|
- ))
|
|
|
- )}
|
|
|
+ ) : (
|
|
|
+ <div className="rounded-xl border border-dashed border-black/10 dark:border-white/10 px-5 py-10 text-center">
|
|
|
+ <FileText className="mx-auto mb-2 h-5 w-5 text-slate-300 dark:text-slate-700" />
|
|
|
+ <p className="text-sm font-medium">暂无现场事件</p>
|
|
|
+ <p className="mt-1 text-[11px] text-slate-500">点击时间轴的图片轨或笔记轨即可添加。</p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
+ )}
|
|
|
+ </section>
|
|
|
|
|
|
- {/* Add Web Note Form */}
|
|
|
- <form onSubmit={handleAddNote} className="p-3 rounded-2xl border border-black/10 dark:border-white/10 space-y-2">
|
|
|
- <div className="flex items-center space-x-1.5 text-xs font-medium text-slate-900 dark:text-white">
|
|
|
- <Plus className="w-3.5 h-3.5" />
|
|
|
- <span>添加网页附言笔记</span>
|
|
|
- </div>
|
|
|
- <div className="flex space-x-2">
|
|
|
- <input
|
|
|
- type="text"
|
|
|
- value={newNoteText}
|
|
|
- onChange={(e) => setNewNoteText(e.target.value)}
|
|
|
- placeholder="输入笔记标记内容..."
|
|
|
- className="flex-1 px-3 py-1.5 rounded-xl bg-black/[0.02] dark:bg-slate-900 border border-black/10 dark:border-white/10 text-xs text-slate-900 dark:text-white focus:outline-none"
|
|
|
- />
|
|
|
- <button
|
|
|
- type="submit"
|
|
|
- disabled={isSubmitting}
|
|
|
- className="px-3.5 py-1.5 rounded-xl bg-slate-900 text-white dark:bg-white dark:text-slate-900 text-xs font-medium"
|
|
|
- >
|
|
|
- 提交
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </form>
|
|
|
+ <div className="min-h-0 flex-1">
|
|
|
+ <AIReservedPanel />
|
|
|
</div>
|
|
|
+ </main>
|
|
|
|
|
|
- {/* Modal Footer */}
|
|
|
- <div className="p-4 border-t border-black/10 dark:border-white/10 flex justify-end">
|
|
|
- <button
|
|
|
- onClick={onClose}
|
|
|
- className="px-4 py-2 rounded-xl bg-slate-100 dark:bg-slate-800 text-xs font-semibold text-slate-800 dark:text-slate-200 border border-black/10 dark:border-white/10"
|
|
|
- >
|
|
|
- 完成关闭
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
+ {editor?.type === 'title' && detail && (
|
|
|
+ <TitleEditorDialog
|
|
|
+ initialValue={detail.title || ''}
|
|
|
+ saving={saving}
|
|
|
+ onClose={() => setEditor(null)}
|
|
|
+ onSave={async (title) => {
|
|
|
+ try {
|
|
|
+ await persistSession({ title });
|
|
|
+ setEditor(null);
|
|
|
+ showToast('记录名称已保存');
|
|
|
+ } catch {
|
|
|
+ // Sync state already exposes the error.
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ {editor?.type === 'newNote' && (
|
|
|
+ <NoteEditorDialog
|
|
|
+ timeMs={editor.timeMs}
|
|
|
+ saving={saving}
|
|
|
+ onClose={() => setEditor(null)}
|
|
|
+ onSave={async (text, location) => {
|
|
|
+ try {
|
|
|
+ await persistSession({
|
|
|
+ nextEvents: [...events, createEvent({
|
|
|
+ relativeTimeMs: editor.timeMs,
|
|
|
+ eventType: 'NOTE',
|
|
|
+ textContent: text,
|
|
|
+ location
|
|
|
+ })]
|
|
|
+ });
|
|
|
+ setEditor(null);
|
|
|
+ showToast('笔记已添加');
|
|
|
+ } catch {
|
|
|
+ // Sync state already exposes the error.
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ {editor?.type === 'noteDetail' && (
|
|
|
+ <NoteEditorDialog
|
|
|
+ title="查看笔记"
|
|
|
+ timeMs={editor.event.relativeTimeMs}
|
|
|
+ initialText={editor.event.textContent || ''}
|
|
|
+ initialLocation={makeLocation(editor.event)}
|
|
|
+ saving={saving}
|
|
|
+ onClose={() => setEditor(null)}
|
|
|
+ onDelete={() => deleteEvent(editor.event)}
|
|
|
+ onSave={async (text, location) => {
|
|
|
+ const targetId = editor.event.clientId || editor.event.id;
|
|
|
+ try {
|
|
|
+ await persistSession({
|
|
|
+ nextEvents: events.map((event) => (
|
|
|
+ (event.clientId || event.id) === targetId
|
|
|
+ ? applyLocation({ ...event, textContent: text }, location)
|
|
|
+ : event
|
|
|
+ ))
|
|
|
+ });
|
|
|
+ setEditor(null);
|
|
|
+ showToast('笔记已保存');
|
|
|
+ } catch {
|
|
|
+ // Sync state already exposes the error.
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ {editor?.type === 'newPhoto' && (
|
|
|
+ <PhotoEditorDialog
|
|
|
+ timeMs={editor.timeMs}
|
|
|
+ saving={saving}
|
|
|
+ onClose={() => setEditor(null)}
|
|
|
+ onSave={saveNewPhotos}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ {editor?.type === 'photoDetail' && (
|
|
|
+ <PhotoDetailDialog
|
|
|
+ events={editor.events}
|
|
|
+ imageUrls={imageUrls}
|
|
|
+ saving={saving}
|
|
|
+ onClose={() => setEditor(null)}
|
|
|
+ onDelete={deleteEvent}
|
|
|
+ onSave={savePhotoDetails}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ {editor?.type === 'markerDetail' && (
|
|
|
+ <MarkerDetailDialog
|
|
|
+ event={editor.event}
|
|
|
+ onClose={() => setEditor(null)}
|
|
|
+ onDelete={() => deleteEvent(editor.event)}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ {editor?.type === 'continuation' && (
|
|
|
+ <ContinuationRecorderDialog
|
|
|
+ saving={saving}
|
|
|
+ onClose={() => setEditor(null)}
|
|
|
+ onFinish={finishContinuation}
|
|
|
+ />
|
|
|
+ )}
|
|
|
</div>
|
|
|
);
|
|
|
};
|