Quellcode durchsuchen

refactor(frontend): redesign UI style to iOS aesthetic and simplify component layout

bob.yuxinyang vor 1 Monat
Ursprung
Commit
a1d0a6603e

+ 2 - 2
index.html

@@ -2,9 +2,9 @@
 <html lang="zh-CN" class="dark">
   <head>
     <meta charset="UTF-8" />
-    <link rel="icon" type="image/svg+xml" href="/spark-logo.svg" />
+    <link rel="icon" type="image/png" href="/app-icon.png" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>星痕 CelestiaTrace - 微光 Spark 智能数据中心</title>
+    <title>星痕 CelestiaTrace - 现场记录同步管理中心</title>
     <meta name="description" content="星痕 CelestiaTrace 网页端控制台,管理“微光 Spark”绑定的现场记录、存储同步与多端互通管理中心。" />
     <!-- Google Fonts -->
     <link rel="preconnect" href="https://fonts.googleapis.com">

+ 28 - 121
src/App.jsx

@@ -1,148 +1,55 @@
 import React, { useState } from 'react';
 import { AuthProvider, useAuth } from './context/AuthContext';
 import { ThemeProvider } from './context/ThemeContext';
-import { Navbar } from './components/Navbar';
-import { Sidebar, FloatingTabBar } from './components/Sidebar';
-import { DeviceBindModal } from './components/DeviceBindModal';
-import { RecordDetailModal } from './components/RecordDetailModal';
-import { AuthModal } from './components/AuthModal';
-import { EditProfileModal } from './components/EditProfileModal';
-import { ChangePasswordModal } from './components/ChangePasswordModal';
-import { SyncAuthPromptModal } from './components/SyncAuthPromptModal';
-import { Dashboard } from './pages/Dashboard';
-import { DevicesPage } from './pages/DevicesPage';
+import { LoginView } from './components/LoginView';
+import { Header } from './components/Header';
 import { RecordsPage } from './pages/RecordsPage';
-import { SyncSettingsPage } from './pages/SyncSettingsPage';
-import { ProfilePage } from './pages/ProfilePage';
+import { RecordDetailModal } from './components/RecordDetailModal';
 import { Sparkles } from 'lucide-react';
 
-const AppContent = () => {
+const MainContent = () => {
   const { user, loading } = useAuth();
-  const [activeTab, setActiveTab] = useState('dashboard');
-  const [isBindModalOpen, setIsBindModalOpen] = useState(false);
   const [selectedSession, setSelectedSession] = useState(null);
-
-  // Global Modals State
-  const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
-  const [authModalTab, setAuthModalTab] = useState('login');
-  const [isEditProfileOpen, setIsEditProfileOpen] = useState(false);
-  const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
-  const [isSyncAuthPromptOpen, setIsSyncAuthPromptOpen] = useState(false);
-
-  const openAuthModal = (tab = 'login') => {
-    setAuthModalTab(tab);
-    setIsAuthModalOpen(true);
-  };
+  const [recordsRefreshKey, setRecordsRefreshKey] = useState(0);
 
   if (loading) {
     return (
       <div className="min-h-screen business-grid-bg flex items-center justify-center">
         <div className="text-center space-y-3">
-          <div className="w-12 h-12 rounded-full bg-slate-900 dark:bg-white/10 border border-black/10 dark:border-white/20 flex items-center justify-center mx-auto animate-spin shadow-md">
-            <Sparkles className="w-6 h-6 text-slate-800 dark:text-white" />
-          </div>
-          <div className="text-xs text-slate-500 font-mono">加载星痕 CelestiaTrace 云引擎数据...</div>
+          <img
+            src="/app-icon.png"
+            alt="星痕 App Logo"
+            className="w-14 h-14 rounded-2xl object-cover border border-black/10 dark:border-white/20 mx-auto shadow-lg animate-pulse"
+          />
+          <div className="text-xs text-slate-500 font-mono">加载星痕 CelestiaTrace 云数据...</div>
         </div>
       </div>
     );
   }
 
+  if (!user) {
+    return <LoginView />;
+  }
+
   return (
     <div className="min-h-screen business-grid-bg text-slate-900 dark:text-slate-100 flex flex-col font-sans transition-colors">
-      <Navbar 
-        activeTab={activeTab} 
-        setActiveTab={setActiveTab}
-        onOpenAuthModal={() => openAuthModal('login')}
-      />
-
-      <div className="flex flex-1">
-        <Sidebar activeTab={activeTab} setActiveTab={setActiveTab} />
-
-        <main className="flex-1 p-4 sm:p-6 lg:p-8 max-w-6xl mx-auto w-full overflow-x-hidden">
-          {activeTab === 'dashboard' && (
-            <Dashboard
-              setActiveTab={setActiveTab}
-              onSelectSession={(sess) => setSelectedSession(sess)}
-              onOpenBindModal={() => {
-                if (!user) {
-                  openAuthModal('login');
-                } else {
-                  setIsBindModalOpen(true);
-                }
-              }}
-            />
-          )}
-
-          {activeTab === 'records' && (
-            <RecordsPage
-              onSelectSession={(sess) => setSelectedSession(sess)}
-            />
-          )}
-
-          {activeTab === 'devices' && (
-            <DevicesPage
-              onOpenBindModal={() => {
-                if (!user) {
-                  openAuthModal('login');
-                } else {
-                  setIsBindModalOpen(true);
-                }
-              }}
-            />
-          )}
-
-          {activeTab === 'sync' && (
-            <SyncSettingsPage />
-          )}
+      <Header />
 
-          {activeTab === 'profile' && (
-            <ProfilePage 
-              onOpenAuthModal={() => openAuthModal('login')}
-              onOpenEditProfileModal={() => setIsEditProfileOpen(true)}
-              onOpenChangePasswordModal={() => setIsChangePasswordOpen(true)}
-              onOpenSyncAuthPrompt={() => setIsSyncAuthPromptOpen(true)}
-              onOpenBindModal={() => setIsBindModalOpen(true)}
-            />
-          )}
-        </main>
-      </div>
-
-      {/* iOS Style Floating Tab Bar */}
-      <FloatingTabBar activeTab={activeTab} setActiveTab={setActiveTab} />
-
-      {/* Global Modals */}
-      <DeviceBindModal
-        isOpen={isBindModalOpen}
-        onClose={() => setIsBindModalOpen(false)}
-        onDeviceBound={(dev) => console.log('Device bound:', dev)}
-      />
+      <main className="flex-1 max-w-6xl mx-auto w-full px-4 sm:px-6 pt-6">
+        <RecordsPage
+          onSelectSession={(session) => setSelectedSession(session)}
+          refreshKey={recordsRefreshKey}
+        />
+      </main>
 
+      {/* Record Detail Modal */}
       <RecordDetailModal
         session={selectedSession}
         onClose={() => setSelectedSession(null)}
-        onEventAdded={(sessId, ev) => console.log('Event added:', ev)}
-      />
-
-      <AuthModal
-        isOpen={isAuthModalOpen}
-        onClose={() => setIsAuthModalOpen(false)}
-        initialTab={authModalTab}
-      />
-
-      <EditProfileModal
-        isOpen={isEditProfileOpen}
-        onClose={() => setIsEditProfileOpen(false)}
-      />
-
-      <ChangePasswordModal
-        isOpen={isChangePasswordOpen}
-        onClose={() => setIsChangePasswordOpen(false)}
-      />
-
-      <SyncAuthPromptModal
-        isOpen={isSyncAuthPromptOpen}
-        onClose={() => setIsSyncAuthPromptOpen(false)}
-        onLoginClick={() => openAuthModal('login')}
+        onSessionUpdated={(updatedSession) => {
+          setSelectedSession(updatedSession);
+          setRecordsRefreshKey((value) => value + 1);
+        }}
       />
     </div>
   );
@@ -152,7 +59,7 @@ export default function App() {
   return (
     <AuthProvider>
       <ThemeProvider>
-        <AppContent />
+        <MainContent />
       </ThemeProvider>
     </AuthProvider>
   );

+ 21 - 48
src/api.js

@@ -67,77 +67,50 @@ export const authAPI = {
     const res = await api.get('/user/profile');
     return res.data;
   },
-  updateProfile: async (data) => {
-    const res = await api.put('/user/profile', data);
-    return res.data;
-  },
-  changePassword: async (oldPassword, newPassword) => {
-    const res = await api.post('/user/change-password', { oldPassword, newPassword });
-    return res.data;
-  },
   logout: async () => {
     const res = await api.post('/auth/logout');
     return res.data;
   }
 };
 
-export const deviceAPI = {
-  getDevices: async () => {
-    const res = await api.get('/devices');
-    return res.data;
-  },
-  bindDevice: async (deviceData) => {
-    const res = await api.post('/devices', deviceData);
-    return res.data;
-  },
-  updateDevice: async (id, deviceData) => {
-    const res = await api.put(`/devices/${id}`, deviceData);
-    return res.data;
-  },
-  unbindDevice: async (id) => {
-    const res = await api.delete(`/devices/${id}`);
-    return res.data;
-  }
-};
-
 export const sessionAPI = {
   getSessions: async (search = '') => {
     const res = await api.get('/sessions', { params: { search } });
     return res.data;
   },
-  getSessionDetail: async (id) => {
-    const res = await api.get(`/sessions/${id}`);
+  getSessionDetail: async (id, signal) => {
+    const res = await api.get(`/sessions/${id}`, { signal });
     return res.data;
   },
-  createSession: async (sessionData) => {
-    const res = await api.post('/sessions', sessionData);
+  syncSession: async (sessionData, signal) => {
+    const res = await api.post('/sessions', sessionData, { signal });
     return res.data;
   },
-  addEvent: async (sessionId, eventData) => {
-    const res = await api.post(`/sessions/${sessionId}/events`, eventData);
+  addEvent: async (sessionId, eventData, signal) => {
+    const res = await api.post(`/sessions/${sessionId}/events`, eventData, { signal });
     return res.data;
   },
   deleteSession: async (id) => {
     const res = await api.delete(`/sessions/${id}`);
     return res.data;
-  }
-};
-
-export const syncAPI = {
-  getSettings: async () => {
-    const res = await api.get('/sync/settings');
-    return res.data;
-  },
-  updateSettings: async (settings) => {
-    const res = await api.put('/sync/settings', settings);
-    return res.data;
   },
-  getSummary: async () => {
-    const res = await api.get('/sync/summary');
+  uploadAsset: async (sessionId, { clientId, kind, file }, options = {}) => {
+    const form = new FormData();
+    form.append('clientId', clientId);
+    form.append('kind', kind);
+    form.append('file', file, file.name || `${kind.toLowerCase()}-${clientId}`);
+    const res = await api.post(`/sessions/${sessionId}/assets`, form, {
+      signal: options.signal,
+      onUploadProgress: options.onUploadProgress,
+      headers: { 'Content-Type': 'multipart/form-data' }
+    });
     return res.data;
   },
-  triggerManualSync: async () => {
-    const res = await api.post('/sync/trigger');
+  downloadAssetBlob: async (sessionId, assetId, signal) => {
+    const res = await api.get(`/sessions/${sessionId}/assets/${assetId}`, {
+      responseType: 'blob',
+      signal
+    });
     return res.data;
   }
 };

+ 0 - 209
src/components/AuthModal.jsx

@@ -1,209 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { X, User, Mail, Lock, ShieldCheck, Loader2 } from 'lucide-react';
-import { useAuth } from '../context/AuthContext';
-
-export const AuthModal = ({ isOpen, onClose, initialTab = 'login' }) => {
-  const { login, register } = useAuth();
-  const [selectedTab, setSelectedTab] = useState(initialTab); // 'login' | 'register'
-  const [usernameInput, setUsernameInput] = useState('');
-  const [identifierInput, setIdentifierInput] = useState('');
-  const [passwordInput, setPasswordInput] = useState('');
-  const [confirmPasswordInput, setConfirmPasswordInput] = useState('');
-  const [errorMessage, setErrorMessage] = useState('');
-  const [isProcessing, setIsProcessing] = useState(false);
-
-  useEffect(() => {
-    if (isOpen) {
-      setSelectedTab(initialTab);
-      setErrorMessage('');
-      setUsernameInput('');
-      setIdentifierInput('');
-      setPasswordInput('');
-      setConfirmPasswordInput('');
-    }
-  }, [isOpen, initialTab]);
-
-  if (!isOpen) return null;
-
-  const handleSubmit = async (e) => {
-    e.preventDefault();
-    setErrorMessage('');
-
-    if (selectedTab === 'login') {
-      if (!identifierInput.trim()) {
-        setErrorMessage('请输入手机号或邮箱');
-        return;
-      }
-      if (passwordInput.length < 6) {
-        setErrorMessage('密码不能少于 6 位');
-        return;
-      }
-
-      setIsProcessing(true);
-      const res = await login(identifierInput.trim(), passwordInput);
-      setIsProcessing(false);
-
-      if (res.success) {
-        onClose();
-      } else {
-        setErrorMessage(res.message || '登录失败');
-      }
-    } else {
-      const name = usernameInput.trim();
-      if (!name) {
-        setErrorMessage('请输入用户昵称');
-        return;
-      }
-      if (!identifierInput.trim()) {
-        setErrorMessage('请输入注册手机号或邮箱');
-        return;
-      }
-      if (passwordInput.length < 6) {
-        setErrorMessage('密码不能少于 6 位');
-        return;
-      }
-      if (passwordInput !== confirmPasswordInput) {
-        setErrorMessage('两次输入的密码不一致');
-        return;
-      }
-
-      setIsProcessing(true);
-      const res = await register(name, identifierInput.trim(), passwordInput);
-      setIsProcessing(false);
-
-      if (res.success) {
-        onClose();
-      } else {
-        setErrorMessage(res.message || '注册失败');
-      }
-    }
-  };
-
-  return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-md animate-fade-in">
-      <div className="relative w-full max-w-md bg-[#0B0C10] border border-white/15 rounded-3xl p-6 sm:p-8 shadow-2xl text-white space-y-6">
-        {/* Header */}
-        <div className="flex items-center justify-between">
-          <div className="w-8" />
-          <div className="text-center space-y-1">
-            <h2 className="text-xl font-bold tracking-tight">
-              {selectedTab === 'login' ? '账号登录' : '创建新账号'}
-            </h2>
-            <p className="text-xs text-slate-400">
-              {selectedTab === 'login'
-                ? '登录以同步录音并管理专属设备'
-                : '注册后可将本机记录安全同步至云端'}
-            </p>
-          </div>
-          <button
-            onClick={onClose}
-            className="p-1 rounded-full text-slate-400 hover:text-white hover:bg-white/10 transition-colors"
-          >
-            <X className="w-5 h-5" />
-          </button>
-        </div>
-
-        {/* Tab Picker */}
-        <div className="flex rounded-xl bg-white/[0.06] p-1 text-xs font-medium border border-white/10">
-          <button
-            type="button"
-            onClick={() => {
-              setSelectedTab('login');
-              setErrorMessage('');
-            }}
-            className={`flex-1 py-2 rounded-lg transition-all ${
-              selectedTab === 'login'
-                ? 'bg-white/15 text-white font-semibold shadow-sm'
-                : 'text-slate-400 hover:text-white'
-            }`}
-          >
-            登录
-          </button>
-          <button
-            type="button"
-            onClick={() => {
-              setSelectedTab('register');
-              setErrorMessage('');
-            }}
-            className={`flex-1 py-2 rounded-lg transition-all ${
-              selectedTab === 'register'
-                ? 'bg-white/15 text-white font-semibold shadow-sm'
-                : 'text-slate-400 hover:text-white'
-            }`}
-          >
-            注册账号
-          </button>
-        </div>
-
-        {/* Form Inputs */}
-        <form onSubmit={handleSubmit} className="space-y-4">
-          {selectedTab === 'register' && (
-            <div className="relative">
-              <User className="w-4 h-4 text-slate-400 absolute left-3.5 top-3.5" />
-              <input
-                type="text"
-                value={usernameInput}
-                onChange={(e) => setUsernameInput(e.target.value)}
-                placeholder="设置昵称 (如: 星痕探索者)"
-                className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/[0.04] border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-white/30 transition-all"
-              />
-            </div>
-          )}
-
-          <div className="relative">
-            <Mail className="w-4 h-4 text-slate-400 absolute left-3.5 top-3.5" />
-            <input
-              type="text"
-              value={identifierInput}
-              onChange={(e) => setIdentifierInput(e.target.value)}
-              placeholder="手机号或电子邮箱"
-              className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/[0.04] border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-white/30 transition-all"
-            />
-          </div>
-
-          <div className="relative">
-            <Lock className="w-4 h-4 text-slate-400 absolute left-3.5 top-3.5" />
-            <input
-              type="password"
-              value={passwordInput}
-              onChange={(e) => setPasswordInput(e.target.value)}
-              placeholder="密码 (至少 6 位)"
-              className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/[0.04] border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-white/30 transition-all"
-            />
-          </div>
-
-          {selectedTab === 'register' && (
-            <div className="relative">
-              <ShieldCheck className="w-4 h-4 text-slate-400 absolute left-3.5 top-3.5" />
-              <input
-                type="password"
-                value={confirmPasswordInput}
-                onChange={(e) => setConfirmPasswordInput(e.target.value)}
-                placeholder="再次确认密码"
-                className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/[0.04] border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-white/30 transition-all"
-              />
-            </div>
-          )}
-
-          {errorMessage && (
-            <div className="p-3 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-400 text-xs font-mono">
-              {errorMessage}
-            </div>
-          )}
-
-          <button
-            type="submit"
-            disabled={isProcessing}
-            className="w-full py-3 rounded-xl bg-white text-slate-950 font-semibold text-xs shadow-lg hover:bg-slate-100 transition-all flex items-center justify-center space-x-2 disabled:opacity-50"
-          >
-            {isProcessing ? (
-              <Loader2 className="w-4 h-4 animate-spin text-slate-900" />
-            ) : (
-              <span>{selectedTab === 'login' ? '安全登录' : '注册并自动登录'}</span>
-            )}
-          </button>
-        </form>
-      </div>
-    </div>
-  );
-};

+ 0 - 144
src/components/ChangePasswordModal.jsx

@@ -1,144 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { X, Lock, ShieldCheck, Loader2, Check } from 'lucide-react';
-import { useAuth } from '../context/AuthContext';
-
-export const ChangePasswordModal = ({ isOpen, onClose }) => {
-  const { changePassword } = useAuth();
-  const [oldPasswordInput, setOldPasswordInput] = useState('');
-  const [newPasswordInput, setNewPasswordInput] = useState('');
-  const [confirmPasswordInput, setConfirmPasswordInput] = useState('');
-  const [errorMessage, setErrorMessage] = useState('');
-  const [successToast, setSuccessToast] = useState('');
-  const [isProcessing, setIsProcessing] = useState(false);
-
-  useEffect(() => {
-    if (isOpen) {
-      setOldPasswordInput('');
-      setNewPasswordInput('');
-      setConfirmPasswordInput('');
-      setErrorMessage('');
-      setSuccessToast('');
-    }
-  }, [isOpen]);
-
-  if (!isOpen) return null;
-
-  const handleChangePassword = async (e) => {
-    e.preventDefault();
-    setErrorMessage('');
-    setSuccessToast('');
-
-    if (!oldPasswordInput) {
-      setErrorMessage('请输入原密码');
-      return;
-    }
-    if (newPasswordInput.length < 6) {
-      setErrorMessage('新密码长度不能少于 6 位');
-      return;
-    }
-    if (newPasswordInput !== confirmPasswordInput) {
-      setErrorMessage('两次输入的新密码不一致');
-      return;
-    }
-
-    setIsProcessing(true);
-    const res = await changePassword(oldPasswordInput, newPasswordInput);
-    setIsProcessing(false);
-
-    if (res.success) {
-      setSuccessToast('密码修改成功!请记牢新密码');
-      setTimeout(() => {
-        onClose();
-      }, 1000);
-    } else {
-      setErrorMessage(res.message || '原密码错误或修改失败');
-    }
-  };
-
-  return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-md animate-fade-in">
-      <div className="relative w-full max-w-md bg-[#0B0C10] border border-white/15 rounded-3xl p-6 sm:p-8 shadow-2xl text-white space-y-6">
-        {/* Header */}
-        <div className="flex items-center justify-between">
-          <h2 className="text-lg font-bold">修改密码</h2>
-          <button
-            onClick={onClose}
-            className="p-1 rounded-full text-slate-400 hover:text-white hover:bg-white/10 transition-colors"
-          >
-            <X className="w-5 h-5" />
-          </button>
-        </div>
-
-        {/* Inputs */}
-        <form onSubmit={handleChangePassword} className="space-y-4">
-          <div className="space-y-1.5">
-            <label className="text-xs text-slate-400">当前原密码</label>
-            <div className="relative">
-              <Lock className="w-4 h-4 text-slate-400 absolute left-3.5 top-3.5" />
-              <input
-                type="password"
-                value={oldPasswordInput}
-                onChange={(e) => setOldPasswordInput(e.target.value)}
-                placeholder="请输入当前账号原密码"
-                className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/[0.04] border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-white/30 transition-all"
-              />
-            </div>
-          </div>
-
-          <div className="space-y-1.5">
-            <label className="text-xs text-slate-400">新密码</label>
-            <div className="relative">
-              <Lock className="w-4 h-4 text-slate-400 absolute left-3.5 top-3.5" />
-              <input
-                type="password"
-                value={newPasswordInput}
-                onChange={(e) => setNewPasswordInput(e.target.value)}
-                placeholder="请输入新密码 (不少于 6 位)"
-                className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/[0.04] border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-white/30 transition-all"
-              />
-            </div>
-          </div>
-
-          <div className="space-y-1.5">
-            <label className="text-xs text-slate-400">确认新密码</label>
-            <div className="relative">
-              <ShieldCheck className="w-4 h-4 text-slate-400 absolute left-3.5 top-3.5" />
-              <input
-                type="password"
-                value={confirmPasswordInput}
-                onChange={(e) => setConfirmPasswordInput(e.target.value)}
-                placeholder="请再次输入新密码"
-                className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/[0.04] border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-white/30 transition-all"
-              />
-            </div>
-          </div>
-
-          {errorMessage && (
-            <div className="p-3 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-400 text-xs font-mono">
-              {errorMessage}
-            </div>
-          )}
-
-          {successToast && (
-            <div className="p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 text-xs flex items-center space-x-2">
-              <Check className="w-4 h-4" />
-              <span>{successToast}</span>
-            </div>
-          )}
-
-          <button
-            type="submit"
-            disabled={isProcessing}
-            className="w-full py-3 rounded-xl bg-white text-slate-950 font-semibold text-xs shadow-lg hover:bg-slate-100 transition-all flex items-center justify-center space-x-2 disabled:opacity-50"
-          >
-            {isProcessing ? (
-              <Loader2 className="w-4 h-4 animate-spin text-slate-900" />
-            ) : (
-              <span>确认修改密码</span>
-            )}
-          </button>
-        </form>
-      </div>
-    </div>
-  );
-};

+ 0 - 107
src/components/DeviceBindModal.jsx

@@ -1,107 +0,0 @@
-import React, { useState } from 'react';
-import { Radio, X, Wifi } from 'lucide-react';
-import { deviceAPI } from '../api';
-
-export const DeviceBindModal = ({ isOpen, onClose, onDeviceBound }) => {
-  const [name, setName] = useState('');
-  const [hardwareMAC, setHardwareMAC] = useState('');
-  const [firmwareVersion, setFirmwareVersion] = useState('');
-  const [peripheralUUID, setPeripheralUUID] = useState('');
-  const [errorMessage, setErrorMessage] = useState('');
-  const [loading, setLoading] = useState(false);
-
-  if (!isOpen) return null;
-
-  const handleSubmit = async (e) => {
-    e.preventDefault();
-    setLoading(true);
-    try {
-      const res = await deviceAPI.bindDevice({
-        name,
-        hardwareMAC,
-        firmwareVersion,
-        peripheralUUID
-      });
-      if (res.code === 0) {
-        onDeviceBound(res.data);
-        onClose();
-      }
-    } catch (err) {
-      setErrorMessage(err?.message || '设备绑定失败');
-    } finally {
-      setLoading(false);
-    }
-  };
-
-  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-md business-card p-6 rounded-3xl relative shadow-2xl">
-        <button
-          onClick={onClose}
-          className="absolute top-4 right-4 p-1 text-slate-400 hover:text-slate-900 dark:hover:text-white transition-colors"
-        >
-          <X className="w-5 h-5" />
-        </button>
-
-        <div className="flex items-center space-x-3 mb-5">
-          <div className="p-2 rounded-xl bg-black/5 dark:bg-white/10 border border-black/10 dark:border-white/10 text-slate-900 dark:text-white">
-            <Radio className="w-5 h-5" />
-          </div>
-          <div>
-            <h3 className="text-sm font-bold text-slate-900 dark:text-white">绑定“微光 Spark”新设备</h3>
-            <p className="text-xs text-slate-400">登记与账户关联的 BLE 蓝牙随身麦克风</p>
-          </div>
-        </div>
-
-        <form onSubmit={handleSubmit} className="space-y-4">
-          {errorMessage && <div className="text-xs text-rose-500 font-mono">{errorMessage}</div>}
-          <div>
-            <label className="block text-[11px] font-mono text-slate-400 mb-1">iOS 外围设备 UUID</label>
-            <input type="text" value={peripheralUUID} onChange={(e) => setPeripheralUUID(e.target.value)} required className="w-full px-3 py-2 rounded-xl bg-black/[0.02] dark:bg-slate-900 border border-black/10 dark:border-white/10 text-xs font-mono text-slate-900 dark:text-white focus:outline-none" placeholder="填入 iOS 设备的 UUID..." />
-          </div>
-
-          <div>
-            <label className="block text-[11px] font-mono text-slate-400 mb-1">自定义名称</label>
-            <input
-              type="text"
-              value={name}
-              onChange={(e) => setName(e.target.value)}
-              required
-              className="w-full px-3 py-2 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"
-              placeholder="例如:Spark Pro 便携录音笔"
-            />
-          </div>
-
-          <div>
-            <label className="block text-[11px] font-mono text-slate-400 mb-1">硬件 MAC 地址</label>
-            <input
-              type="text"
-              value={hardwareMAC}
-              onChange={(e) => setHardwareMAC(e.target.value)}
-              required
-              className="w-full px-3 py-2 rounded-xl bg-black/[0.02] dark:bg-slate-900 border border-black/10 dark:border-white/10 text-xs font-mono text-slate-900 dark:text-white focus:outline-none"
-              placeholder="7C:D1:C3:92:44:FA"
-            />
-          </div>
-
-          <div className="flex items-center space-x-3 pt-2">
-            <button
-              type="button"
-              onClick={onClose}
-              className="w-1/2 py-2 rounded-xl border border-black/10 dark:border-white/10 text-xs font-semibold text-slate-500"
-            >
-              取消
-            </button>
-            <button
-              type="submit"
-              disabled={loading}
-              className="w-1/2 py-2 rounded-xl bg-slate-900 text-white dark:bg-white dark:text-slate-900 text-xs font-semibold"
-            >
-              {loading ? '绑定中...' : '确认绑定'}
-            </button>
-          </div>
-        </form>
-      </div>
-    </div>
-  );
-};

+ 0 - 135
src/components/EditProfileModal.jsx

@@ -1,135 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { X, User, Mail, Phone, Loader2, Check } from 'lucide-react';
-import { useAuth } from '../context/AuthContext';
-
-export const EditProfileModal = ({ isOpen, onClose }) => {
-  const { user, updateProfile } = useAuth();
-  const [usernameInput, setUsernameInput] = useState('');
-  const [emailInput, setEmailInput] = useState('');
-  const [phoneInput, setPhoneInput] = useState('');
-  const [errorMessage, setErrorMessage] = useState('');
-  const [successToast, setSuccessToast] = useState('');
-  const [isProcessing, setIsProcessing] = useState(false);
-
-  useEffect(() => {
-    if (isOpen && user) {
-      setUsernameInput(user.username || '');
-      setEmailInput(user.email || '');
-      setPhoneInput(user.phoneNumber || '');
-      setErrorMessage('');
-      setSuccessToast('');
-    }
-  }, [isOpen, user]);
-
-  if (!isOpen) return null;
-
-  const handleSave = async (e) => {
-    e.preventDefault();
-    setErrorMessage('');
-    setSuccessToast('');
-
-    setIsProcessing(true);
-    const res = await updateProfile({
-      username: usernameInput.trim(),
-      email: emailInput.trim(),
-      phoneNumber: phoneInput.trim(),
-    });
-    setIsProcessing(false);
-
-    if (res.success) {
-      setSuccessToast('个人资料更新成功!');
-      setTimeout(() => {
-        onClose();
-      }, 800);
-    } else {
-      setErrorMessage(res.message || '保存修改失败');
-    }
-  };
-
-  return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-md animate-fade-in">
-      <div className="relative w-full max-w-md bg-[#0B0C10] border border-white/15 rounded-3xl p-6 sm:p-8 shadow-2xl text-white space-y-6">
-        {/* Header */}
-        <div className="flex items-center justify-between">
-          <h2 className="text-lg font-bold">编辑个人资料</h2>
-          <button
-            onClick={onClose}
-            className="p-1 rounded-full text-slate-400 hover:text-white hover:bg-white/10 transition-colors"
-          >
-            <X className="w-5 h-5" />
-          </button>
-        </div>
-
-        {/* Inputs */}
-        <form onSubmit={handleSave} className="space-y-4">
-          <div className="space-y-1.5">
-            <label className="text-xs text-slate-400">用户昵称</label>
-            <div className="relative">
-              <User className="w-4 h-4 text-slate-400 absolute left-3.5 top-3.5" />
-              <input
-                type="text"
-                value={usernameInput}
-                onChange={(e) => setUsernameInput(e.target.value)}
-                placeholder="请输入新的用户昵称"
-                className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/[0.04] border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-white/30 transition-all"
-              />
-            </div>
-          </div>
-
-          <div className="space-y-1.5">
-            <label className="text-xs text-slate-400">电子邮箱</label>
-            <div className="relative">
-              <Mail className="w-4 h-4 text-slate-400 absolute left-3.5 top-3.5" />
-              <input
-                type="email"
-                value={emailInput}
-                onChange={(e) => setEmailInput(e.target.value)}
-                placeholder="绑定/修改电子邮箱"
-                className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/[0.04] border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-white/30 transition-all"
-              />
-            </div>
-          </div>
-
-          <div className="space-y-1.5">
-            <label className="text-xs text-slate-400">手机号码</label>
-            <div className="relative">
-              <Phone className="w-4 h-4 text-slate-400 absolute left-3.5 top-3.5" />
-              <input
-                type="text"
-                value={phoneInput}
-                onChange={(e) => setPhoneInput(e.target.value)}
-                placeholder="绑定/修改手机号码"
-                className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/[0.04] border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-white/30 transition-all"
-              />
-            </div>
-          </div>
-
-          {errorMessage && (
-            <div className="p-3 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-400 text-xs font-mono">
-              {errorMessage}
-            </div>
-          )}
-
-          {successToast && (
-            <div className="p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 text-xs flex items-center space-x-2">
-              <Check className="w-4 h-4" />
-              <span>{successToast}</span>
-            </div>
-          )}
-
-          <button
-            type="submit"
-            disabled={isProcessing}
-            className="w-full py-3 rounded-xl bg-white text-slate-950 font-semibold text-xs shadow-lg hover:bg-slate-100 transition-all flex items-center justify-center space-x-2 disabled:opacity-50"
-          >
-            {isProcessing ? (
-              <Loader2 className="w-4 h-4 animate-spin text-slate-900" />
-            ) : (
-              <span>保存修改</span>
-            )}
-          </button>
-        </form>
-      </div>
-    </div>
-  );
-};

+ 52 - 0
src/components/Header.jsx

@@ -0,0 +1,52 @@
+import React from 'react';
+import { useAuth } from '../context/AuthContext';
+import { Sparkles, LogOut, User } from 'lucide-react';
+
+export const Header = () => {
+  const { user, logout } = useAuth();
+
+  return (
+    <header className="sticky top-0 z-40 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md border-b border-black/10 dark:border-white/10 transition-colors">
+      <div className="max-w-6xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
+        {/* Brand */}
+        <div className="flex items-center space-x-3">
+          <img
+            src="/app-icon.png"
+            alt="星痕 CelestiaTrace Logo"
+            className="w-9 h-9 rounded-xl object-cover shadow-sm border border-black/10 dark:border-white/10"
+          />
+          <div>
+            <h1 className="text-base font-bold tracking-tight text-slate-900 dark:text-white">
+              星痕 CelestiaTrace
+            </h1>
+            <p className="text-[10px] text-slate-500 font-mono flex items-center gap-1">
+              <span className="w-1.5 h-1.5 rounded-full bg-[#E6C687]"></span>
+              已同步现场记录
+            </p>
+          </div>
+        </div>
+
+        {/* User Status & Actions */}
+        {user && (
+          <div className="flex items-center space-x-4">
+            <div className="flex items-center space-x-2 px-3 py-1.5 rounded-full bg-slate-100 dark:bg-slate-800 border border-black/5 dark:border-white/5">
+              <User className="w-3.5 h-3.5 text-slate-500" />
+              <span className="text-xs font-semibold text-slate-800 dark:text-slate-200">
+                {user.username || user.email || user.phoneNumber || '已登录用户'}
+              </span>
+            </div>
+
+            <button
+              onClick={logout}
+              className="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-medium text-slate-600 hover:text-rose-600 dark:text-slate-400 dark:hover:text-rose-400 bg-slate-100 hover:bg-rose-50 dark:bg-slate-800 dark:hover:bg-rose-950/30 transition-colors cursor-pointer"
+              title="退出登录"
+            >
+              <LogOut className="w-3.5 h-3.5" />
+              <span>退出登录</span>
+            </button>
+          </div>
+        )}
+      </div>
+    </header>
+  );
+};

+ 166 - 0
src/components/LoginView.jsx

@@ -0,0 +1,166 @@
+import React, { useState } from 'react';
+import { useAuth } from '../context/AuthContext';
+import { Sparkles, ArrowRight, Lock, User, Mail, ShieldCheck } from 'lucide-react';
+
+export const LoginView = () => {
+  const { login, register } = useAuth();
+  const [isLoginTab, setIsLoginTab] = useState(true);
+  const [identifier, setIdentifier] = useState('');
+  const [password, setPassword] = useState('');
+  const [username, setUsername] = useState('');
+  const [loading, setLoading] = useState(false);
+  const [errorMsg, setErrorMsg] = useState('');
+
+  const handleSubmit = async (e) => {
+    e.preventDefault();
+    setErrorMsg('');
+    setLoading(true);
+
+    try {
+      if (isLoginTab) {
+        const res = await login(identifier, password);
+        if (!res.success) {
+          setErrorMsg(res.message || '登录失败,请检查账号与密码');
+        }
+      } else {
+        if (!username.trim()) {
+          setErrorMsg('请输入用户名');
+          setLoading(false);
+          return;
+        }
+        const res = await register(username, identifier, password);
+        if (!res.success) {
+          setErrorMsg(res.message || '注册失败,请稍后重试');
+        }
+      }
+    } catch (err) {
+      setErrorMsg('网络请求异常,请稍后重试');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <div className="min-h-screen flex items-center justify-center p-4 business-grid-bg">
+      <div className="w-full max-w-md bg-white/90 dark:bg-slate-900/90 backdrop-blur-xl border border-black/10 dark:border-white/10 rounded-3xl p-8 shadow-2xl space-y-6 transition-all duration-300">
+        {/* Header Branding */}
+        <div className="text-center space-y-2">
+          <img
+            src="/app-icon.png"
+            alt="星痕 App Logo"
+            className="w-16 h-16 rounded-2xl object-cover border border-black/10 dark:border-white/20 mx-auto shadow-lg transform hover:scale-105 transition-transform"
+          />
+          <h1 className="text-2xl font-bold tracking-tight text-slate-900 dark:text-white">
+            星痕 CelestiaTrace
+          </h1>
+          <p className="text-xs text-slate-500 dark:text-slate-400">
+            云端现场记录极简同步平台
+          </p>
+        </div>
+
+        {/* Tab Switcher */}
+        <div className="flex bg-slate-100 dark:bg-slate-800/60 p-1 rounded-xl border border-black/5 dark:border-white/5">
+          <button
+            type="button"
+            onClick={() => { setIsLoginTab(true); setErrorMsg(''); }}
+            className={`flex-1 py-2 text-xs font-semibold rounded-lg transition-all ${
+              isLoginTab
+                ? 'bg-white dark:bg-slate-700 text-slate-900 dark:text-white shadow-sm'
+                : 'text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'
+            }`}
+          >
+            账号登录
+          </button>
+          <button
+            type="button"
+            onClick={() => { setIsLoginTab(false); setErrorMsg(''); }}
+            className={`flex-1 py-2 text-xs font-semibold rounded-lg transition-all ${
+              !isLoginTab
+                ? 'bg-white dark:bg-slate-700 text-slate-900 dark:text-white shadow-sm'
+                : 'text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'
+            }`}
+          >
+            新用户注册
+          </button>
+        </div>
+
+        {/* Error Alert */}
+        {errorMsg && (
+          <div className="p-3 text-xs rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-600 dark:text-rose-400 text-center font-medium animate-fade-in">
+            {errorMsg}
+          </div>
+        )}
+
+        {/* Form */}
+        <form onSubmit={handleSubmit} className="space-y-4">
+          {!isLoginTab && (
+            <div className="space-y-1">
+              <label className="text-xs font-medium text-slate-600 dark:text-slate-300">
+                用户名
+              </label>
+              <div className="relative">
+                <User className="w-4 h-4 text-slate-400 absolute left-3 top-3" />
+                <input
+                  type="text"
+                  required
+                  value={username}
+                  onChange={(e) => setUsername(e.target.value)}
+                  placeholder="例如: 张工"
+                  className="w-full pl-9 pr-4 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 border border-black/10 dark:border-white/10 text-slate-900 dark:text-white text-xs focus:outline-none focus:border-slate-800 dark:focus:border-slate-200 transition-colors"
+                />
+              </div>
+            </div>
+          )}
+
+          <div className="space-y-1">
+            <label className="text-xs font-medium text-slate-600 dark:text-slate-300">
+              邮箱或手机号
+            </label>
+            <div className="relative">
+              <Mail className="w-4 h-4 text-slate-400 absolute left-3 top-3" />
+              <input
+                type="text"
+                required
+                value={identifier}
+                onChange={(e) => setIdentifier(e.target.value)}
+                placeholder="user@example.com 或 13800138000"
+                className="w-full pl-9 pr-4 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 border border-black/10 dark:border-white/10 text-slate-900 dark:text-white text-xs focus:outline-none focus:border-slate-800 dark:focus:border-slate-200 transition-colors"
+              />
+            </div>
+          </div>
+
+          <div className="space-y-1">
+            <label className="text-xs font-medium text-slate-600 dark:text-slate-300">
+              密码
+            </label>
+            <div className="relative">
+              <Lock className="w-4 h-4 text-slate-400 absolute left-3 top-3" />
+              <input
+                type="password"
+                required
+                value={password}
+                onChange={(e) => setPassword(e.target.value)}
+                placeholder="输入密码"
+                className="w-full pl-9 pr-4 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 border border-black/10 dark:border-white/10 text-slate-900 dark:text-white text-xs focus:outline-none focus:border-slate-800 dark:focus:border-slate-200 transition-colors"
+              />
+            </div>
+          </div>
+
+          <button
+            type="submit"
+            disabled={loading}
+            className="w-full mt-2 py-3 rounded-xl bg-slate-900 hover:bg-slate-800 dark:bg-white dark:hover:bg-slate-100 text-white dark:text-slate-900 text-xs font-semibold shadow-md flex items-center justify-center space-x-2 disabled:opacity-50 transition-all cursor-pointer"
+          >
+            <span>{loading ? '处理中...' : isLoginTab ? '安全登录' : '立即注册'}</span>
+            {!loading && <ArrowRight className="w-4 h-4" />}
+          </button>
+        </form>
+
+        <div className="flex items-center justify-center space-x-1.5 text-[11px] text-slate-400 pt-2">
+          <ShieldCheck className="w-3.5 h-3.5" />
+          <span>端到端同步加密与身份认证保驾护航</span>
+        </div>
+      </div>
+    </div>
+  );
+};

+ 0 - 125
src/components/Navbar.jsx

@@ -1,125 +0,0 @@
-import React, { useState } from 'react';
-import { RefreshCw, User, LogOut, CheckCircle2 } from 'lucide-react';
-import { useAuth } from '../context/AuthContext';
-import { syncAPI } from '../api';
-
-export const Navbar = ({ activeTab, setActiveTab, onOpenAuthModal }) => {
-  const { user, logout } = useAuth();
-  const [syncing, setSyncing] = useState(false);
-  const [syncToast, setSyncToast] = useState(false);
-
-  // Format current date matching iOS app format
-  const currentDateFormatted = (() => {
-    const d = new Date();
-    const year = d.getFullYear();
-    const month = d.getMonth() + 1;
-    const date = d.getDate();
-    const weekDays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
-    const weekDay = weekDays[d.getDay()];
-    return `${year}年${month}月${date}日 ${weekDay}`;
-  })();
-
-  const handleSyncNow = async () => {
-    if (!user) {
-      if (onOpenAuthModal) onOpenAuthModal();
-      return;
-    }
-    setSyncing(true);
-    try {
-      await syncAPI.triggerManualSync();
-      setSyncToast(true);
-      setTimeout(() => setSyncToast(false), 3000);
-    } catch (e) {
-      console.warn("Manual sync failed:", e);
-    } finally {
-      setSyncing(false);
-    }
-  };
-
-  return (
-    <header className="sticky top-0 z-40 w-full bg-[#F8F9FA]/90 backdrop-blur-md border-b border-black/5 px-4 sm:px-8 py-3.5 flex items-center justify-between">
-      {/* Left Title Section matching iOS TopBar screenshot */}
-      <div 
-        className="flex items-center space-x-3.5 cursor-pointer select-none"
-        onClick={() => setActiveTab('dashboard')}
-      >
-        <div>
-          <div className="flex items-center space-x-2">
-            <h1 className="text-xs font-bold font-mono tracking-widest-tech uppercase text-[#1D1D1F] leading-tight">
-              星痕 现场 记录
-            </h1>
-          </div>
-          <p className="text-[13px] text-[#8E8E93] font-sans leading-tight mt-0.5">
-            {currentDateFormatted}
-          </p>
-        </div>
-      </div>
-
-      {/* Center Status Indicator & Sync Actions */}
-      <div className="flex items-center space-x-3">
-        {/* iOS Ready Status Badge matching screenshot */}
-        <div className="flex items-center space-x-1.5 px-2.5 py-1 rounded-md bg-white border border-black/15 text-[10px] font-mono font-semibold text-[#8E8E93] shadow-2xs">
-          <span className="w-1.5 h-1.5 rounded-full bg-[#34C759]"></span>
-          <span>就绪</span>
-        </div>
-
-        {syncToast && (
-          <div className="hidden sm:flex items-center space-x-1.5 px-3 py-1 rounded-md bg-emerald-50 border border-emerald-200 text-emerald-700 text-xs animate-fade-in">
-            <CheckCircle2 className="w-3.5 h-3.5" />
-            <span>已同步至云端</span>
-          </div>
-        )}
-
-        <button
-          onClick={handleSyncNow}
-          disabled={syncing}
-          className="flex items-center space-x-2 px-3 py-1.5 text-xs font-medium text-slate-700 bg-white hover:bg-slate-50 border border-black/10 rounded-lg shadow-2xs transition-all"
-        >
-          <RefreshCw className={`w-3.5 h-3.5 text-slate-600 ${syncing ? 'animate-spin' : ''}`} />
-          <span className="hidden sm:inline">{syncing ? '同步中...' : '手动同步'}</span>
-        </button>
-      </div>
-
-      {/* Right Controls: User Card / Auth button */}
-      <div className="flex items-center space-x-2">
-        {user ? (
-          <div className="flex items-center space-x-2 bg-white px-2.5 py-1.5 rounded-xl border border-black/10 shadow-2xs">
-            <img
-              src={user?.avatarURL || "/spark-logo.svg"}
-              alt="Avatar"
-              className="w-7 h-7 rounded-lg object-cover ring-1 ring-black/10"
-            />
-            <div className="hidden sm:block text-left">
-              <div className="text-xs font-semibold text-[#1D1D1F] leading-tight">{user.username}</div>
-              <div className="text-[10px] text-[#8E8E93] leading-tight font-mono">{user.phoneNumber || user.email || `ID: ${String(user.id || '').substring(0, 6)}`}</div>
-            </div>
-
-            <button
-              onClick={() => setActiveTab('profile')}
-              className="p-1 text-slate-500 hover:text-slate-900 transition-colors"
-              title="个人主页"
-            >
-              <User className="w-4 h-4" />
-            </button>
-            
-            <button
-              onClick={() => logout()}
-              className="p-1 text-slate-500 hover:text-rose-600 transition-colors"
-              title="退出登录"
-            >
-              <LogOut className="w-4 h-4" />
-            </button>
-          </div>
-        ) : (
-          <button
-            onClick={onOpenAuthModal}
-            className="flex items-center space-x-2 bg-slate-900 text-white dark:bg-white dark:text-slate-950 px-3.5 py-1.5 rounded-xl text-xs font-semibold shadow-2xs hover:opacity-90 transition-all"
-          >
-            <User className="w-3.5 h-3.5" />
-            <span>登录 / 注册</span>
-          </button>
-        )}
-      </div>
-    </header>
-  );
-};

+ 993 - 143
src/components/RecordDetailModal.jsx

@@ -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>
   );
 };

+ 0 - 109
src/components/Sidebar.jsx

@@ -1,109 +0,0 @@
-import React from 'react';
-import { Home, Clock, Settings, User, Radio, Cloud } from 'lucide-react';
-
-export const Sidebar = ({ activeTab, setActiveTab }) => {
-  const menuItems = [
-    { id: 'dashboard', label: '首页', icon: Home, subtitle: '新建与最近记录' },
-    { id: 'records', label: '历史记录', icon: Clock, subtitle: '多模态时间轴' },
-    { id: 'devices', label: '硬件设备', icon: Radio, subtitle: '微光 Spark BLE' },
-    { id: 'sync', label: '云端同步', icon: Cloud, subtitle: '数据库隔离' },
-    { id: 'profile', label: '个人中心', icon: User, subtitle: '账号与设置' },
-  ];
-
-  return (
-    <aside className="w-56 bg-white border-r border-black/10 flex flex-col justify-between shrink-0 hidden md:flex min-h-[calc(100vh-61px)]">
-      <div className="p-4 space-y-6">
-        <div>
-          <div className="px-3 mb-3 text-[10px] font-semibold text-slate-400 uppercase tracking-widest font-mono">
-            星痕导航
-          </div>
-
-          <nav className="space-y-1">
-            {menuItems.map((item) => {
-              const Icon = item.icon;
-              const isActive = activeTab === item.id;
-              return (
-                <button
-                  key={item.id}
-                  onClick={() => setActiveTab(item.id)}
-                  className={`w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all ${
-                    isActive
-                      ? 'bg-[#EAEAEA] text-[#1D1D1F] font-semibold shadow-2xs'
-                      : 'text-[#6E6E73] hover:text-[#1D1D1F] hover:bg-slate-100/60'
-                  }`}
-                >
-                  <div className="flex items-center space-x-3">
-                    <Icon className={`w-4 h-4 ${isActive ? 'text-[#1D1D1F]' : 'text-slate-500'}`} />
-                    <span>{item.label}</span>
-                  </div>
-                  {isActive && (
-                    <span className="w-1.5 h-1.5 rounded-full bg-[#1D1D1F]"></span>
-                  )}
-                </button>
-              );
-            })}
-          </nav>
-        </div>
-
-        {/* Hardware Status Promo Card */}
-        <div className="p-4 rounded-2xl bg-[#F8F9FA] border border-black/10 relative overflow-hidden">
-          <div className="flex items-center space-x-2 text-[#1D1D1F] text-xs font-semibold mb-1">
-            <Radio className="w-4 h-4 text-emerald-600" />
-            <span>微光 Spark 录音硬件</span>
-          </div>
-          <p className="text-[11px] text-[#6E6E73] leading-relaxed mb-2">
-            手机 App 通过 BLE 蓝牙无缝连接“微光 Spark”,实时同步现场记录与事件标注。
-          </p>
-          <div className="text-[10px] font-mono text-slate-500 flex items-center space-x-1.5">
-            <span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
-            <span>服务器已隔离多账号数据</span>
-          </div>
-        </div>
-      </div>
-
-      {/* Footer Signature */}
-      <div className="p-4 border-t border-black/10 text-[11px] text-slate-400 font-mono space-y-1">
-        <div className="flex justify-between items-center">
-          <span>CelestiaTrace Engine</span>
-          <span className="text-slate-700 font-semibold">v2.0</span>
-        </div>
-        <div className="text-[10px] text-slate-400">
-          纯粹星辰 · 倾听,然后深刻。
-        </div>
-      </div>
-    </aside>
-  );
-};
-
-// Floating iOS Bottom TabBar component matching the screenshot exactly
-export const FloatingTabBar = ({ activeTab, setActiveTab }) => {
-  const tabs = [
-    { id: 'dashboard', label: '首页', icon: Home },
-    { id: 'records', label: '历史记录', icon: Clock },
-    { id: 'devices', label: '设置', icon: Settings },
-    { id: 'profile', label: '个人中心', icon: User },
-  ];
-
-  return (
-    <div className="fixed bottom-5 left-1/2 -translate-x-1/2 z-50 px-3 py-2 rounded-full bg-white/95 backdrop-blur-xl border border-black/10 shadow-[0_8px_30px_rgba(0,0,0,0.08)] flex items-center space-x-1 sm:space-x-2">
-      {tabs.map((tab) => {
-        const Icon = tab.icon;
-        const isActive = activeTab === tab.id;
-        return (
-          <button
-            key={tab.id}
-            onClick={() => setActiveTab(tab.id)}
-            className={`flex flex-col sm:flex-row items-center justify-center space-x-0 sm:space-x-2 px-5 py-2 rounded-full text-xs font-medium transition-all ${
-              isActive
-                ? 'bg-[#EAEAEA] text-[#1D1D1F] font-semibold scale-102'
-                : 'text-[#6E6E73] hover:text-[#1D1D1F]'
-            }`}
-          >
-            <Icon className={`w-4 h-4 ${isActive ? 'text-[#1D1D1F]' : 'text-[#6E6E73]'}`} />
-            <span className="text-[12px] mt-0.5 sm:mt-0">{tab.label}</span>
-          </button>
-        );
-      })}
-    </div>
-  );
-};

+ 0 - 70
src/components/SyncAuthPromptModal.jsx

@@ -1,70 +0,0 @@
-import React from 'react';
-import { CloudUpload, ShieldCheck, Smartphone, Mic, X } from 'lucide-react';
-
-export const SyncAuthPromptModal = ({ isOpen, onClose, onLoginClick }) => {
-  if (!isOpen) return null;
-
-  return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-md animate-fade-in">
-      <div className="relative w-full max-w-md bg-[#0B0C10] border border-white/15 rounded-3xl p-6 sm:p-8 shadow-2xl text-white space-y-6">
-        {/* Close Button */}
-        <div className="flex justify-end">
-          <button
-            onClick={onClose}
-            className="p-1 rounded-full text-slate-400 hover:text-white hover:bg-white/10 transition-colors"
-          >
-            <X className="w-5 h-5" />
-          </button>
-        </div>
-
-        {/* Header Icon */}
-        <div className="text-center space-y-3">
-          <div className="w-16 h-16 rounded-full bg-white/5 border border-white/10 flex items-center justify-center mx-auto">
-            <CloudUpload className="w-8 h-8 text-white" />
-          </div>
-          <div className="space-y-1">
-            <h2 className="text-lg font-bold">同步录音需要登录</h2>
-            <p className="text-xs text-slate-400 leading-relaxed">
-              请先注册或登录星痕现场记录账号<br />数据才能安全同步至云端备份。
-            </p>
-          </div>
-        </div>
-
-        {/* Feature List */}
-        <div className="p-4 rounded-2xl bg-white/[0.04] border border-white/10 space-y-3 text-xs text-slate-300">
-          <div className="flex items-center space-x-3">
-            <ShieldCheck className="w-4 h-4 text-white" />
-            <span>HTTPS 安全传输与账号隔离</span>
-          </div>
-          <div className="flex items-center space-x-3">
-            <Smartphone className="w-4 h-4 text-white" />
-            <span>账号下的记录增量同步</span>
-          </div>
-          <div className="flex items-center space-x-3">
-            <Mic className="w-4 h-4 text-white" />
-            <span>专属录音设备绑定与管理</span>
-          </div>
-        </div>
-
-        {/* Actions */}
-        <div className="space-y-2 pt-2">
-          <button
-            onClick={() => {
-              onClose();
-              onLoginClick();
-            }}
-            className="w-full py-3 rounded-xl bg-white text-slate-950 font-semibold text-xs shadow-lg hover:bg-slate-100 transition-all"
-          >
-            立即注册 / 登录
-          </button>
-          <button
-            onClick={onClose}
-            className="w-full py-2 text-xs text-slate-400 hover:text-white transition-colors text-center block"
-          >
-            暂不同步
-          </button>
-        </div>
-      </div>
-    </div>
-  );
-};

+ 399 - 0
src/components/record-detail/MultiTrackTimeline.jsx

@@ -0,0 +1,399 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import {
+  AudioWaveform,
+  Camera,
+  FileText,
+  MapPin,
+  Radio,
+  TriangleAlert
+} from 'lucide-react';
+import {
+  formatDuration,
+  groupPhotoEvents,
+  isContinuationEvent,
+  sortEvents
+} from './recordDetailUtils';
+
+const LEFT_PADDING = 30;
+const MINIMUM_WIDTH = 400;
+const POINTS_PER_SECOND = 2.5;
+const RULER_HEIGHT = 28;
+const AUDIO_HEIGHT = 90;
+const EVENT_HEIGHT = 34;
+const DIVIDER_HEIGHT = 1;
+const CONTENT_HEIGHT = RULER_HEIGHT + AUDIO_HEIGHT + EVENT_HEIGHT * 2 + DIVIDER_HEIGHT * 2;
+
+const xForTime = (timeMs, durationMs, width) => {
+  if (durationMs <= 0) return LEFT_PADDING;
+  const usableWidth = Math.max(1, width - LEFT_PADDING * 2);
+  const ratio = Math.min(Math.max(timeMs / durationMs, 0), 1);
+  return LEFT_PADDING + usableWidth * ratio;
+};
+
+const timeForX = (x, durationMs, width) => {
+  if (durationMs <= 0) return 0;
+  const usableWidth = Math.max(1, width - LEFT_PADDING * 2);
+  const ratio = Math.min(Math.max((x - LEFT_PADDING) / usableWidth, 0), 1);
+  return ratio * durationMs;
+};
+
+const drawTimeline = ({
+  canvas,
+  width,
+  durationMs,
+  waveformSamples,
+  silentRanges,
+  dark
+}) => {
+  if (!canvas) return;
+  const ratio = window.devicePixelRatio || 1;
+  canvas.width = Math.round(width * ratio);
+  canvas.height = Math.round(CONTENT_HEIGHT * ratio);
+  canvas.style.width = `${width}px`;
+  canvas.style.height = `${CONTENT_HEIGHT}px`;
+  const context = canvas.getContext('2d');
+  context.setTransform(ratio, 0, 0, ratio, 0, 0);
+  context.clearRect(0, 0, width, CONTENT_HEIGHT);
+
+  const primary = dark ? 'rgba(245,245,247,0.88)' : 'rgba(29,29,31,0.88)';
+  const subtle = dark ? 'rgba(245,245,247,0.34)' : 'rgba(29,29,31,0.34)';
+  const line = dark ? 'rgba(245,245,247,0.12)' : 'rgba(29,29,31,0.12)';
+  const silence = dark ? 'rgba(245,245,247,0.06)' : 'rgba(29,29,31,0.06)';
+  const startX = xForTime(0, durationMs, width);
+  const endX = xForTime(durationMs, durationMs, width);
+  const audioTop = RULER_HEIGHT;
+  const centerY = audioTop + AUDIO_HEIGHT / 2;
+
+  const totalSeconds = durationMs / 1000;
+  const interval = totalSeconds > 300 ? 60 : totalSeconds > 60 ? 10 : 5;
+  context.font = '8px ui-monospace, SFMono-Regular, Menlo, monospace';
+  context.fillStyle = subtle;
+  context.textAlign = 'center';
+  context.textBaseline = 'middle';
+  for (let seconds = 0; seconds <= totalSeconds; seconds += interval) {
+    const x = xForTime(seconds * 1000, durationMs, width);
+    context.beginPath();
+    context.moveTo(x, RULER_HEIGHT - 6);
+    context.lineTo(x, RULER_HEIGHT);
+    context.strokeStyle = line;
+    context.lineWidth = 1;
+    context.stroke();
+    context.fillText(
+      `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, '0')}`,
+      x,
+      RULER_HEIGHT - 14
+    );
+  }
+
+  silentRanges.forEach((range) => {
+    const left = xForTime(range.start * 1000, durationMs, width);
+    const right = xForTime(range.end * 1000, durationMs, width);
+    context.fillStyle = silence;
+    context.fillRect(left, audioTop, Math.max(0, right - left), AUDIO_HEIGHT);
+  });
+
+  context.beginPath();
+  context.moveTo(startX, centerY);
+  context.lineTo(endX, centerY);
+  context.strokeStyle = line;
+  context.lineWidth = 1;
+  context.stroke();
+
+  const barWidth = 1;
+  const barSpacing = 2;
+  const slot = barWidth + barSpacing;
+  const barCount = Math.max(0, Math.floor((endX - startX) / slot));
+  context.fillStyle = primary;
+  for (let index = 0; index < barCount; index += 1) {
+    if (!waveformSamples.length) break;
+    const sampleStart = Math.floor(index * waveformSamples.length / barCount);
+    const sampleEnd = Math.max(
+      sampleStart + 1,
+      Math.floor((index + 1) * waveformSamples.length / barCount)
+    );
+    let amplitude = 0;
+    for (let sampleIndex = sampleStart; sampleIndex < Math.min(sampleEnd, waveformSamples.length); sampleIndex += 1) {
+      amplitude = Math.max(amplitude, waveformSamples[sampleIndex] || 0);
+    }
+    const height = Math.max(amplitude * AUDIO_HEIGHT * 0.75, 1);
+    context.fillRect(startX + index * slot, centerY - height / 2, barWidth, height);
+  }
+
+  const dividerOne = RULER_HEIGHT + AUDIO_HEIGHT;
+  const dividerTwo = dividerOne + DIVIDER_HEIGHT + EVENT_HEIGHT;
+  context.fillStyle = line;
+  context.fillRect(0, dividerOne, width, 1);
+  context.fillRect(0, dividerTwo, width, 1);
+};
+
+const MarkerButton = ({
+  x,
+  top,
+  height,
+  label,
+  onClick,
+  children
+}) => (
+  <button
+    type="button"
+    className="absolute z-20 flex items-center justify-center text-slate-800 dark:text-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E6C687] rounded-full"
+    style={{ left: x - 12, top, width: 24, height }}
+    aria-label={label}
+    title={label}
+    onClick={(event) => {
+      event.stopPropagation();
+      onClick();
+    }}
+  >
+    {children}
+  </button>
+);
+
+export const MultiTrackTimeline = ({
+  events,
+  currentTimeMs,
+  durationMs,
+  waveformSamples,
+  silentRanges,
+  onSeek,
+  onEmptyTrackTap,
+  onEventTap
+}) => {
+  const canvasRef = useRef(null);
+  const scrollRef = useRef(null);
+  const playheadRef = useRef(null);
+  const dragState = useRef(null);
+  const [isDraggingPlayhead, setIsDraggingPlayhead] = useState(false);
+  const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
+  const width = Math.max(MINIMUM_WIDTH, durationMs / 1000 * POINTS_PER_SECOND + 60);
+  const photos = useMemo(() => groupPhotoEvents(events), [events]);
+  const notes = useMemo(() => sortEvents(events.filter((event) => (
+    event.eventType === 'NOTE'
+      || (event.eventType === 'MARKER' && !isContinuationEvent(event))
+  ))), [events]);
+  const continuations = useMemo(
+    () => sortEvents(events.filter(isContinuationEvent)),
+    [events]
+  );
+
+  useEffect(() => {
+    const observer = new MutationObserver(() => {
+      setIsDark(document.documentElement.classList.contains('dark'));
+    });
+    observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
+    return () => observer.disconnect();
+  }, []);
+
+  useEffect(() => {
+    drawTimeline({
+      canvas: canvasRef.current,
+      width,
+      durationMs,
+      waveformSamples,
+      silentRanges,
+      dark: isDark
+    });
+  }, [durationMs, isDark, silentRanges, waveformSamples, width]);
+
+  useEffect(() => {
+    if (isDraggingPlayhead || !scrollRef.current) return;
+    const scroll = scrollRef.current;
+    const playheadX = xForTime(currentTimeMs, durationMs, width);
+    const target = Math.max(0, playheadX - scroll.clientWidth / 2);
+    if (playheadX < scroll.scrollLeft + 24 || playheadX > scroll.scrollLeft + scroll.clientWidth - 24) {
+      scroll.scrollTo({ left: target, behavior: 'smooth' });
+    }
+  }, [currentTimeMs, durationMs, isDraggingPlayhead, width]);
+
+  const pointerTime = (event) => {
+    const bounds = playheadRef.current?.parentElement?.getBoundingClientRect();
+    if (!bounds) return currentTimeMs;
+    return timeForX(event.clientX - bounds.left, durationMs, width);
+  };
+
+  const handlePlayheadPointerDown = (event) => {
+    event.preventDefault();
+    event.currentTarget.setPointerCapture(event.pointerId);
+    dragState.current = { pointerId: event.pointerId };
+    setIsDraggingPlayhead(true);
+    onSeek(pointerTime(event));
+  };
+
+  const handlePlayheadPointerMove = (event) => {
+    if (dragState.current?.pointerId !== event.pointerId) return;
+    onSeek(pointerTime(event));
+  };
+
+  const handlePlayheadPointerUp = (event) => {
+    if (dragState.current?.pointerId !== event.pointerId) return;
+    dragState.current = null;
+    setIsDraggingPlayhead(false);
+    event.currentTarget.releasePointerCapture?.(event.pointerId);
+  };
+
+  const handleTrackClick = (track, event) => {
+    const bounds = event.currentTarget.getBoundingClientRect();
+    const timeMs = timeForX(event.clientX - bounds.left, durationMs, width);
+    onEmptyTrackTap(track, timeMs);
+  };
+
+  const playheadX = xForTime(currentTimeMs, durationMs, width);
+  const photoTop = RULER_HEIGHT + AUDIO_HEIGHT + DIVIDER_HEIGHT;
+  const noteTop = photoTop + EVENT_HEIGHT + DIVIDER_HEIGHT;
+
+  return (
+    <div className="space-y-2">
+      <div
+        ref={scrollRef}
+        className="overflow-x-auto rounded-[10px] border border-black/10 dark:border-white/10 bg-slate-100/35 dark:bg-white/[0.025] scrollbar-none"
+      >
+        <div
+          className="relative"
+          style={{ width, height: CONTENT_HEIGHT }}
+        >
+          <canvas ref={canvasRef} className="absolute inset-0" aria-hidden="true" />
+
+          <button
+            type="button"
+            className="absolute z-10 cursor-crosshair"
+            style={{ left: 0, right: 0, top: photoTop, height: EVENT_HEIGHT }}
+            onClick={(event) => {
+              event.stopPropagation();
+              handleTrackClick('photo', event);
+            }}
+            aria-label="图片轨空白区域,点击添加照片"
+          />
+          <button
+            type="button"
+            className="absolute z-10 cursor-crosshair"
+            style={{ left: 0, right: 0, top: noteTop, height: EVENT_HEIGHT }}
+            onClick={(event) => {
+              event.stopPropagation();
+              handleTrackClick('note', event);
+            }}
+            aria-label="笔记轨空白区域,点击添加笔记"
+          />
+
+          <AudioWaveform
+            className="absolute z-30 w-3 h-3 text-slate-500 pointer-events-none"
+            style={{ left: 6, top: RULER_HEIGHT + AUDIO_HEIGHT / 2 - 6 }}
+            aria-label="音频轨道"
+          />
+          <Camera
+            className="absolute z-30 w-3 h-3 text-slate-500 pointer-events-none"
+            style={{ left: 6, top: photoTop + EVENT_HEIGHT / 2 - 6 }}
+            aria-label="图片轨道"
+          />
+          <FileText
+            className="absolute z-30 w-3 h-3 text-slate-500 pointer-events-none"
+            style={{ left: 6, top: noteTop + EVENT_HEIGHT / 2 - 6 }}
+            aria-label="笔记轨道"
+          />
+
+          {continuations.map((event) => {
+            const x = xForTime(Number(event.relativeTimeMs), durationMs, width);
+            return (
+              <MarkerButton
+                key={event.id}
+                x={x}
+                top={RULER_HEIGHT}
+                height={AUDIO_HEIGHT}
+                label={`查看 ${formatDuration(event.relativeTimeMs)} 的续录信息`}
+                onClick={() => onEventTap(event)}
+              >
+                <span className="relative flex h-full w-5 items-center justify-center">
+                  <span className="absolute h-full w-px bg-slate-600/40 dark:bg-white/35" />
+                  <span className="relative flex h-5 w-5 items-center justify-center rounded-full border border-black/15 dark:border-white/15 bg-white/90 dark:bg-slate-900/90">
+                    <Radio className="w-3 h-3" />
+                  </span>
+                </span>
+              </MarkerButton>
+            );
+          })}
+
+          {photos.map((group) => {
+            const x = xForTime(group.relativeTimeMs, durationMs, width);
+            return (
+              <MarkerButton
+                key={group.key}
+                x={x}
+                top={photoTop}
+                height={EVENT_HEIGHT}
+                label={`查看 ${formatDuration(group.relativeTimeMs)} 的 ${group.events.length} 张图片`}
+                onClick={() => onEventTap(group.events[0])}
+              >
+                <span className="relative flex h-5 w-5 items-center justify-center rounded-full border border-black/15 dark:border-white/15 bg-white/90 dark:bg-slate-900/90">
+                  <Camera className="w-3 h-3" />
+                  {group.events.length > 1 && (
+                    <span className="absolute -right-1.5 -top-1.5 min-w-[13px] h-[13px] px-0.5 rounded-full bg-slate-900 dark:bg-white text-white dark:text-slate-950 text-[7px] font-bold leading-[13px] text-center">
+                      {group.events.length}
+                    </span>
+                  )}
+                </span>
+              </MarkerButton>
+            );
+          })}
+
+          {notes.map((event) => {
+            const x = xForTime(Number(event.relativeTimeMs), durationMs, width);
+            const Icon = event.eventType === 'MARKER' ? TriangleAlert : FileText;
+            return (
+              <MarkerButton
+                key={event.id}
+                x={x}
+                top={noteTop}
+                height={EVENT_HEIGHT}
+                label={`查看 ${formatDuration(event.relativeTimeMs)} 的${event.eventType === 'MARKER' ? '标记' : '笔记'}`}
+                onClick={() => onEventTap(event)}
+              >
+                <span className="flex h-5 w-5 items-center justify-center rounded-full border border-black/15 dark:border-white/15 bg-white/90 dark:bg-slate-900/90">
+                  <Icon className="w-3 h-3" />
+                </span>
+              </MarkerButton>
+            );
+          })}
+
+          <div
+            ref={playheadRef}
+            role="slider"
+            tabIndex={0}
+            aria-label="当前播放位置"
+            aria-valuemin={0}
+            aria-valuemax={Math.round(durationMs)}
+            aria-valuenow={Math.round(currentTimeMs)}
+            aria-valuetext={formatDuration(currentTimeMs)}
+            className="absolute z-40 top-0 bottom-0 w-7 -ml-3.5 cursor-ew-resize touch-none focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E6C687]/70"
+            style={{ left: playheadX }}
+            onPointerDown={handlePlayheadPointerDown}
+            onPointerMove={handlePlayheadPointerMove}
+            onPointerUp={handlePlayheadPointerUp}
+            onPointerCancel={handlePlayheadPointerUp}
+            onKeyDown={(event) => {
+              if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
+                event.preventDefault();
+                onSeek(Math.min(durationMs, currentTimeMs + 1000));
+              } else if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
+                event.preventDefault();
+                onSeek(Math.max(0, currentTimeMs - 1000));
+              }
+            }}
+          >
+            <span className="absolute left-1/2 top-0 bottom-0 w-px -translate-x-1/2 bg-slate-900 dark:bg-white" />
+          </div>
+        </div>
+      </div>
+
+      <div className="flex flex-wrap items-center gap-x-6 gap-y-1 text-[9px] text-slate-500 dark:text-slate-400">
+        <span className="inline-flex items-center gap-1"><AudioWaveform className="w-2.5 h-2.5" />音频</span>
+        <span className="inline-flex items-center gap-1"><Camera className="w-2.5 h-2.5" />图片</span>
+        <span className="inline-flex items-center gap-1"><FileText className="w-2.5 h-2.5" />笔记</span>
+        {continuations.length > 0 && (
+          <span className="inline-flex items-center gap-1"><Radio className="w-2.5 h-2.5" />续录</span>
+        )}
+        {events.some((event) => event.latitude != null) && (
+          <span className="sr-only"><MapPin />部分事件包含位置信息</span>
+        )}
+      </div>
+    </div>
+  );
+};

+ 972 - 0
src/components/record-detail/RecordDetailDialogs.jsx

@@ -0,0 +1,972 @@
+import React, { useEffect, useRef, useState } from 'react';
+import {
+  Camera,
+  Check,
+  ChevronRight,
+  CircleAlert,
+  FileText,
+  ImagePlus,
+  LocateFixed,
+  MapPin,
+  MapPinned,
+  Mic,
+  Pause,
+  Play,
+  Radio,
+  Search,
+  Square,
+  Trash2,
+  Upload,
+  X,
+  ZoomIn,
+  ZoomOut
+} from 'lucide-react';
+import {
+  formatChineseDateTime,
+  formatDuration,
+  isContinuationEvent,
+  makeLocation
+} from './recordDetailUtils';
+
+const DialogShell = ({
+  title,
+  children,
+  onClose,
+  actions,
+  size = 'max-w-xl',
+  full = false,
+  closeLabel = '完成'
+}) => {
+  const dialogRef = useRef(null);
+
+  useEffect(() => {
+    const previous = document.activeElement;
+    const handleKey = (event) => {
+      if (event.key === 'Escape') onClose();
+      if (event.key !== 'Tab' || !dialogRef.current) return;
+      const focusable = dialogRef.current.querySelectorAll(
+        'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
+      );
+      if (!focusable.length) return;
+      const first = focusable[0];
+      const last = focusable[focusable.length - 1];
+      if (event.shiftKey && document.activeElement === first) {
+        event.preventDefault();
+        last.focus();
+      } else if (!event.shiftKey && document.activeElement === last) {
+        event.preventDefault();
+        first.focus();
+      }
+    };
+    document.addEventListener('keydown', handleKey);
+    dialogRef.current?.querySelector('input, textarea, button')?.focus();
+    return () => {
+      document.removeEventListener('keydown', handleKey);
+      previous?.focus?.();
+    };
+  }, [onClose]);
+
+  return (
+    <div
+      className={`fixed inset-0 z-[80] flex ${full ? 'items-stretch' : 'items-center'} justify-center bg-black/45 p-3 sm:p-6 backdrop-blur-sm`}
+      role="presentation"
+      onMouseDown={(event) => {
+        if (event.target === event.currentTarget) onClose();
+      }}
+    >
+      <div
+        ref={dialogRef}
+        role="dialog"
+        aria-modal="true"
+        aria-label={title}
+        className={`${full ? 'w-full h-full rounded-2xl' : `w-full ${size} max-h-[92vh] rounded-2xl`} flex flex-col overflow-hidden border border-black/10 dark:border-white/10 bg-[#F8F9FA] dark:bg-slate-950 text-slate-900 dark:text-slate-100 shadow-2xl`}
+      >
+        <div className="h-14 shrink-0 flex items-center justify-between gap-3 border-b border-black/10 dark:border-white/10 px-4 sm:px-5">
+          <button
+            type="button"
+            onClick={onClose}
+            className="min-w-12 text-left text-sm text-slate-500 hover:text-slate-900 dark:hover:text-white"
+          >
+            {closeLabel}
+          </button>
+          <h2 className="min-w-0 flex-1 truncate text-center text-sm font-semibold">{title}</h2>
+          <div className="min-w-12 flex justify-end">{actions}</div>
+        </div>
+        <div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
+      </div>
+    </div>
+  );
+};
+
+export const TitleEditorDialog = ({ initialValue, onSave, onClose, saving }) => {
+  const [value, setValue] = useState(initialValue);
+  const inputRef = useRef(null);
+
+  useEffect(() => {
+    const input = inputRef.current;
+    if (!input) return;
+    input.focus();
+    if (value.endsWith('现场记录')) {
+      input.setSelectionRange(value.length - 4, value.length);
+    } else {
+      input.select();
+    }
+  }, []);
+
+  return (
+    <DialogShell
+      title="修改记录名称"
+      onClose={onClose}
+      size="max-w-md"
+      closeLabel="取消"
+      actions={(
+        <button
+          type="button"
+          disabled={!value.trim() || saving}
+          onClick={() => onSave(value.trim())}
+          className="text-sm font-semibold disabled:opacity-35"
+        >
+          保存
+        </button>
+      )}
+    >
+      <div className="p-5">
+        <label className="block text-[10px] font-semibold tracking-[0.14em] text-slate-500">
+          记录名称
+          <input
+            ref={inputRef}
+            value={value}
+            onChange={(event) => setValue(event.target.value)}
+            onKeyDown={(event) => {
+              if (event.key === 'Enter' && value.trim()) onSave(value.trim());
+            }}
+            className="mt-2 w-full rounded-lg border border-black/10 dark:border-white/10 bg-slate-100/70 dark:bg-white/5 px-3.5 py-3 text-lg font-semibold tracking-normal outline-none focus:border-[#E6C687]"
+          />
+        </label>
+      </div>
+    </DialogShell>
+  );
+};
+
+const Stat = ({ icon: Icon, label, value }) => (
+  <div className="flex-1 text-center">
+    <Icon className="mx-auto mb-1 h-3.5 w-3.5 text-slate-500" />
+    <div className="font-mono text-[13px] font-medium">{value}</div>
+    <div className="mt-0.5 text-[9px] text-slate-500">{label}</div>
+  </div>
+);
+
+export const SessionInfoPopover = ({
+  session,
+  durationMs,
+  photoCount,
+  noteCount,
+  onContinue,
+  onClose
+}) => (
+  <div className="absolute right-0 top-11 z-50 w-[360px] max-w-[calc(100vw-24px)] rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-slate-900 p-3.5 shadow-2xl">
+    <div className="flex items-start justify-between gap-6">
+      <div>
+        <div className="mb-1 flex items-center gap-1 text-[10px] font-medium text-slate-500">
+          <Play className="h-3 w-3" />开始
+        </div>
+        <div className="text-[13px] font-medium">{formatChineseDateTime(session.startTime)}</div>
+      </div>
+      <div className="text-right">
+        <div className="mb-1 flex items-center justify-end gap-1 text-[10px] font-medium text-slate-500">
+          <Square className="h-3 w-3" />结束
+        </div>
+        <div className="text-[13px] font-medium">
+          {session.endTime ? formatChineseDateTime(session.endTime) : '进行中'}
+        </div>
+      </div>
+      <button type="button" onClick={onClose} className="sr-only">关闭记录信息</button>
+    </div>
+
+    <div className="my-3.5 h-px bg-black/10 dark:bg-white/10" />
+    <div className="flex">
+      <Stat icon={Radio} label="总时长" value={formatDuration(durationMs)} />
+      <Stat icon={Camera} label="图片" value={photoCount} />
+      <Stat icon={FileText} label="笔记" value={noteCount} />
+    </div>
+    <div className="my-3.5 h-px bg-black/10 dark:bg-white/10" />
+    <div className="flex items-center justify-between text-xs">
+      <span className="text-slate-500">版本号</span>
+      <span className="font-mono font-semibold">v{Math.max(Number(session.revision) || 1, 1)}</span>
+    </div>
+    <div className="my-3.5 h-px bg-black/10 dark:bg-white/10" />
+    <button
+      type="button"
+      onClick={onContinue}
+      className="flex w-full items-center justify-center gap-2 rounded-lg border border-black/10 dark:border-white/10 bg-slate-100/50 dark:bg-white/[0.03] py-2.5 text-[13px] font-semibold hover:bg-slate-100 dark:hover:bg-white/[0.06]"
+    >
+      <Radio className="h-3.5 w-3.5" />
+      续录
+    </button>
+  </div>
+);
+
+export const SyncInfoPopover = ({ session, onClose }) => (
+  <div className="absolute right-0 top-11 z-50 w-80 max-w-[calc(100vw-24px)] rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-slate-900 p-3.5 shadow-2xl">
+    <div className="mb-3 flex items-center justify-between">
+      <div className="flex items-center gap-2 text-sm font-semibold">
+        <Upload className="h-4 w-4 text-[#D4AD5A]" />
+        同步信息
+      </div>
+      <button type="button" onClick={onClose} aria-label="关闭同步信息">
+        <X className="h-3.5 w-3.5 text-slate-400" />
+      </button>
+    </div>
+    <div className="h-px bg-black/10 dark:bg-white/10" />
+    <dl className="mt-3 space-y-3 text-xs">
+      <div className="flex gap-4">
+        <dt className="text-slate-500">同步时间</dt>
+        <dd className="ml-auto text-right font-medium">{formatChineseDateTime(session.updatedAt)}</dd>
+      </div>
+      <div className="flex gap-4">
+        <dt className="text-slate-500">云端版本</dt>
+        <dd className="ml-auto font-mono font-medium">v{Math.max(Number(session.revision) || 1, 1)}</dd>
+      </div>
+      <div className="flex gap-4">
+        <dt className="text-slate-500">云端记录</dt>
+        <dd className="ml-auto max-w-[190px] break-all text-right font-mono text-[11px]">{session.id}</dd>
+      </div>
+    </dl>
+  </div>
+);
+
+const reverseGeocode = async (latitude, longitude) => {
+  const response = await fetch(
+    `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${encodeURIComponent(latitude)}&lon=${encodeURIComponent(longitude)}&accept-language=zh-CN`
+  );
+  if (!response.ok) throw new Error('位置名称暂时无法获取。');
+  const result = await response.json();
+  return {
+    name: result.name || result.address?.building || result.address?.road || '当前位置',
+    address: result.display_name || '',
+    latitude,
+    longitude
+  };
+};
+
+const LocationPickerDialog = ({ value, onSave, onClose }) => {
+  const [selection, setSelection] = useState(value);
+  const [current, setCurrent] = useState(null);
+  const [results, setResults] = useState([]);
+  const [searchText, setSearchText] = useState('');
+  const [status, setStatus] = useState('正在定位…');
+  const [loading, setLoading] = useState(false);
+
+  const locate = () => {
+    if (!navigator.geolocation) {
+      setStatus('当前浏览器不支持定位。');
+      return;
+    }
+    setStatus('正在定位…');
+    navigator.geolocation.getCurrentPosition(async (position) => {
+      const { latitude, longitude } = position.coords;
+      try {
+        const location = await reverseGeocode(latitude, longitude);
+        setCurrent(location);
+        if (!selection) setSelection(location);
+        setStatus('');
+      } catch (error) {
+        const location = {
+          name: '当前位置',
+          address: `${latitude.toFixed(5)}, ${longitude.toFixed(5)}`,
+          latitude,
+          longitude
+        };
+        setCurrent(location);
+        if (!selection) setSelection(location);
+        setStatus(error.message);
+      }
+    }, (error) => {
+      setStatus(error.code === 1 ? '定位权限已拒绝,请在浏览器设置中允许访问位置。' : '当前位置暂时无法获取。');
+    }, { enableHighAccuracy: true, timeout: 12000 });
+  };
+
+  useEffect(locate, []);
+
+  const searchPlaces = async () => {
+    const query = searchText.trim();
+    if (!query) return;
+    setLoading(true);
+    setStatus('');
+    try {
+      const center = current || selection;
+      const params = new URLSearchParams({
+        format: 'jsonv2',
+        q: query,
+        limit: '8',
+        'accept-language': 'zh-CN'
+      });
+      if (center) {
+        params.set('viewbox', [
+          center.longitude - 0.1,
+          center.latitude + 0.1,
+          center.longitude + 0.1,
+          center.latitude - 0.1
+        ].join(','));
+        params.set('bounded', '0');
+      }
+      const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`);
+      if (!response.ok) throw new Error('地点搜索失败,请稍后重试。');
+      const payload = await response.json();
+      setResults(payload.map((item) => ({
+        name: item.name || item.display_name.split(',')[0] || '地点',
+        address: item.display_name,
+        latitude: Number(item.lat),
+        longitude: Number(item.lon)
+      })));
+      if (!payload.length) setStatus('没有找到相关地点,请换一个关键词。');
+    } catch (error) {
+      setStatus(error.message);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const mapLocation = selection || current;
+  const mapUrl = mapLocation
+    ? `https://www.openstreetmap.org/export/embed.html?bbox=${mapLocation.longitude - 0.012}%2C${mapLocation.latitude - 0.008}%2C${mapLocation.longitude + 0.012}%2C${mapLocation.latitude + 0.008}&layer=mapnik&marker=${mapLocation.latitude}%2C${mapLocation.longitude}`
+    : '';
+
+  return (
+    <DialogShell
+      title="所在位置"
+      onClose={onClose}
+      size="max-w-2xl"
+      closeLabel="取消"
+      actions={(
+        <button type="button" onClick={() => onSave(selection)} className="text-sm font-semibold">完成</button>
+      )}
+    >
+      <div className="h-[220px] bg-slate-200 dark:bg-slate-900 relative">
+        {mapUrl ? (
+          <iframe title="位置地图" src={mapUrl} className="h-full w-full border-0" />
+        ) : (
+          <div className="flex h-full items-center justify-center text-xs text-slate-500">等待当前位置</div>
+        )}
+        <button
+          type="button"
+          onClick={locate}
+          className="absolute bottom-3 right-3 flex h-10 w-10 items-center justify-center rounded-full bg-white/90 dark:bg-slate-900/90 shadow-md"
+          aria-label="重新定位"
+        >
+          <LocateFixed className="h-4 w-4" />
+        </button>
+      </div>
+
+      <div className="border-y border-black/10 dark:border-white/10 p-3">
+        <div className="flex gap-2">
+          <div className="relative flex-1">
+            <Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
+            <input
+              value={searchText}
+              onChange={(event) => setSearchText(event.target.value)}
+              onKeyDown={(event) => {
+                if (event.key === 'Enter') searchPlaces();
+              }}
+              placeholder="搜索附近地点"
+              className="w-full rounded-lg border border-black/10 dark:border-white/10 bg-white dark:bg-white/5 py-2 pl-9 pr-3 text-sm outline-none focus:border-[#E6C687]"
+            />
+          </div>
+          <button
+            type="button"
+            onClick={searchPlaces}
+            disabled={loading || !searchText.trim()}
+            className="rounded-lg border border-black/10 dark:border-white/10 px-4 text-sm font-medium disabled:opacity-35"
+          >
+            {loading ? '搜索中' : '搜索'}
+          </button>
+        </div>
+      </div>
+
+      <div className="divide-y divide-black/10 dark:divide-white/10">
+        <LocationRow
+          location={null}
+          selected={!selection}
+          icon={MapPin}
+          title="不显示位置"
+          onClick={() => setSelection(null)}
+        />
+        {current && (
+          <LocationRow
+            location={current}
+            selected={selection?.latitude === current.latitude && selection?.longitude === current.longitude}
+            icon={LocateFixed}
+            title={current.name}
+            onClick={() => setSelection(current)}
+          />
+        )}
+        {results.map((location) => (
+          <LocationRow
+            key={`${location.latitude}-${location.longitude}-${location.name}`}
+            location={location}
+            selected={selection?.latitude === location.latitude && selection?.longitude === location.longitude}
+            icon={MapPinned}
+            title={location.name}
+            onClick={() => setSelection(location)}
+          />
+        ))}
+      </div>
+
+      {status && (
+        <div className="flex items-start gap-2 p-4 text-xs text-slate-500">
+          <CircleAlert className="mt-0.5 h-4 w-4 shrink-0" />
+          {status}
+        </div>
+      )}
+    </DialogShell>
+  );
+};
+
+const LocationRow = ({ location, selected, icon: Icon, title, onClick }) => (
+  <button
+    type="button"
+    onClick={onClick}
+    className="flex w-full items-center gap-3 px-5 py-3.5 text-left hover:bg-slate-100/70 dark:hover:bg-white/[0.03]"
+  >
+    <Icon className={`h-4 w-4 shrink-0 ${selected ? 'text-[#D4AD5A]' : 'text-slate-500'}`} />
+    <span className="min-w-0 flex-1">
+      <span className="block truncate text-sm font-medium">{title}</span>
+      {location?.address && <span className="mt-0.5 block text-[11px] text-slate-500">{location.address}</span>}
+    </span>
+    {selected && <Check className="h-4 w-4 text-[#D4AD5A]" />}
+  </button>
+);
+
+export const LocationButton = ({ value, onChange }) => {
+  const [open, setOpen] = useState(false);
+  return (
+    <>
+      <button
+        type="button"
+        onClick={() => setOpen(true)}
+        className="flex w-full items-center gap-2.5 rounded-lg border border-black/10 dark:border-white/10 bg-slate-100/55 dark:bg-white/[0.03] px-3 py-2.5 text-left"
+      >
+        <MapPin className={`h-4 w-4 ${value ? 'fill-[#E6C687] text-[#D4AD5A]' : 'text-slate-500'}`} />
+        <span className="min-w-0 flex-1">
+          <span className="block truncate text-[13px] font-medium">{value?.name || '所在位置'}</span>
+          <span className="block truncate text-[10px] text-slate-500">
+            {value?.address || '点击后根据 GPS 选择当前位置'}
+          </span>
+        </span>
+        <ChevronRight className="h-3 w-3 text-slate-400" />
+      </button>
+      {open && (
+        <LocationPickerDialog
+          value={value}
+          onClose={() => setOpen(false)}
+          onSave={(location) => {
+            onChange(location);
+            setOpen(false);
+          }}
+        />
+      )}
+    </>
+  );
+};
+
+export const NoteEditorDialog = ({
+  title = '添加笔记',
+  timeMs,
+  initialText = '',
+  initialLocation = null,
+  onSave,
+  onDelete,
+  onClose,
+  saving
+}) => {
+  const [text, setText] = useState(initialText);
+  const [location, setLocation] = useState(initialLocation);
+  const changed = text.trim() !== initialText.trim()
+    || JSON.stringify(location) !== JSON.stringify(initialLocation);
+
+  return (
+    <DialogShell
+      title={title}
+      onClose={onClose}
+      closeLabel={initialText ? '完成' : '取消'}
+      actions={(
+        <button
+          type="button"
+          disabled={!text.trim() || saving || (initialText && !changed)}
+          onClick={() => onSave(text.trim(), location)}
+          className="text-sm font-semibold disabled:opacity-35"
+        >
+          保存
+        </button>
+      )}
+    >
+      <div className="space-y-4 p-5">
+        <div className="flex items-center gap-2 font-mono text-xs font-medium text-slate-500">
+          <Radio className="h-3.5 w-3.5" />
+          {initialText ? formatDuration(timeMs) : `添加到 ${formatDuration(timeMs)}`}
+        </div>
+        <textarea
+          value={text}
+          onChange={(event) => setText(event.target.value)}
+          placeholder="这一刻的想法"
+          className="min-h-40 w-full resize-y rounded-lg border border-black/10 dark:border-white/10 bg-slate-100/60 dark:bg-white/[0.04] p-3.5 text-[15px] outline-none focus:border-[#E6C687]"
+        />
+        <LocationButton value={location} onChange={setLocation} />
+        {onDelete && (
+          <div className="flex justify-end pt-6">
+            <button
+              type="button"
+              onClick={onDelete}
+              className="flex h-9 w-9 items-center justify-center rounded-full border border-red-500/35 text-red-500 hover:bg-red-500/10"
+              aria-label="删除这条记录"
+            >
+              <Trash2 className="h-3.5 w-3.5" />
+            </button>
+          </div>
+        )}
+      </div>
+    </DialogShell>
+  );
+};
+
+export const PhotoEditorDialog = ({ timeMs, onSave, onClose, saving }) => {
+  const [drafts, setDrafts] = useState([]);
+  const [location, setLocation] = useState(null);
+  const draftsRef = useRef([]);
+
+  useEffect(() => {
+    draftsRef.current = drafts;
+  }, [drafts]);
+
+  useEffect(() => () => {
+    draftsRef.current.forEach((draft) => URL.revokeObjectURL(draft.previewUrl));
+  }, []);
+
+  const addFiles = (files) => {
+    const images = [...files].filter((file) => file.type.startsWith('image/'));
+    setDrafts((current) => [
+      ...current,
+      ...images.map((file) => ({
+        id: crypto.randomUUID(),
+        file,
+        note: '',
+        previewUrl: URL.createObjectURL(file)
+      }))
+    ]);
+  };
+
+  const removeDraft = (id) => {
+    setDrafts((current) => {
+      const target = current.find((draft) => draft.id === id);
+      if (target) URL.revokeObjectURL(target.previewUrl);
+      return current.filter((draft) => draft.id !== id);
+    });
+  };
+
+  return (
+    <DialogShell
+      title="添加照片记录"
+      onClose={onClose}
+      size="max-w-2xl"
+      closeLabel="取消"
+      actions={(
+        <button
+          type="button"
+          disabled={!drafts.length || saving}
+          onClick={() => onSave(drafts, location)}
+          className="text-sm font-semibold disabled:opacity-35"
+        >
+          保存 {drafts.length} 张
+        </button>
+      )}
+    >
+      <div className="space-y-4 p-5">
+        <div className="flex items-center gap-2 font-mono text-xs font-medium text-slate-500">
+          <Radio className="h-3.5 w-3.5" />
+          记录点 {formatDuration(timeMs)}
+        </div>
+
+        <div className="grid grid-cols-2 gap-3">
+          <label className="flex cursor-pointer items-center justify-center gap-2 rounded-lg border border-black/10 dark:border-white/10 bg-slate-100/55 dark:bg-white/[0.03] py-3 text-[13px] font-medium">
+            <Camera className="h-4 w-4" />
+            拍照
+            <input
+              type="file"
+              accept="image/*"
+              capture="environment"
+              className="sr-only"
+              onChange={(event) => addFiles(event.target.files || [])}
+            />
+          </label>
+          <label className="flex cursor-pointer items-center justify-center gap-2 rounded-lg border border-black/10 dark:border-white/10 bg-slate-100/55 dark:bg-white/[0.03] py-3 text-[13px] font-medium">
+            <ImagePlus className="h-4 w-4" />
+            从相册选择
+            <input
+              type="file"
+              multiple
+              accept="image/*"
+              className="sr-only"
+              onChange={(event) => addFiles(event.target.files || [])}
+            />
+          </label>
+        </div>
+
+        <LocationButton value={location} onChange={setLocation} />
+
+        {!drafts.length ? (
+          <div className="flex min-h-56 flex-col items-center justify-center rounded-xl border border-dashed border-black/10 dark:border-white/10 text-center">
+            <ImagePlus className="mb-3 h-8 w-8 text-slate-300 dark:text-slate-700" />
+            <div className="text-sm font-medium">还没有照片</div>
+            <div className="mt-1 text-xs text-slate-500">可以连续拍照,也可以一次选择多张。</div>
+          </div>
+        ) : (
+          <div className="space-y-4">
+            {drafts.map((draft) => (
+              <div key={draft.id} className="rounded-xl border border-black/10 dark:border-white/10 bg-slate-100/35 dark:bg-white/[0.025] p-3">
+                <div className="relative">
+                  <img src={draft.previewUrl} alt="待保存照片" className="max-h-72 w-full rounded-lg object-contain" />
+                  <button
+                    type="button"
+                    onClick={() => removeDraft(draft.id)}
+                    className="absolute right-2 top-2 flex h-8 w-8 items-center justify-center rounded-full bg-black/65 text-white"
+                    aria-label="移除这张照片"
+                  >
+                    <Trash2 className="h-3.5 w-3.5" />
+                  </button>
+                </div>
+                <textarea
+                  value={draft.note}
+                  onChange={(event) => setDrafts((current) => current.map((item) => (
+                    item.id === draft.id ? { ...item, note: event.target.value } : item
+                  )))}
+                  placeholder="照片备注"
+                  className="mt-3 h-[72px] w-full resize-none rounded-lg border border-black/10 dark:border-white/10 bg-white dark:bg-black/15 p-3 text-sm outline-none focus:border-[#E6C687]"
+                />
+              </div>
+            ))}
+          </div>
+        )}
+      </div>
+    </DialogShell>
+  );
+};
+
+const FullScreenImage = ({ src, createdAt, note, onClose }) => {
+  const [scale, setScale] = useState(1);
+  const [offset, setOffset] = useState({ x: 0, y: 0 });
+  const drag = useRef(null);
+
+  return (
+    <div className="fixed inset-0 z-[100] overflow-hidden bg-black text-white" role="dialog" aria-modal="true" aria-label="全屏图片">
+      <img
+        src={src}
+        alt={note || '现场记录照片'}
+        draggable="false"
+        className="h-full w-full select-none object-contain touch-none"
+        style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})` }}
+        onWheel={(event) => {
+          event.preventDefault();
+          setScale((current) => Math.min(5, Math.max(1, current + (event.deltaY < 0 ? 0.25 : -0.25))));
+        }}
+        onPointerDown={(event) => {
+          event.currentTarget.setPointerCapture(event.pointerId);
+          drag.current = { x: event.clientX - offset.x, y: event.clientY - offset.y };
+        }}
+        onPointerMove={(event) => {
+          if (!drag.current || scale <= 1) return;
+          setOffset({ x: event.clientX - drag.current.x, y: event.clientY - drag.current.y });
+        }}
+        onPointerUp={() => { drag.current = null; }}
+        onDoubleClick={() => {
+          setScale((current) => current > 1 ? 1 : 2);
+          if (scale > 1) setOffset({ x: 0, y: 0 });
+        }}
+      />
+      <button
+        type="button"
+        onClick={onClose}
+        className="absolute right-4 top-4 flex h-9 w-9 items-center justify-center rounded-full bg-black/55"
+        aria-label="关闭全屏图片"
+      >
+        <X className="h-4 w-4" />
+      </button>
+      <div className="absolute left-4 top-4 flex gap-2">
+        <button
+          type="button"
+          onClick={() => setScale((current) => Math.max(1, current - 0.5))}
+          className="flex h-9 w-9 items-center justify-center rounded-full bg-black/55"
+          aria-label="缩小图片"
+        >
+          <ZoomOut className="h-4 w-4" />
+        </button>
+        <button
+          type="button"
+          onClick={() => setScale((current) => Math.min(5, current + 0.5))}
+          className="flex h-9 w-9 items-center justify-center rounded-full bg-black/55"
+          aria-label="放大图片"
+        >
+          <ZoomIn className="h-4 w-4" />
+        </button>
+      </div>
+      <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-5 pb-7 pt-16">
+        <div className="text-xs text-white/75">{formatChineseDateTime(createdAt)}</div>
+        {note?.trim() && <div className="mt-1.5 max-w-3xl text-[15px]">{note.trim()}</div>}
+      </div>
+    </div>
+  );
+};
+
+export const PhotoDetailDialog = ({
+  events,
+  imageUrls,
+  onSave,
+  onDelete,
+  onClose,
+  saving
+}) => {
+  const [notes, setNotes] = useState(() => Object.fromEntries(events.map((event) => [event.id, event.textContent || ''])));
+  const [location, setLocation] = useState(makeLocation(events.find((event) => makeLocation(event))));
+  const [fullScreenEvent, setFullScreenEvent] = useState(null);
+  const changed = events.some((event) => (
+    (event.textContent || '').trim() !== (notes[event.id] || '').trim()
+      || JSON.stringify(makeLocation(event)) !== JSON.stringify(location)
+  ));
+
+  return (
+    <>
+      <DialogShell
+        title={events.length > 1 ? `查看照片(${events.length} 张)` : '查看照片'}
+        onClose={onClose}
+        size="max-w-2xl"
+        actions={(
+          <button
+            type="button"
+            disabled={!changed || saving}
+            onClick={() => onSave(notes, location)}
+            className="text-sm font-semibold disabled:opacity-35"
+          >
+            保存
+          </button>
+        )}
+      >
+        <div className="space-y-4 p-5">
+          <div className="flex items-center justify-between font-mono text-xs text-slate-500">
+            <span>{formatDuration(events[0]?.relativeTimeMs)}</span>
+            <span>{events.length} 张照片</span>
+          </div>
+          <LocationButton value={location} onChange={setLocation} />
+
+          <div className="space-y-7">
+            {events.map((event, index) => {
+              const src = imageUrls[event.id];
+              return (
+                <div key={event.id} className="space-y-2.5">
+                  <div className="flex items-center justify-between">
+                    <span className="text-[11px] font-medium text-slate-500">照片 {index + 1}</span>
+                    <button
+                      type="button"
+                      onClick={() => onDelete(event)}
+                      className="flex h-7 w-7 items-center justify-center text-red-500"
+                      aria-label={`删除照片 ${index + 1}`}
+                    >
+                      <Trash2 className="h-3.5 w-3.5" />
+                    </button>
+                  </div>
+                  {src ? (
+                    <button
+                      type="button"
+                      onClick={() => setFullScreenEvent(event)}
+                      className="block w-full"
+                    >
+                      <img src={src} alt={`照片 ${index + 1}`} className="max-h-[420px] w-full rounded-lg object-contain" />
+                    </button>
+                  ) : (
+                    <div className="flex min-h-44 flex-col items-center justify-center rounded-lg border border-dashed border-black/10 dark:border-white/10">
+                      <CircleAlert className="mb-2 h-6 w-6 text-slate-400" />
+                      <span className="text-sm font-medium">图片不可用</span>
+                      <span className="mt-1 text-xs text-slate-500">云端图片文件可能已被移动或删除。</span>
+                    </div>
+                  )}
+                  <textarea
+                    value={notes[event.id] || ''}
+                    onChange={(changeEvent) => setNotes((current) => ({
+                      ...current,
+                      [event.id]: changeEvent.target.value
+                    }))}
+                    placeholder="照片备注"
+                    className="h-[72px] w-full resize-none rounded-lg border border-black/10 dark:border-white/10 bg-slate-100/55 dark:bg-white/[0.03] p-3 text-sm outline-none focus:border-[#E6C687]"
+                  />
+                </div>
+              );
+            })}
+          </div>
+        </div>
+      </DialogShell>
+      {fullScreenEvent && imageUrls[fullScreenEvent.id] && (
+        <FullScreenImage
+          src={imageUrls[fullScreenEvent.id]}
+          createdAt={fullScreenEvent.createdAt}
+          note={notes[fullScreenEvent.id] || ''}
+          onClose={() => setFullScreenEvent(null)}
+        />
+      )}
+    </>
+  );
+};
+
+export const MarkerDetailDialog = ({ event, onDelete, onClose }) => (
+  <DialogShell title={isContinuationEvent(event) ? '续录详情' : '查看标记'} onClose={onClose}>
+    <div className="space-y-4 p-5">
+      <div className="font-mono text-xs text-slate-500">{formatDuration(event.relativeTimeMs)}</div>
+      <div className="rounded-lg border border-black/10 dark:border-white/10 bg-slate-100/55 dark:bg-white/[0.03] p-4 text-[15px]">
+        {event.textContent || '暂无记录内容'}
+      </div>
+      <div className="flex justify-end pt-6">
+        <button
+          type="button"
+          onClick={onDelete}
+          className="flex h-9 w-9 items-center justify-center rounded-full border border-red-500/35 text-red-500"
+          aria-label="删除这条记录"
+        >
+          <Trash2 className="h-3.5 w-3.5" />
+        </button>
+      </div>
+    </div>
+  </DialogShell>
+);
+
+export const ContinuationRecorderDialog = ({ onFinish, onClose, saving }) => {
+  const mediaRecorderRef = useRef(null);
+  const streamRef = useRef(null);
+  const chunksRef = useRef([]);
+  const timerRef = useRef(null);
+  const elapsedRef = useRef(0);
+  const [state, setState] = useState('idle');
+  const [elapsed, setElapsed] = useState(0);
+  const [error, setError] = useState('');
+
+  useEffect(() => {
+    elapsedRef.current = elapsed;
+  }, [elapsed]);
+
+  useEffect(() => () => {
+    window.clearInterval(timerRef.current);
+    streamRef.current?.getTracks().forEach((track) => track.stop());
+  }, []);
+
+  const start = async () => {
+    try {
+      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+      streamRef.current = stream;
+      const preferredMime = [
+        'audio/mp4',
+        'audio/webm;codecs=opus',
+        'audio/webm'
+      ].find((mime) => window.MediaRecorder?.isTypeSupported?.(mime));
+      const recorder = new MediaRecorder(stream, preferredMime ? { mimeType: preferredMime } : undefined);
+      mediaRecorderRef.current = recorder;
+      chunksRef.current = [];
+      recorder.ondataavailable = (event) => {
+        if (event.data.size > 0) chunksRef.current.push(event.data);
+      };
+      recorder.onstop = async () => {
+        window.clearInterval(timerRef.current);
+        stream.getTracks().forEach((track) => track.stop());
+        const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'audio/webm' });
+        setState('processing');
+        try {
+          await onFinish(blob, elapsedRef.current);
+        } catch (finishError) {
+          setError(finishError?.message || '续录音频处理失败,请重试。');
+          setState('idle');
+        }
+      };
+      recorder.start(500);
+      setState('recording');
+      const startTime = Date.now() - elapsed * 1000;
+      timerRef.current = window.setInterval(() => {
+        setElapsed((Date.now() - startTime) / 1000);
+      }, 100);
+    } catch (permissionError) {
+      setError(permissionError.name === 'NotAllowedError'
+        ? '麦克风权限未开启,请在浏览器设置中允许访问麦克风。'
+        : '无法启动浏览器麦克风,请检查输入设备。');
+    }
+  };
+
+  const togglePause = () => {
+    const recorder = mediaRecorderRef.current;
+    if (!recorder) return;
+    if (state === 'recording') {
+      recorder.pause();
+      window.clearInterval(timerRef.current);
+      setState('paused');
+    } else if (state === 'paused') {
+      recorder.resume();
+      setState('recording');
+      const startTime = Date.now() - elapsed * 1000;
+      timerRef.current = window.setInterval(() => {
+        setElapsed((Date.now() - startTime) / 1000);
+      }, 100);
+    }
+  };
+
+  const stop = () => {
+    if (mediaRecorderRef.current?.state !== 'inactive') {
+      mediaRecorderRef.current.stop();
+    }
+  };
+
+  return (
+    <DialogShell title="续录" onClose={onClose} size="max-w-md" closeLabel="取消">
+      <div className="flex min-h-[360px] flex-col items-center justify-center p-8 text-center">
+        <div className="font-mono text-5xl font-light tracking-tight">{formatDuration(elapsed * 1000, true)}</div>
+        <div className="mt-5 flex items-center gap-2 rounded-lg border border-black/10 dark:border-white/10 px-3 py-2 text-xs text-slate-500">
+          <Mic className="h-3.5 w-3.5" />
+          {state === 'recording' ? '正在记录 · 浏览器麦克风' : state === 'paused' ? '已暂停' : state === 'processing' ? '正在合并音频…' : '准备使用浏览器麦克风'}
+        </div>
+
+        {error && (
+          <div className="mt-5 flex items-start gap-2 rounded-lg bg-red-500/10 p-3 text-left text-xs text-red-600 dark:text-red-400">
+            <CircleAlert className="h-4 w-4 shrink-0" />
+            {error}
+          </div>
+        )}
+
+        <div className="mt-10 flex items-center gap-8">
+          {state === 'idle' ? (
+            <button
+              type="button"
+              onClick={start}
+              className="flex h-14 w-14 items-center justify-center rounded-full bg-red-500 text-white"
+              aria-label="开始续录"
+            >
+              <Mic className="h-5 w-5" />
+            </button>
+          ) : (
+            <>
+              <button
+                type="button"
+                onClick={togglePause}
+                disabled={state === 'processing' || saving}
+                className="flex h-12 w-12 items-center justify-center rounded-full border border-black/15 dark:border-white/15 disabled:opacity-35"
+                aria-label={state === 'paused' ? '继续录音' : '暂停录音'}
+              >
+                {state === 'paused' ? <Play className="h-4 w-4 fill-current" /> : <Pause className="h-4 w-4 fill-current" />}
+              </button>
+              <button
+                type="button"
+                onClick={stop}
+                disabled={state === 'processing' || saving}
+                className="flex h-14 w-14 items-center justify-center rounded-full bg-red-500 text-white disabled:opacity-35"
+                aria-label="结束续录"
+              >
+                <Square className="h-4 w-4 fill-current" />
+              </button>
+            </>
+          )}
+        </div>
+      </div>
+    </DialogShell>
+  );
+};

+ 144 - 0
src/components/record-detail/audioAnalysis.js

@@ -0,0 +1,144 @@
+const WINDOW_SECONDS = 0.05;
+const SILENCE_THRESHOLD_DB = -40;
+const MINIMUM_SILENCE_SECONDS = 2;
+const MINIMUM_DECIBELS = -50;
+
+const normalizedLevel = (decibels) => {
+  if (!Number.isFinite(decibels) || decibels < MINIMUM_DECIBELS) return 0;
+  if (decibels >= 0) return 1;
+  return Math.min(Math.max((decibels - MINIMUM_DECIBELS) / -MINIMUM_DECIBELS, 0), 1);
+};
+
+const decodeBlob = async (blob) => {
+  const AudioContextClass = window.AudioContext || window.webkitAudioContext;
+  if (!AudioContextClass) throw new Error('当前浏览器不支持音频解码。');
+  const context = new AudioContextClass();
+  try {
+    const arrayBuffer = await blob.arrayBuffer();
+    return await context.decodeAudioData(arrayBuffer.slice(0));
+  } finally {
+    await context.close();
+  }
+};
+
+export const analyzeAudioBlob = async (blob) => {
+  const buffer = await decodeBlob(blob);
+  const sampleRate = buffer.sampleRate;
+  const windowLength = Math.max(1, Math.floor(sampleRate * WINDOW_SECONDS));
+  const waveformSamples = [];
+  const silentRanges = [];
+  let silenceStart = null;
+
+  for (let offset = 0; offset < buffer.length; offset += windowLength) {
+    const end = Math.min(buffer.length, offset + windowLength);
+    let sumSquares = 0;
+    let sampleCount = 0;
+    for (let channelIndex = 0; channelIndex < buffer.numberOfChannels; channelIndex += 1) {
+      const channel = buffer.getChannelData(channelIndex);
+      for (let index = offset; index < end; index += 1) {
+        const sample = channel[index];
+        sumSquares += sample * sample;
+        sampleCount += 1;
+      }
+    }
+
+    const rms = sampleCount > 0 ? Math.sqrt(sumSquares / sampleCount) : 0;
+    const decibels = rms > 0 ? 20 * Math.log10(rms) : -100;
+    waveformSamples.push(normalizedLevel(decibels));
+    const time = offset / sampleRate;
+    const isSilent = decibels < SILENCE_THRESHOLD_DB;
+
+    if (isSilent && silenceStart == null) {
+      silenceStart = time;
+    } else if (!isSilent && silenceStart != null) {
+      if (time - silenceStart >= MINIMUM_SILENCE_SECONDS) {
+        silentRanges.push({ start: silenceStart, end: time });
+      }
+      silenceStart = null;
+    }
+
+    if (waveformSamples.length % 300 === 0) {
+      await new Promise((resolve) => window.setTimeout(resolve, 0));
+    }
+  }
+
+  if (silenceStart != null && buffer.duration - silenceStart >= MINIMUM_SILENCE_SECONDS) {
+    silentRanges.push({ start: silenceStart, end: buffer.duration });
+  }
+
+  return {
+    durationMs: buffer.duration * 1000,
+    samples: waveformSamples,
+    silentRanges
+  };
+};
+
+const writeAscii = (view, offset, value) => {
+  for (let index = 0; index < value.length; index += 1) {
+    view.setUint8(offset + index, value.charCodeAt(index));
+  }
+};
+
+export const encodeAudioBufferAsWav = (buffer) => {
+  const channelCount = Math.min(buffer.numberOfChannels, 2);
+  const bytesPerSample = 2;
+  const frameCount = buffer.length;
+  const dataLength = frameCount * channelCount * bytesPerSample;
+  const arrayBuffer = new ArrayBuffer(44 + dataLength);
+  const view = new DataView(arrayBuffer);
+
+  writeAscii(view, 0, 'RIFF');
+  view.setUint32(4, 36 + dataLength, true);
+  writeAscii(view, 8, 'WAVE');
+  writeAscii(view, 12, 'fmt ');
+  view.setUint32(16, 16, true);
+  view.setUint16(20, 1, true);
+  view.setUint16(22, channelCount, true);
+  view.setUint32(24, buffer.sampleRate, true);
+  view.setUint32(28, buffer.sampleRate * channelCount * bytesPerSample, true);
+  view.setUint16(32, channelCount * bytesPerSample, true);
+  view.setUint16(34, 16, true);
+  writeAscii(view, 36, 'data');
+  view.setUint32(40, dataLength, true);
+
+  let writeOffset = 44;
+  for (let frame = 0; frame < frameCount; frame += 1) {
+    for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
+      const sample = Math.max(-1, Math.min(1, buffer.getChannelData(channelIndex)[frame] || 0));
+      view.setInt16(writeOffset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
+      writeOffset += bytesPerSample;
+    }
+  }
+
+  return new Blob([arrayBuffer], { type: 'audio/wav' });
+};
+
+export const concatenateAudioBlobs = async (originalBlob, continuationBlob) => {
+  const [original, continuation] = await Promise.all([
+    decodeBlob(originalBlob),
+    decodeBlob(continuationBlob)
+  ]);
+  const sampleRate = Math.max(original.sampleRate, continuation.sampleRate);
+  const channelCount = Math.max(original.numberOfChannels, continuation.numberOfChannels, 1);
+  const totalDuration = original.duration + continuation.duration;
+  const OfflineContextClass = window.OfflineAudioContext || window.webkitOfflineAudioContext;
+  if (!OfflineContextClass) throw new Error('当前浏览器不支持续录音频合并。');
+  const offlineContext = new OfflineContextClass(
+    channelCount,
+    Math.ceil(totalDuration * sampleRate),
+    sampleRate
+  );
+
+  const originalSource = offlineContext.createBufferSource();
+  originalSource.buffer = original;
+  originalSource.connect(offlineContext.destination);
+  originalSource.start(0);
+
+  const continuationSource = offlineContext.createBufferSource();
+  continuationSource.buffer = continuation;
+  continuationSource.connect(offlineContext.destination);
+  continuationSource.start(original.duration);
+
+  const rendered = await offlineContext.startRendering();
+  return encodeAudioBufferAsWav(rendered);
+};

+ 182 - 0
src/components/record-detail/recordDetailUtils.js

@@ -0,0 +1,182 @@
+export const formatDuration = (milliseconds, forceHours = false) => {
+  const totalSeconds = Math.max(0, Math.floor((Number(milliseconds) || 0) / 1000));
+  const hours = Math.floor(totalSeconds / 3600);
+  const minutes = Math.floor((totalSeconds % 3600) / 60);
+  const seconds = totalSeconds % 60;
+  if (forceHours || hours > 0) {
+    return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
+  }
+  return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
+};
+
+export const formatChineseDate = (value) => {
+  if (!value) return '—';
+  const date = new Date(value);
+  if (Number.isNaN(date.getTime())) return '—';
+  return new Intl.DateTimeFormat('zh-CN', {
+    year: 'numeric',
+    month: 'long',
+    day: 'numeric'
+  }).format(date);
+};
+
+export const formatChineseTime = (value) => {
+  if (!value) return '—';
+  const date = new Date(value);
+  if (Number.isNaN(date.getTime())) return '—';
+  return new Intl.DateTimeFormat('zh-CN', {
+    hour: '2-digit',
+    minute: '2-digit',
+    second: '2-digit',
+    hour12: false
+  }).format(date);
+};
+
+export const formatChineseDateTime = (value) => {
+  if (!value) return '—';
+  return `${formatChineseDate(value)} ${formatChineseTime(value)}`;
+};
+
+export const isContinuationEvent = (event) => (
+  event?.eventType === 'MARKER' && String(event?.textContent || '').startsWith('续录时间:')
+);
+
+export const eventClientId = (event) => event?.clientId || event?.id;
+
+export const photoAssetClientId = (event) => `${eventClientId(event)}-photo`;
+
+export const findPhotoAsset = (event, assets = []) => {
+  const expected = photoAssetClientId(event).toLowerCase();
+  return assets
+    .filter((asset) => String(asset.kind).toUpperCase() === 'PHOTO')
+    .find((asset) => String(asset.clientId).toLowerCase() === expected);
+};
+
+export const selectLatestAudioAsset = (assets = []) => (
+  assets
+    .filter((asset) => String(asset.kind).toUpperCase() === 'AUDIO')
+    .sort((left, right) => {
+      const leftTime = new Date(left.createdAt || 0).getTime();
+      const rightTime = new Date(right.createdAt || 0).getTime();
+      return rightTime - leftTime;
+    })[0] || null
+);
+
+export const sortEvents = (events = []) => (
+  [...events].sort((left, right) => {
+    const delta = Number(left.relativeTimeMs || 0) - Number(right.relativeTimeMs || 0);
+    if (delta !== 0) return delta;
+    return String(left.createdAt || left.id).localeCompare(String(right.createdAt || right.id));
+  })
+);
+
+export const groupPhotoEvents = (events = []) => (
+  sortEvents(events.filter((event) => event.eventType === 'PHOTO'))
+    .reduce((groups, event) => {
+      const key = String(event.relativeTimeMs || 0);
+      const existing = groups.find((group) => group.key === key);
+      if (existing) existing.events.push(event);
+      else groups.push({ key, relativeTimeMs: Number(event.relativeTimeMs || 0), events: [event] });
+      return groups;
+    }, [])
+);
+
+export const buildChronoFeedItems = (events = []) => {
+  const photoGroups = new Map(groupPhotoEvents(events).map((group) => [group.key, group.events]));
+  const emittedPhotoTimes = new Set();
+  return sortEvents(events).flatMap((event) => {
+    if (event.eventType !== 'PHOTO') return [{ event, photoEvents: [] }];
+    const key = String(event.relativeTimeMs || 0);
+    if (emittedPhotoTimes.has(key)) return [];
+    emittedPhotoTimes.add(key);
+    return [{ event, photoEvents: photoGroups.get(key) || [event] }];
+  });
+};
+
+export const countSessionEvents = (events = []) => ({
+  photoCount: events.filter((event) => event.eventType === 'PHOTO').length,
+  noteCount: events.filter((event) => (
+    event.eventType === 'NOTE'
+      || (event.eventType === 'MARKER' && !isContinuationEvent(event))
+  )).length
+});
+
+export const makeLocation = (event) => {
+  if (event?.latitude == null || event?.longitude == null) return null;
+  return {
+    name: event.locationName || event.locationAddress || '所在位置',
+    address: event.locationAddress || '',
+    latitude: Number(event.latitude),
+    longitude: Number(event.longitude)
+  };
+};
+
+export const applyLocation = (event, location) => ({
+  ...event,
+  locationName: location?.name || '',
+  locationAddress: location?.address || '',
+  latitude: location?.latitude ?? null,
+  longitude: location?.longitude ?? null
+});
+
+export const createEvent = ({
+  relativeTimeMs,
+  eventType,
+  textContent = '',
+  location = null
+}) => {
+  const id = crypto.randomUUID();
+  return applyLocation({
+    id,
+    clientId: id,
+    relativeTimeMs: Math.round(relativeTimeMs),
+    eventType,
+    textContent,
+    createdAt: new Date().toISOString()
+  }, location);
+};
+
+export const buildSessionPayload = ({
+  session,
+  title = session.title,
+  events = session.events || [],
+  durationMs = session.durationMs,
+  endTime = session.endTime,
+  deletedEventClientIds = []
+}) => ({
+  clientId: session.clientId || session.id,
+  title: title.trim(),
+  startTime: session.startTime,
+  endTime,
+  durationMs: Math.round(Number(durationMs) || 0),
+  baseRevision: Number(session.revision) > 0 ? Number(session.revision) : undefined,
+  deletedEventClientIds,
+  events: events.map((event) => ({
+    clientId: eventClientId(event),
+    relativeTimeMs: Math.round(Number(event.relativeTimeMs) || 0),
+    eventType: event.eventType,
+    textContent: event.textContent || '',
+    voiceStartOffsetMs: event.voiceStartOffsetMs ?? null,
+    voiceEndOffsetMs: event.voiceEndOffsetMs ?? null,
+    locationName: event.locationName || '',
+    locationAddress: event.locationAddress || '',
+    latitude: event.latitude ?? null,
+    longitude: event.longitude ?? null
+  }))
+});
+
+export const sanitizeAudioFileName = (title, fallbackExtension = 'm4a') => {
+  const clean = String(title || '')
+    .replace(/[/:\\?%*|"<>]/g, '-')
+    .replace(/[\u0000-\u001f\u007f-\u009f\r\n]/g, '-')
+    .replace(/[.\s]+$/g, '')
+    .trim();
+  return `${clean || '现场记录'}.${fallbackExtension || 'm4a'}`;
+};
+
+export const errorMessage = (error, fallback = '操作失败,请稍后重试。') => (
+  error?.response?.data?.message
+    || error?.message
+    || error?.data?.message
+    || fallback
+);

+ 15 - 42
src/context/AuthContext.jsx

@@ -15,7 +15,9 @@ export const AuthProvider = ({ children }) => {
           const res = await authAPI.getProfile();
           if (res.code === 0 && res.data) {
             setUser(res.data);
-          } else setUser(null);
+          } else {
+            setUser(null);
+          }
         } catch (e) {
           localStorage.removeItem('celestia_token');
           localStorage.removeItem('celestia_refresh_token');
@@ -66,54 +68,25 @@ export const AuthProvider = ({ children }) => {
   };
 
   const logout = async () => {
-    try { await authAPI.logout(); } catch (_) { /* Always clear local credentials. */ }
+    try {
+      await authAPI.logout();
+    } catch (_) {
+      /* Always clear local credentials. */
+    }
     setToken('');
     setUser(null);
     localStorage.removeItem('celestia_token');
     localStorage.removeItem('celestia_refresh_token');
   };
 
-  const updateProfile = async (data) => {
-    try {
-      const res = await authAPI.updateProfile(data);
-      if (res.code === 0 && res.data) {
-        setUser(res.data);
-        return { success: true, user: res.data };
-      }
-      return { success: false, message: res.message || '更新个人资料失败' };
-    } catch (err) {
-      return { success: false, message: err.message || '更新个人资料失败' };
-    }
-  };
-
-  const changePassword = async (oldPassword, newPassword) => {
-    try {
-      const res = await authAPI.changePassword(oldPassword, newPassword);
-      if (res.code === 0) {
-        return { success: true };
-      }
-      return { success: false, message: res.message || '原密码错误或修改失败' };
-    } catch (err) {
-      return { success: false, message: err.message || '原密码错误或修改失败' };
-    }
-  };
-
-  const updateUser = (updatedUser) => {
-    setUser(prev => (prev ? { ...prev, ...updatedUser } : updatedUser));
-  };
-
   return (
-    <AuthContext.Provider value={{ 
-      user, 
-      token, 
-      loading, 
-      login, 
-      register, 
-      logout, 
-      updateProfile, 
-      changePassword, 
-      updateUser,
-      updateProfileState: updateUser 
+    <AuthContext.Provider value={{
+      user,
+      token,
+      loading,
+      login,
+      register,
+      logout
     }}>
       {children}
     </AuthContext.Provider>

+ 37 - 0
src/index.css

@@ -115,3 +115,40 @@
 .animate-wave-bar {
   animation: waveBar 1.2s infinite ease-in-out;
 }
+
+/* iOS Sync Golden Dot (#E6C687) */
+.gold-sync-dot {
+  width: 6px;
+  height: 6px;
+  background-color: #E6C687;
+  border-radius: 9999px;
+  box-shadow: 0 0 8px rgba(230, 198, 135, 0.6);
+  display: inline-block;
+}
+
+.gold-sync-badge {
+  display: inline-flex;
+  items-center: center;
+  gap: 6px;
+  padding: 2px 8px;
+  border-radius: 9999px;
+  background-color: rgba(230, 198, 135, 0.12);
+  border: 1px solid rgba(230, 198, 135, 0.25);
+  color: #D4AD5A;
+  font-size: 11px;
+  font-weight: 500;
+}
+
+.dark .gold-sync-badge {
+  background-color: rgba(230, 198, 135, 0.15);
+  border-color: rgba(230, 198, 135, 0.3);
+  color: #E6C687;
+}
+
+.scrollbar-none {
+  scrollbar-width: none;
+}
+
+.scrollbar-none::-webkit-scrollbar {
+  display: none;
+}

+ 0 - 261
src/pages/Dashboard.jsx

@@ -1,261 +0,0 @@
-import React, { useState, useEffect, useRef } from 'react';
-import { Mic, List, ChevronRight, Camera, FileText, Square, Radio, HardDrive, Activity } from 'lucide-react';
-import { syncAPI, sessionAPI, deviceAPI } from '../api';
-
-export const Dashboard = ({ setActiveTab, onSelectSession, onOpenBindModal }) => {
-  const [summary, setSummary] = useState(null);
-  const [recentSessions, setRecentSessions] = useState([]);
-  const [devices, setDevices] = useState([]);
-  const [loading, setLoading] = useState(true);
-
-  // Web Voice Recording State
-  const [isRecording, setIsRecording] = useState(false);
-  const [recordingTime, setRecordingTime] = useState(0);
-  const [audioStream, setAudioStream] = useState(null);
-  const [mediaRecorder, setMediaRecorder] = useState(null);
-  const timerRef = useRef(null);
-
-  const fetchDashboardData = async () => {
-    try {
-      const [sumRes, sessRes, devRes] = await Promise.all([
-        syncAPI.getSummary().catch(() => null),
-        sessionAPI.getSessions().catch(() => null),
-        deviceAPI.getDevices().catch(() => null)
-      ]);
-
-      if (sumRes && sumRes.code === 0) setSummary(sumRes.data);
-      if (sessRes && sessRes.code === 0 && Array.isArray(sessRes.data)) {
-        setRecentSessions(sessRes.data);
-      }
-      if (devRes && devRes.code === 0) setDevices(devRes.data);
-    } catch (e) {
-      console.warn("Dashboard fetch error:", e);
-    } finally {
-      setLoading(false);
-    }
-  };
-
-  useEffect(() => {
-    fetchDashboardData();
-  }, []);
-
-  // Web Audio Recording logic
-  const handleStartRecording = async () => {
-    try {
-      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
-      setAudioStream(stream);
-      const recorder = new MediaRecorder(stream);
-      const chunks = [];
-
-      recorder.ondataavailable = (e) => {
-        if (e.data.size > 0) chunks.push(e.data);
-      };
-
-      recorder.onstop = async () => {
-        const now = new Date();
-        const formattedDate = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
-        const title = `现场记录 · ${formattedDate}`;
-
-        try {
-          const res = await sessionAPI.createSession({
-            title: title,
-            durationMs: recordingTime * 1000,
-            photoCount: 0,
-            noteCount: 1,
-          });
-          if (res.code === 0) {
-            fetchDashboardData();
-          }
-        } catch (err) {
-          console.warn("Failed to create session:", err);
-        }
-      };
-
-      recorder.start();
-      setMediaRecorder(recorder);
-      setIsRecording(true);
-      setRecordingTime(0);
-
-      timerRef.current = setInterval(() => {
-        setRecordingTime((prev) => prev + 1);
-      }, 1000);
-    } catch (err) {
-      alert("无法访问麦克风。请检查浏览器声音权限设置。");
-      console.error(err);
-    }
-  };
-
-  const handleStopRecording = () => {
-    if (mediaRecorder && mediaRecorder.state !== 'inactive') {
-      mediaRecorder.stop();
-    }
-    if (audioStream) {
-      audioStream.getTracks().forEach((track) => track.stop());
-    }
-    if (timerRef.current) {
-      clearInterval(timerRef.current);
-    }
-    setIsRecording(false);
-  };
-
-  const formatSeconds = (totalSeconds) => {
-    const mins = Math.floor(totalSeconds / 60);
-    const secs = totalSeconds % 60;
-    return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
-  };
-
-  return (
-    <div className="space-y-8 animate-fade-in pb-24">
-      {/* 1. iOS Central Recording Trigger Section (Matching Screenshot) */}
-      <div className="flex flex-col items-center justify-center py-10 my-4 text-center">
-        {/* Pulsing Outer/Inner Circle Trigger */}
-        <div className="relative group cursor-pointer" onClick={isRecording ? handleStopRecording : handleStartRecording}>
-          {/* Outer Ring */}
-          <div className={`w-36 h-36 sm:w-44 sm:h-44 rounded-full border border-black/10 flex items-center justify-center transition-all ${isRecording ? 'border-red-500/60 animate-record-pulse' : 'group-hover:border-black/20'}`}>
-            {/* Inner Ring Button */}
-            <div className={`w-24 h-24 sm:w-28 sm:h-28 rounded-full border border-black/10 flex items-center justify-center transition-transform ${isRecording ? 'bg-red-500 text-white scale-105' : 'bg-[#FAFAFC] text-[#1D1D1F] group-hover:scale-105 shadow-2xs'}`}>
-              {isRecording ? (
-                <Square className="w-8 h-8 fill-current" />
-              ) : (
-                <Mic className="w-8 h-8 sm:w-9 sm:h-9 stroke-[1.5]" />
-              )}
-            </div>
-          </div>
-        </div>
-
-        {/* Text Subtitles matching iOS screenshot */}
-        <div className="mt-6 space-y-1">
-          <h2 className="text-base sm:text-lg font-medium text-[#1D1D1F] tracking-tight">
-            {isRecording ? `正在现场录音 · ${formatSeconds(recordingTime)}` : '新建现场记录'}
-          </h2>
-          <p className="text-[10px] font-mono tracking-widest-tech text-slate-400">
-            {isRecording ? '点击停止并保存现场音频' : '点击开启全新现场录音'}
-          </p>
-        </div>
-
-        {/* Recording Wave Indicator */}
-        {isRecording && (
-          <div className="flex items-center space-x-1.5 mt-4">
-            <div className="w-1 bg-red-500 animate-wave-bar" style={{ animationDelay: '0s' }}></div>
-            <div className="w-1 bg-red-500 animate-wave-bar" style={{ animationDelay: '0.2s' }}></div>
-            <div className="w-1 bg-red-500 animate-wave-bar" style={{ animationDelay: '0.4s' }}></div>
-            <div className="w-1 bg-red-500 animate-wave-bar" style={{ animationDelay: '0.1s' }}></div>
-          </div>
-        )}
-      </div>
-
-      {/* 2. Recent Sessions Section (最近记录 - Matching Screenshot) */}
-      <div className="space-y-4">
-        <div className="flex items-center justify-between">
-          <div className="flex items-center space-x-2 text-[#1D1D1F]">
-            <List className="w-4 h-4 text-slate-500" />
-            <h3 className="text-sm font-semibold tracking-tight">最近记录</h3>
-          </div>
-        </div>
-
-        {/* Sessions Horizontal Scroll / Grid */}
-        {recentSessions.length === 0 ? (
-          <div className="business-card rounded-2xl p-8 text-center text-xs text-slate-400 font-mono">
-            暂无现场记录,点击上方按钮开启全新录音
-          </div>
-        ) : (
-          <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
-            {recentSessions.slice(0, 3).map((session) => (
-              <div
-                key={session.id}
-                onClick={() => onSelectSession(session)}
-                className="business-card-interactive p-4 rounded-2xl cursor-pointer space-y-3 flex flex-col justify-between group"
-              >
-                <div>
-                  <h4 className="text-xs font-semibold text-[#1D1D1F] group-hover:text-slate-600 transition-colors line-clamp-1">
-                    {session.title || '现场记录 · 2026.07.22'}
-                  </h4>
-                </div>
-
-                <div className="flex items-center justify-between text-[11px] font-mono text-slate-500">
-                  <div className="flex items-center space-x-1.5">
-                    <span className="inline-block w-2.5 h-2.5 text-slate-400">🎙</span>
-                    <span>{session.durationFormatted || formatSeconds(Math.floor((session.durationMs || 13000) / 1000))}</span>
-                  </div>
-
-                  <div className="flex items-center space-x-3">
-                    <span className="flex items-center space-x-1">
-                      <Camera className="w-3 h-3 text-slate-400" />
-                      <span>{session.photoCount || 0}</span>
-                    </span>
-                    <span className="flex items-center space-x-1">
-                      <FileText className="w-3 h-3 text-slate-400" />
-                      <span>{session.noteCount || 1}</span>
-                    </span>
-                  </div>
-                </div>
-              </div>
-            ))}
-          </div>
-        )}
-
-        {/* View History List Button (Matching iOS screenshot: 查看历史列表 >) */}
-        <div className="flex justify-center pt-2">
-          <button
-            onClick={() => setActiveTab('records')}
-            className="flex items-center space-x-1.5 px-5 py-2 rounded-xl bg-white border border-black/15 text-xs text-slate-800 font-medium shadow-2xs hover:bg-slate-50 transition-all"
-          >
-            <span>查看历史列表</span>
-            <ChevronRight className="w-3.5 h-3.5 text-slate-500" />
-          </button>
-        </div>
-      </div>
-
-      {/* 3. Hardware & Cloud Overview Metrics (Strict Light Mode) */}
-      <div className="pt-6 border-t border-black/10 space-y-4">
-        <h3 className="text-xs font-mono font-semibold uppercase tracking-widest text-slate-400">
-          “微光 Spark” 状态与多端同步
-        </h3>
-
-        <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
-          {/* Card 1: Spark Device */}
-          <div className="business-card p-4 rounded-2xl space-y-2">
-            <div className="flex items-center justify-between text-xs text-slate-500">
-              <span>绑定设备</span>
-              <Radio className="w-4 h-4 text-slate-700" />
-            </div>
-            <div className="text-xl font-bold font-mono text-[#1D1D1F]">
-              {devices.length || 1} <span className="text-xs font-normal text-slate-400">台</span>
-            </div>
-            <div className="text-[10px] font-mono text-emerald-600">
-              ● BLE-Spark-Pro (已就绪)
-            </div>
-          </div>
-
-          {/* Card 2: Record Count */}
-          <div className="business-card p-4 rounded-2xl space-y-2">
-            <div className="flex items-center justify-between text-xs text-slate-500">
-              <span>同步记录总数</span>
-              <Activity className="w-4 h-4 text-slate-700" />
-            </div>
-            <div className="text-xl font-bold font-mono text-[#1D1D1F]">
-              {summary?.sessionCount || recentSessions.length} <span className="text-xs font-normal text-slate-400">条</span>
-            </div>
-            <div className="text-[10px] font-mono text-slate-400">
-              账号专属数据隔离
-            </div>
-          </div>
-
-          {/* Card 3: Storage */}
-          <div className="business-card p-4 rounded-2xl space-y-2">
-            <div className="flex items-center justify-between text-xs text-slate-500">
-              <span>云端存储</span>
-              <HardDrive className="w-4 h-4 text-slate-700" />
-            </div>
-            <div className="text-xl font-bold font-mono text-[#1D1D1F]">
-              {summary?.storage?.cloudStorageUsedMB ?? 12} <span className="text-xs font-normal text-slate-400">/ 5120 MB</span>
-            </div>
-            <div className="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden">
-              <div className="bg-slate-700 h-full w-[5%]"></div>
-            </div>
-          </div>
-        </div>
-      </div>
-    </div>
-  );
-};

+ 0 - 126
src/pages/DevicesPage.jsx

@@ -1,126 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { Radio, Battery, HardDrive, Wifi, Plus, CheckCircle2, ShieldCheck, RefreshCw, Smartphone } from 'lucide-react';
-import { deviceAPI } from '../api';
-
-export const DevicesPage = ({ onOpenBindModal }) => {
-  const [devices, setDevices] = useState([]);
-  const [loading, setLoading] = useState(true);
-
-  const fetchDevices = async () => {
-    setLoading(true);
-    try {
-      const res = await deviceAPI.getDevices();
-      setDevices(res.code === 0 && Array.isArray(res.data) ? res.data : []);
-    } catch (e) {
-      console.warn("Fetch devices error:", e);
-      setDevices([]);
-    } finally {
-      setLoading(false);
-    }
-  };
-
-  useEffect(() => {
-    fetchDevices();
-  }, []);
-
-  return (
-    <div className="space-y-6 animate-fade-in pb-20">
-      {/* Page Header */}
-      <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
-        <div>
-          <div className="flex items-center space-x-2">
-            <Radio className="w-4 h-4 text-slate-700 dark:text-slate-300" />
-            <h2 className="text-base font-semibold text-slate-900 dark:text-white">
-              “微光 Spark” 录音硬件设备
-            </h2>
-          </div>
-          <p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
-            管理与当前账号綁定的 Spark 随身录音硬件设备,查看蓝牙信号与云端同步状态。
-          </p>
-        </div>
-
-        <button
-          onClick={onOpenBindModal}
-          className="flex items-center space-x-2 px-4 py-2 rounded-xl bg-slate-900 text-white dark:bg-white dark:text-slate-900 text-xs font-semibold shadow-sm hover:opacity-90 transition-all"
-        >
-          <Plus className="w-4 h-4" />
-          <span>绑定新 Spark 设备</span>
-        </button>
-      </div>
-
-      {/* Device Cards Grid */}
-      {loading ? (
-        <div className="text-center py-12 text-xs font-mono text-slate-400">
-          检索中...
-        </div>
-      ) : devices.length === 0 ? (
-        <div className="business-card p-8 rounded-2xl text-center space-y-3">
-          <Radio className="w-8 h-8 text-slate-400 mx-auto stroke-1" />
-          <div className="text-xs font-mono text-slate-500">暂未绑定 Spark 硬件设备</div>
-          <button
-            onClick={onOpenBindModal}
-            className="text-xs text-slate-800 dark:text-slate-200 underline font-medium"
-          >
-            点击绑定首台“微光 Spark”
-          </button>
-        </div>
-      ) : (
-        <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
-          {devices.map((device) => (
-            <div key={device.id || device.macAddress} className="business-card p-5 rounded-2xl space-y-4">
-              <div className="flex items-center justify-between">
-                <div className="flex items-center space-x-3">
-                  <div className="w-10 h-10 rounded-xl bg-black/5 dark:bg-white/10 flex items-center justify-center text-slate-800 dark:text-white border border-black/10 dark:border-white/10">
-                    <Radio className="w-5 h-5" />
-                  </div>
-                  <div>
-                    <h3 className="text-sm font-bold text-slate-900 dark:text-white">
-                      {device.deviceName || 'Spark-Pro-0812'}
-                    </h3>
-                    <p className="text-[10px] font-mono text-slate-400">
-                      MAC: {device.macAddress || '7C:D1:C3:92:44:FA'}
-                    </p>
-                  </div>
-                </div>
-
-                <span className="px-2 py-0.5 text-[10px] font-mono font-semibold text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 rounded border border-emerald-500/20">
-                  BLE 已就绪
-                </span>
-              </div>
-
-              <div className="grid grid-cols-2 gap-3 pt-2">
-                <div 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-[11px] text-slate-500">
-                    <span>剩余电量</span>
-                    <Battery className="w-3.5 h-3.5 text-emerald-500" />
-                  </div>
-                  <div className="text-sm font-bold font-mono text-slate-900 dark:text-white">
-                    {device.batteryLevel ?? 95}%
-                  </div>
-                </div>
-
-                <div 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-[11px] text-slate-500">
-                    <span>可用闪存</span>
-                    <HardDrive className="w-3.5 h-3.5 text-slate-400" />
-                  </div>
-                  <div className="text-sm font-bold font-mono text-slate-900 dark:text-white">
-                    13.4 GB / 16GB
-                  </div>
-                </div>
-              </div>
-
-              <div className="flex items-center justify-between text-[10px] font-mono text-slate-400 pt-1">
-                <div className="flex items-center space-x-1.5">
-                  <ShieldCheck className="w-3.5 h-3.5 text-slate-500" />
-                  <span>硬件固件 v2.1.0</span>
-                </div>
-                <span>自动连云同步</span>
-              </div>
-            </div>
-          ))}
-        </div>
-      )}
-    </div>
-  );
-};

+ 0 - 211
src/pages/Login.jsx

@@ -1,211 +0,0 @@
-import React, { useState } from 'react';
-import { Sparkles, User, Mail, Lock, ShieldCheck, Loader2 } from 'lucide-react';
-import { useAuth } from '../context/AuthContext';
-
-export const Login = ({ onSuccess }) => {
-  const { login, register } = useAuth();
-  const [selectedTab, setSelectedTab] = useState('login'); // 'login' | 'register'
-  const [usernameInput, setUsernameInput] = useState('');
-  const [identifierInput, setIdentifierInput] = useState('');
-  const [passwordInput, setPasswordInput] = useState('');
-  const [confirmPasswordInput, setConfirmPasswordInput] = useState('');
-  const [errorMessage, setErrorMessage] = useState('');
-  const [isProcessing, setIsProcessing] = useState(false);
-
-  const handleSubmit = async (e) => {
-    e.preventDefault();
-    setErrorMessage('');
-
-    if (selectedTab === 'login') {
-      if (!identifierInput.trim()) {
-        setErrorMessage('请输入手机号或邮箱');
-        return;
-      }
-      if (passwordInput.length < 6) {
-        setErrorMessage('密码不能少于 6 位');
-        return;
-      }
-
-      setIsProcessing(true);
-      const res = await login(identifierInput.trim(), passwordInput);
-      setIsProcessing(false);
-
-      if (res.success) {
-        if (onSuccess) onSuccess();
-      } else {
-        setErrorMessage(res.message || '登录失败');
-      }
-    } else {
-      const name = usernameInput.trim();
-      if (!name) {
-        setErrorMessage('请输入用户昵称');
-        return;
-      }
-      if (!identifierInput.trim()) {
-        setErrorMessage('请输入注册手机号或邮箱');
-        return;
-      }
-      if (passwordInput.length < 6) {
-        setErrorMessage('密码不能少于 6 位');
-        return;
-      }
-      if (passwordInput !== confirmPasswordInput) {
-        setErrorMessage('两次输入的密码不一致');
-        return;
-      }
-
-      setIsProcessing(true);
-      const res = await register(name, identifierInput.trim(), passwordInput);
-      setIsProcessing(false);
-
-      if (res.success) {
-        if (onSuccess) onSuccess();
-      } else {
-        setErrorMessage(res.message || '注册失败');
-      }
-    }
-  };
-
-  return (
-    <div className="min-h-screen business-grid-bg flex items-center justify-center p-4 sm:p-6 transition-colors">
-      <div className="w-full max-w-sm space-y-6">
-        {/* Brand Hero */}
-        <div className="text-center space-y-3">
-          <div className="w-16 h-16 rounded-full bg-slate-900 dark:bg-white/10 flex items-center justify-center mx-auto border border-black/10 dark:border-white/20 shadow-lg">
-            <Sparkles className="w-8 h-8 text-white dark:text-amber-300" />
-          </div>
-
-          <div className="space-y-1">
-            <h1 className="text-xl font-bold font-mono tracking-widest-tech uppercase text-slate-900 dark:text-white">
-              星痕 CelestiaTrace
-            </h1>
-            <p className="text-xs text-slate-500 font-sans tracking-wide">
-              倾听,然后深刻。
-            </p>
-          </div>
-        </div>
-
-        {/* Login/Register Card */}
-        <div className="bg-[#0B0C10] border border-white/15 p-6 rounded-3xl space-y-5 text-white shadow-2xl">
-          {/* Header */}
-          <div className="text-center space-y-1">
-            <h2 className="text-lg font-bold">
-              {selectedTab === 'login' ? '账号登录' : '创建新账号'}
-            </h2>
-            <p className="text-xs text-slate-400">
-              {selectedTab === 'login'
-                ? '登录以同步录音并管理专属设备'
-                : '注册后可将本机记录安全同步至云端'}
-            </p>
-          </div>
-
-          {/* Tab Switcher */}
-          <div className="flex rounded-xl bg-white/[0.06] p-1 text-xs font-medium border border-white/10">
-            <button
-              type="button"
-              onClick={() => {
-                setSelectedTab('login');
-                setErrorMessage('');
-              }}
-              className={`flex-1 py-2 rounded-lg transition-all ${
-                selectedTab === 'login'
-                  ? 'bg-white/15 text-white font-semibold shadow-sm'
-                  : 'text-slate-400 hover:text-white'
-              }`}
-            >
-              登录
-            </button>
-            <button
-              type="button"
-              onClick={() => {
-                setSelectedTab('register');
-                setErrorMessage('');
-              }}
-              className={`flex-1 py-2 rounded-lg transition-all ${
-                selectedTab === 'register'
-                  ? 'bg-white/15 text-white font-semibold shadow-sm'
-                  : 'text-slate-400 hover:text-white'
-              }`}
-            >
-              注册账号
-            </button>
-          </div>
-
-          {/* Form */}
-          <form onSubmit={handleSubmit} className="space-y-4">
-            {selectedTab === 'register' && (
-              <div className="relative">
-                <User className="w-4 h-4 text-slate-400 absolute left-3.5 top-3.5" />
-                <input
-                  type="text"
-                  value={usernameInput}
-                  onChange={(e) => setUsernameInput(e.target.value)}
-                  placeholder="设置昵称 (如: 星痕探索者)"
-                  className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/[0.04] border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-white/30 transition-all"
-                />
-              </div>
-            )}
-
-            <div className="relative">
-              <Mail className="w-4 h-4 text-slate-400 absolute left-3.5 top-3.5" />
-              <input
-                type="text"
-                value={identifierInput}
-                onChange={(e) => setIdentifierInput(e.target.value)}
-                placeholder="手机号或电子邮箱"
-                className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/[0.04] border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-white/30 transition-all"
-              />
-            </div>
-
-            <div className="relative">
-              <Lock className="w-4 h-4 text-slate-400 absolute left-3.5 top-3.5" />
-              <input
-                type="password"
-                value={passwordInput}
-                onChange={(e) => setPasswordInput(e.target.value)}
-                placeholder="密码 (至少 6 位)"
-                className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/[0.04] border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-white/30 transition-all"
-              />
-            </div>
-
-            {selectedTab === 'register' && (
-              <div className="relative">
-                <ShieldCheck className="w-4 h-4 text-slate-400 absolute left-3.5 top-3.5" />
-                <input
-                  type="password"
-                  value={confirmPasswordInput}
-                  onChange={(e) => setConfirmPasswordInput(e.target.value)}
-                  placeholder="再次确认密码"
-                  className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/[0.04] border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-white/30 transition-all"
-                />
-              </div>
-            )}
-
-            {errorMessage && (
-              <div className="p-3 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-400 text-xs font-mono">
-                {errorMessage}
-              </div>
-            )}
-
-            <button
-              type="submit"
-              disabled={isProcessing}
-              className="w-full py-3 rounded-xl bg-white text-slate-950 font-semibold text-xs shadow-lg hover:bg-slate-100 transition-all flex items-center justify-center space-x-2 disabled:opacity-50"
-            >
-              {isProcessing ? (
-                <Loader2 className="w-4 h-4 animate-spin text-slate-900" />
-              ) : (
-                <span>{selectedTab === 'login' ? '安全登录' : '注册并自动登录'}</span>
-              )}
-            </button>
-          </form>
-        </div>
-
-        <div className="text-center text-[10px] font-mono text-slate-400 space-y-1">
-          <div>纯粹星辰 Pure Cosmos 出品</div>
-          <div>多端硬件 BLE 蓝牙通信协议受保护</div>
-        </div>
-      </div>
-    </div>
-  );
-};

+ 0 - 275
src/pages/ProfilePage.jsx

@@ -1,275 +0,0 @@
-import React, { useState } from 'react';
-import { 
-  User, Mail, Phone, Lock, LogOut, ShieldCheck, RefreshCw, 
-  CheckCircle2, Sliders, Edit3, Key, UserPlus, Cpu, AlertCircle, ChevronRight 
-} from 'lucide-react';
-import { useAuth } from '../context/AuthContext';
-import { syncAPI } from '../api';
-
-export const ProfilePage = ({ 
-  onOpenAuthModal, 
-  onOpenEditProfileModal, 
-  onOpenChangePasswordModal, 
-  onOpenSyncAuthPrompt,
-  onOpenBindModal
-}) => {
-  const { user, logout } = useAuth();
-  const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
-  const [syncing, setSyncing] = useState(false);
-  const [syncToast, setSyncToast] = useState('');
-  const [lastSyncDate, setLastSyncDate] = useState(() => localStorage.getItem('celestia_last_sync') || null);
-
-  const handleTriggerSync = async () => {
-    if (!user) {
-      onOpenSyncAuthPrompt();
-      return;
-    }
-
-    setSyncing(true);
-    setSyncToast('');
-    try {
-      await syncAPI.triggerManualSync();
-      const nowStr = new Date().toLocaleString();
-      setLastSyncDate(nowStr);
-      localStorage.setItem('celestia_last_sync', nowStr);
-      setSyncToast('服务器已确认全部记录与可用附件。');
-      setTimeout(() => setSyncToast(''), 4000);
-    } catch (err) {
-      setSyncToast(err?.message || '同步完成');
-      setTimeout(() => setSyncToast(''), 3000);
-    } finally {
-      setSyncing(false);
-    }
-  };
-
-  return (
-    <div className="space-y-5 animate-fade-in pb-20 max-w-2xl mx-auto">
-      {/* Header */}
-      <div>
-        <div className="flex items-center space-x-2">
-          <User className="w-5 h-5 text-slate-800 dark:text-slate-200" />
-          <h2 className="text-lg font-bold text-slate-900 dark:text-white">
-            个人中心
-          </h2>
-        </div>
-        <p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
-          全功能通用控制中心,同步云端日志与端侧 BLE 硬件状态。
-        </p>
-      </div>
-
-      {/* 1. Account Profile Header Card */}
-      <div className="business-card p-5 rounded-2xl border border-black/10 dark:border-white/10 bg-white dark:bg-slate-900 shadow-sm">
-        {user ? (
-          <div className="flex items-center justify-between">
-            <div className="flex items-center space-x-4">
-              <div className="w-14 h-14 rounded-full bg-slate-900/10 dark:bg-white/10 flex items-center justify-center border border-black/10 dark:border-white/20">
-                <User className="w-7 h-7 text-slate-900 dark:text-white" />
-              </div>
-              <div>
-                <div className="flex items-center space-x-2">
-                  <h3 className="text-base font-bold text-slate-900 dark:text-white">
-                    {user.username}
-                  </h3>
-                  <span className="px-2 py-0.5 rounded text-[10px] font-mono font-bold bg-slate-900 text-white dark:bg-white dark:text-slate-950">
-                    云端账号
-                  </span>
-                </div>
-                <p className="text-xs font-mono text-slate-500 dark:text-slate-400 mt-1">
-                  {user.email || user.phoneNumber || `用户 ID: ${String(user.id || '').substring(0, 12)}`}
-                </p>
-              </div>
-            </div>
-
-            <button
-              onClick={() => setShowLogoutConfirm(true)}
-              className="px-3 py-1.5 rounded-lg border border-rose-500/40 text-rose-600 dark:text-rose-400 hover:bg-rose-500/10 text-xs font-medium transition-colors"
-            >
-              退出
-            </button>
-          </div>
-        ) : (
-          <div className="space-y-4">
-            <div className="flex items-center space-x-3.5">
-              <div className="w-12 h-12 rounded-full bg-slate-100 dark:bg-white/5 flex items-center justify-center border border-black/10 dark:border-white/10">
-                <UserPlus className="w-6 h-6 text-slate-500 dark:text-slate-400" />
-              </div>
-              <div>
-                <h3 className="text-sm font-semibold text-slate-900 dark:text-white">
-                  未登录账号
-                </h3>
-                <p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
-                  登录/注册后支持全功能体验与 BLE 硬件绑定
-                </p>
-              </div>
-            </div>
-
-            <button
-              onClick={onOpenAuthModal}
-              className="w-full py-2.5 rounded-xl bg-slate-900 text-white dark:bg-white dark:text-slate-900 font-semibold text-xs shadow hover:opacity-95 transition-all text-center"
-            >
-              注册 / 登录账号
-            </button>
-          </div>
-        )}
-      </div>
-
-      {/* 2. User Profile Management Card (Only visible when logged in) */}
-      {user && (
-        <div className="business-card p-5 rounded-2xl border border-black/10 dark:border-white/10 bg-white dark:bg-slate-900 shadow-sm space-y-4">
-          <div className="flex items-center space-x-2 text-slate-900 dark:text-white">
-            <Sliders className="w-4 h-4 text-slate-500" />
-            <h4 className="text-xs font-semibold">用户资料管理</h4>
-          </div>
-
-          <div className="grid grid-cols-2 gap-3">
-            <button
-              onClick={onOpenEditProfileModal}
-              className="flex items-center justify-center space-x-2 py-2.5 px-3 rounded-xl border border-black/10 dark:border-white/15 hover:bg-black/5 dark:hover:bg-white/5 text-xs text-slate-800 dark:text-slate-200 font-medium transition-all"
-            >
-              <Edit3 className="w-3.5 h-3.5" />
-              <span>编辑基本资料</span>
-            </button>
-
-            <button
-              onClick={onOpenChangePasswordModal}
-              className="flex items-center justify-center space-x-2 py-2.5 px-3 rounded-xl border border-black/10 dark:border-white/15 hover:bg-black/5 dark:hover:bg-white/5 text-xs text-slate-800 dark:text-slate-200 font-medium transition-all"
-            >
-              <Key className="w-3.5 h-3.5" />
-              <span>修改安全密码</span>
-            </button>
-          </div>
-        </div>
-      )}
-
-      {/* 3. Audio Cloud Sync Card */}
-      <div className="business-card p-5 rounded-2xl border border-black/10 dark:border-white/10 bg-white dark:bg-slate-900 shadow-sm space-y-4">
-        <div className="flex items-center justify-between">
-          <div className="flex items-center space-x-2 text-slate-900 dark:text-white">
-            <RefreshCw className="w-4 h-4 text-slate-500" />
-            <h4 className="text-xs font-semibold">录音云端同步</h4>
-          </div>
-
-          {user ? (
-            <div className="flex items-center space-x-1.5 text-emerald-600 dark:text-emerald-400 text-xs">
-              <span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
-              <span className="text-[11px]">同步功能就绪</span>
-            </div>
-          ) : (
-            <div className="flex items-center space-x-1 text-rose-500 text-xs">
-              <AlertCircle className="w-3.5 h-3.5" />
-              <span className="text-[11px]">需要登录</span>
-            </div>
-          )}
-        </div>
-
-        <div className="space-y-1.5 text-xs text-slate-600 dark:text-slate-400 font-mono">
-          <div className="flex justify-between">
-            <span>上次同步时间</span>
-            <span className="text-slate-900 dark:text-white">
-              {lastSyncDate || '暂无同步记录'}
-            </span>
-          </div>
-
-          {syncToast && (
-            <div className="p-2.5 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-600 dark:text-emerald-400 text-xs flex items-center space-x-2 font-sans mt-2">
-              <CheckCircle2 className="w-4 h-4 flex-shrink-0" />
-              <span>{syncToast}</span>
-            </div>
-          )}
-        </div>
-
-        <button
-          onClick={handleTriggerSync}
-          disabled={syncing}
-          className="w-full py-2.5 rounded-xl border border-black/15 dark:border-white/20 hover:bg-black/5 dark:hover:bg-white/5 text-xs font-medium text-slate-900 dark:text-white transition-all flex items-center justify-center space-x-2 disabled:opacity-50"
-        >
-          <RefreshCw className={`w-3.5 h-3.5 ${syncing ? 'animate-spin' : ''}`} />
-          <span>{syncing ? '正在同步...' : '立即同步录音文件'}</span>
-        </button>
-      </div>
-
-      {/* 4. Device Binding & Management Entrance */}
-      <div className="business-card p-5 rounded-2xl border border-black/10 dark:border-white/10 bg-white dark:bg-slate-900 shadow-sm flex items-center justify-between">
-        <div className="flex items-center space-x-3">
-          <Cpu className="w-5 h-5 text-slate-600 dark:text-slate-400" />
-          <div>
-            <h4 className="text-xs font-semibold text-slate-900 dark:text-white">BLE 硬件设备绑定</h4>
-            <p className="text-[11px] text-slate-500 dark:text-slate-400 mt-0.5">绑定与管理 CelestiaTrace 端侧近场硬件设备</p>
-          </div>
-        </div>
-
-        <button
-          onClick={() => {
-            if (!user) {
-              onOpenAuthModal();
-            } else {
-              onOpenBindModal();
-            }
-          }}
-          className="flex items-center space-x-1 text-xs text-slate-700 dark:text-slate-300 font-medium hover:underline"
-        >
-          <span>绑定设备</span>
-          <ChevronRight className="w-4 h-4" />
-        </button>
-      </div>
-
-      {/* 5. Account Security & Cloud Compliance Card */}
-      <div className="business-card p-5 rounded-2xl border border-black/10 dark:border-white/10 bg-white dark:bg-slate-900 shadow-sm space-y-3">
-        <div className="flex items-center space-x-2 text-slate-900 dark:text-white">
-          <ShieldCheck className="w-4 h-4 text-slate-500" />
-          <h4 className="text-xs font-semibold">安全与云端合规</h4>
-        </div>
-
-        <div className="space-y-2 text-xs font-mono">
-          <div className="flex justify-between text-slate-600 dark:text-slate-400">
-            <span>数据存储模式</span>
-            <span className="text-slate-900 dark:text-slate-200">IndexedDB 本地优先 + 云端同步</span>
-          </div>
-          <div className="flex justify-between text-slate-600 dark:text-slate-400 border-t border-black/5 dark:border-white/5 pt-2">
-            <span>登录凭据</span>
-            <span className="text-slate-900 dark:text-slate-200">Web LocalStorage JWT Token</span>
-          </div>
-          <div className="flex justify-between text-slate-600 dark:text-slate-400 border-t border-black/5 dark:border-white/5 pt-2">
-            <span>传输保护</span>
-            <span className="text-slate-900 dark:text-slate-200">HTTPS / TLS</span>
-          </div>
-        </div>
-      </div>
-
-      {/* Footer */}
-      <div className="text-center text-[10px] font-mono text-slate-400 space-y-1 pt-4">
-        <div className="tracking-widest uppercase font-bold">CELESTIA TRACE USER & DEVICE</div>
-        <div>安全本地注册与极简日志空间</div>
-      </div>
-
-      {/* Logout Confirmation Dialog Modal */}
-      {showLogoutConfirm && (
-        <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-fade-in">
-          <div className="w-full max-w-sm bg-[#0B0C10] border border-white/15 rounded-2xl p-6 text-white space-y-5 shadow-2xl">
-            <h3 className="text-base font-bold text-center">确定要退出登录吗?</h3>
-            <p className="text-xs text-slate-400 text-center">
-              退出后将停止录音云端同步,本机已有记录将安全保留。
-            </p>
-            <div className="flex space-x-3 pt-2">
-              <button
-                onClick={() => setShowLogoutConfirm(false)}
-                className="flex-1 py-2.5 rounded-xl border border-white/15 text-xs text-slate-300 hover:bg-white/10"
-              >
-                取消
-              </button>
-              <button
-                onClick={() => {
-                  setShowLogoutConfirm(false);
-                  logout();
-                }}
-                className="flex-1 py-2.5 rounded-xl bg-rose-600 hover:bg-rose-700 text-white font-semibold text-xs shadow"
-              >
-                退出登录
-              </button>
-            </div>
-          </div>
-        </div>
-      )}
-    </div>
-  );
-};

+ 95 - 52
src/pages/RecordsPage.jsx

@@ -1,14 +1,17 @@
 import React, { useState, useEffect } from 'react';
-import { Search, Mic, Camera, FileText, Trash2, ChevronRight, Activity, Calendar } from 'lucide-react';
+import { Search, Mic, Camera, FileText, Trash2, ChevronRight, Activity, Clock, Database, RefreshCw } from 'lucide-react';
 import { sessionAPI } from '../api';
 
-export const RecordsPage = ({ onSelectSession }) => {
+export const RecordsPage = ({ onSelectSession, refreshKey = 0 }) => {
   const [sessions, setSessions] = useState([]);
   const [search, setSearch] = useState('');
   const [loading, setLoading] = useState(true);
+  const [refreshing, setRefreshing] = useState(false);
+
+  const fetchSessions = async (isManual = false) => {
+    if (isManual) setRefreshing(true);
+    else setLoading(true);
 
-  const fetchSessions = async () => {
-    setLoading(true);
     try {
       const res = await sessionAPI.getSessions(search);
       setSessions(res.code === 0 && Array.isArray(res.data) ? res.data : []);
@@ -17,16 +20,17 @@ export const RecordsPage = ({ onSelectSession }) => {
       setSessions([]);
     } finally {
       setLoading(false);
+      setRefreshing(false);
     }
   };
 
   useEffect(() => {
     fetchSessions();
-  }, [search]);
+  }, [search, refreshKey]);
 
   const handleDelete = async (e, id) => {
     e.stopPropagation();
-    if (!window.confirm("确定删除此现场记录吗?包含的音频与事件将一并清理。")) return;
+    if (!window.confirm("确定删除此现场记录吗?")) return;
     try {
       await sessionAPI.deleteSession(id);
       setSessions((prev) => prev.filter((s) => s.id !== id));
@@ -36,102 +40,141 @@ export const RecordsPage = ({ onSelectSession }) => {
   };
 
   const formatSeconds = (ms) => {
-    if (!ms) return '00:13';
+    if (!ms) return '00:00';
     const totalSecs = Math.floor(ms / 1000);
     const mins = Math.floor(totalSecs / 60);
     const secs = totalSecs % 60;
     return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
   };
 
+  const formatDate = (isoString) => {
+    if (!isoString) return '';
+    const d = new Date(isoString);
+    return d.toLocaleString('zh-CN', {
+      year: 'numeric',
+      month: '2-digit',
+      day: '2-digit',
+      hour: '2-digit',
+      minute: '2-digit'
+    });
+  };
+
   return (
-    <div className="space-y-6 animate-fade-in pb-20">
-      {/* Header & iOS Search bar */}
-      <div className="space-y-3">
-        <div className="flex items-center justify-between">
+    <div className="space-y-6 pb-16 animate-fade-in">
+      {/* Header & Controls */}
+      <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
+        <div>
           <div className="flex items-center space-x-2">
-            <Activity className="w-4 h-4 text-slate-700 dark:text-slate-300" />
-            <h2 className="text-base font-semibold text-slate-900 dark:text-white">
-              历史现场记录
+            <Activity className="w-5 h-5 text-slate-800 dark:text-slate-200" />
+            <h2 className="text-xl font-bold tracking-tight text-slate-900 dark:text-white">
+              已同步现场记录
             </h2>
           </div>
-          <span className="text-[11px] font-mono text-slate-400">
-            共 {sessions.length} 条记录
-          </span>
+          <p className="text-xs text-slate-500 mt-1">
+            包含音视频、现场快照及时间轴标记
+          </p>
         </div>
 
-        {/* Filter Input */}
-        <div className="relative">
-          <Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-3" />
-          <input
-            type="text"
-            value={search}
-            onChange={(e) => setSearch(e.target.value)}
-            placeholder="搜索“现场记录”标题或时间关键词..."
-            className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white dark:bg-slate-900 border border-black/10 dark:border-white/10 text-slate-900 dark:text-white text-xs focus:outline-none focus:border-black/30 dark:focus:border-white/30 transition-colors shadow-sm"
-          />
+        <div className="flex items-center space-x-3">
+          <div className="px-3 py-1.5 rounded-xl bg-slate-100 dark:bg-slate-800 border border-black/5 dark:border-white/5 text-xs text-slate-600 dark:text-slate-300 font-mono">
+            共 <span className="font-bold text-slate-900 dark:text-white">{sessions.length}</span> 条记录
+          </div>
+          <button
+            onClick={() => fetchSessions(true)}
+            disabled={refreshing || loading}
+            className="p-2 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 transition-colors disabled:opacity-50 cursor-pointer"
+            title="刷新记录"
+          >
+            <RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
+          </button>
         </div>
       </div>
 
-      {/* Grid of Sessions matching iOS minimalist wireframe cards */}
+      {/* Filter Search Input */}
+      <div className="relative">
+        <Search className="w-4 h-4 text-slate-400 absolute left-4 top-3.5" />
+        <input
+          type="text"
+          value={search}
+          onChange={(e) => setSearch(e.target.value)}
+          placeholder="按标题或时间关键词搜索已同步现场记录..."
+          className="w-full pl-11 pr-4 py-3 rounded-2xl bg-white/90 dark:bg-slate-900/90 border border-black/10 dark:border-white/10 text-slate-900 dark:text-white text-xs focus:outline-none focus:border-slate-800 dark:focus:border-slate-200 shadow-sm transition-colors"
+        />
+      </div>
+
+      {/* Grid of Sessions */}
       {loading ? (
-        <div className="text-center py-12 text-xs font-mono text-slate-400">
-          加载云端记录中...
+        <div className="py-20 text-center space-y-3">
+          <div className="w-8 h-8 rounded-full border-2 border-slate-900 border-t-transparent dark:border-white dark:border-t-transparent animate-spin mx-auto" />
+          <p className="text-xs text-slate-400 font-mono">同步数据加载中...</p>
         </div>
       ) : sessions.length === 0 ? (
-        <div className="business-card rounded-2xl p-12 text-center text-xs font-mono text-slate-400">
-          未检索到符合条件的现场记录
+        <div className="bg-white/80 dark:bg-slate-900/80 border border-black/10 dark:border-white/10 rounded-3xl p-12 text-center space-y-3 shadow-sm">
+          <div className="w-12 h-12 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center mx-auto text-slate-400">
+            <Database className="w-6 h-6" />
+          </div>
+          <h3 className="text-sm font-semibold text-slate-900 dark:text-white">
+            暂无已同步的现场记录
+          </h3>
+          <p className="text-xs text-slate-400 max-w-sm mx-auto">
+            手机移动端现场采集上云后,会自动保存在此列表中。
+          </p>
         </div>
       ) : (
         <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
           {sessions.map((session) => (
             <div
               key={session.id}
-              onClick={() => onSelectSession(session)}
-              className="business-card-interactive p-4 rounded-2xl cursor-pointer flex flex-col justify-between space-y-4 group"
+              onClick={() => onSelectSession && onSelectSession(session)}
+              className="bg-white/90 dark:bg-slate-900/90 hover:bg-slate-50 dark:hover:bg-slate-800/80 border border-black/10 dark:border-white/10 p-5 rounded-3xl cursor-pointer flex flex-col justify-between space-y-4 group transition-all shadow-sm hover:shadow-md"
             >
-              <div className="space-y-2">
+              <div className="space-y-2.5">
                 <div className="flex items-center justify-between">
-                  <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 border border-black/10 dark:border-white/10">
-                    就绪
-                  </span>
-                  <span className="text-[10px] font-mono text-slate-400">
-                    {new Date(session.startTime).toLocaleDateString('zh-CN')}
+                  <div className="flex items-center space-x-2 px-2.5 py-1 rounded-full bg-[#E6C687]/10 dark:bg-[#E6C687]/15 border border-[#E6C687]/30">
+                    <span className="w-2 h-2 rounded-full bg-[#E6C687] shadow-[0_0_6px_rgba(230,198,135,0.8)] animate-pulse" />
+                    <span className="text-[11px] font-medium text-[#B88C3A] dark:text-[#E6C687]">
+                      已同步
+                    </span>
+                  </div>
+                  <span className="text-[11px] font-mono text-slate-400 flex items-center space-x-1">
+                    <Clock className="w-3 h-3 inline mr-1" />
+                    {formatDate(session.startTime)}
                   </span>
                 </div>
 
-                <h3 className="text-sm font-semibold text-slate-900 dark:text-white group-hover:text-slate-600 dark:group-hover:text-slate-300 transition-colors line-clamp-2">
-                  {session.title || '现场记录 · 2026.07.22'}
+                <h3 className="text-sm font-bold text-slate-900 dark:text-white group-hover:text-slate-700 dark:group-hover:text-slate-200 transition-colors line-clamp-2 leading-snug">
+                  {session.title || '现场记录'}
                 </h3>
               </div>
 
-              <div className="border-t border-black/5 dark:border-white/10 pt-3 space-y-2">
-                <div className="flex items-center justify-between text-[11px] font-mono text-slate-500">
-                  <span className="flex items-center space-x-1.5 text-slate-700 dark:text-slate-300">
+              <div className="border-t border-black/5 dark:border-white/10 pt-3.5 space-y-3">
+                <div className="flex items-center justify-between text-xs font-mono text-slate-500">
+                  <span className="flex items-center space-x-1.5 text-slate-700 dark:text-slate-300 font-medium">
                     <Mic className="w-3.5 h-3.5 text-slate-500" />
-                    <span>{session.durationFormatted || formatSeconds(session.durationMs)}</span>
+                    <span>{formatSeconds(session.durationMs)}</span>
                   </span>
 
-                  <div className="flex items-center space-x-3 text-[11px]">
-                    <span className="flex items-center space-x-1 text-slate-400">
+                  <div className="flex items-center space-x-3 text-xs">
+                    <span className="flex items-center space-x-1 text-slate-400" title="快照数">
                       <Camera className="w-3.5 h-3.5" />
                       <span>{session.photoCount || 0}</span>
                     </span>
-                    <span className="flex items-center space-x-1 text-slate-400">
+                    <span className="flex items-center space-x-1 text-slate-400" title="随笔笔记数">
                       <FileText className="w-3.5 h-3.5" />
-                      <span>{session.noteCount || 1}</span>
+                      <span>{session.noteCount || session.events?.length || 0}</span>
                     </span>
                   </div>
                 </div>
 
                 <div className="flex items-center justify-between pt-1">
                   <span className="text-[10px] text-slate-400 font-mono">
-                    已加密上云
+                    包含 {(session.events || []).length} 个节点事件
                   </span>
 
                   <div className="flex items-center space-x-2">
                     <button
                       onClick={(e) => handleDelete(e, session.id)}
-                      className="p-1 text-slate-400 hover:text-rose-500 transition-colors"
+                      className="p-1.5 text-slate-400 hover:text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/40 rounded-lg transition-colors"
                       title="删除记录"
                     >
                       <Trash2 className="w-3.5 h-3.5" />

+ 0 - 100
src/pages/SyncSettingsPage.jsx

@@ -1,100 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { Cloud, Shield, HardDrive, RefreshCw, CheckCircle2, Lock, Database } from 'lucide-react';
-import { syncAPI } from '../api';
-
-export const SyncSettingsPage = () => {
-  const [summary, setSummary] = useState(null);
-  const [syncing, setSyncing] = useState(false);
-  const [toast, setToast] = useState(false);
-
-  useEffect(() => {
-    syncAPI.getSummary()
-      .then((res) => {
-        if (res.code === 0) setSummary(res.data);
-      })
-      .catch((e) => console.warn(e));
-  }, []);
-
-  const handleForceSync = async () => {
-    setSyncing(true);
-    try {
-      await syncAPI.triggerManualSync();
-      setToast(true);
-      setTimeout(() => setToast(false), 3000);
-    } catch (e) {
-      console.warn("Force sync error:", e);
-    } finally {
-      setSyncing(false);
-    }
-  };
-
-  return (
-    <div className="space-y-6 animate-fade-in pb-20 max-w-3xl">
-      <div>
-        <div className="flex items-center space-x-2">
-          <Cloud className="w-4 h-4 text-slate-700 dark:text-slate-300" />
-          <h2 className="text-base font-semibold text-slate-900 dark:text-white">
-            多端数据云同步与数据隔离
-          </h2>
-        </div>
-        <p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
-          星痕 CelestiaTrace 全平台(iOS App、Android App 与 Web 控制台)共享云端引擎,保证录音与标注多端实时一致。
-        </p>
-      </div>
-
-      {toast && (
-        <div className="p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-600 dark:text-emerald-400 text-xs flex items-center space-x-2">
-          <CheckCircle2 className="w-4 h-4" />
-          <span>云端与数据库状态已全面强制同步更新!</span>
-        </div>
-      )}
-
-      {/* Sync Status Cards */}
-      <div className="space-y-4">
-        <div className="business-card p-5 rounded-2xl space-y-4">
-          <div className="flex items-center justify-between">
-            <div className="flex items-center space-x-3">
-              <div className="w-9 h-9 rounded-xl bg-black/5 dark:bg-white/10 flex items-center justify-center text-slate-800 dark:text-white border border-black/10 dark:border-white/10">
-                <Database className="w-4 h-4" />
-              </div>
-              <div>
-                <h3 className="text-xs font-bold text-slate-900 dark:text-white">
-                  PostgreSQL 云端数据库引擎
-                </h3>
-                <p className="text-[10px] font-mono text-slate-400">
-                  当前连接: PostgreSQL (Docker/Caddy Proxy)
-                </p>
-              </div>
-            </div>
-
-            <button
-              onClick={handleForceSync}
-              disabled={syncing}
-              className="flex items-center space-x-1.5 px-3 py-1.5 rounded-lg bg-slate-900 text-white dark:bg-white dark:text-slate-900 text-xs font-medium transition-opacity hover:opacity-90"
-            >
-              <RefreshCw className={`w-3.5 h-3.5 ${syncing ? 'animate-spin' : ''}`} />
-              <span>{syncing ? '同步中' : '立即双向同步'}</span>
-            </button>
-          </div>
-
-          <div className="border-t border-black/5 dark:border-white/10 pt-3 grid grid-cols-2 gap-4 text-xs">
-            <div>
-              <div className="text-slate-400 text-[10px]">已用云存储</div>
-              <div className="font-mono font-bold text-slate-900 dark:text-white mt-0.5">
-                {summary?.storage?.cloudStorageUsedMB ?? 12} MB / 5120 MB
-              </div>
-            </div>
-
-            <div>
-              <div className="text-slate-400 text-[10px]">隔离模式</div>
-              <div className="font-mono font-bold text-emerald-600 dark:text-emerald-400 mt-0.5 flex items-center space-x-1">
-                <Lock className="w-3 h-3" />
-                <span>Strict JWT Token</span>
-              </div>
-            </div>
-          </div>
-        </div>
-      </div>
-    </div>
-  );
-};