فهرست منبع

chore(deps): add deployment docker configs and dev environment setup

bob.yuxinyang 1 ماه پیش
والد
کامیت
430a583ffe
12فایلهای تغییر یافته به همراه736 افزوده شده و 49 حذف شده
  1. 10 0
      .dockerignore
  2. 5 0
      .env.development
  3. 7 0
      .env.example
  4. 4 2
      Dockerfile
  5. 83 26
      deploy.sh
  6. 30 0
      design-qa.md
  7. 26 3
      docker-compose.yml
  8. 526 0
      docs/PRD-ios-playback-parity.md
  9. 22 6
      nginx.conf
  10. BIN
      public/app-icon.png
  11. 2 1
      tailwind.config.js
  12. 21 11
      vite.config.js

+ 10 - 0
.dockerignore

@@ -0,0 +1,10 @@
+.git
+.gitignore
+.env
+.env.*
+node_modules
+dist
+npm-debug.log*
+deploy.sh
+Dockerfile*
+docker-compose*.yml

+ 5 - 0
.env.development

@@ -0,0 +1,5 @@
+# Local Development Environment Configuration
+# Default: Proxy /v1 API requests to remote server via Vite dev server
+VITE_API_BASE_URL=/v1
+VITE_PROXY_TARGET=http://47.93.193.127
+VITE_PROXY_HOST=c.lessncosmos.com

+ 7 - 0
.env.example

@@ -0,0 +1,7 @@
+# Example Environment Configuration
+# API Base URL (relative path '/v1' uses Vite proxy in dev)
+VITE_API_BASE_URL=/v1
+
+# Vite Proxy Target (Default: Remote Server IP)
+VITE_PROXY_TARGET=http://47.93.193.127
+VITE_PROXY_HOST=c.lessncosmos.com

+ 4 - 2
Dockerfile

@@ -4,13 +4,15 @@ FROM docker.m.daocloud.io/library/node:20-alpine AS builder
 WORKDIR /app
 
 COPY package*.json ./
-RUN npm install
+RUN npm ci
 
 COPY . .
+ARG VITE_API_BASE_URL=https://api.ccdw.life/celestia-trace/v1
+ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
 RUN npm run build
 
 # Stage 2: Serve static files via Nginx
-FROM docker.m.daocloud.io/library/nginx:alpine
+FROM docker.m.daocloud.io/library/nginx:1.29-alpine
 
 COPY --from=builder /app/dist /usr/share/nginx/html
 COPY nginx.conf /etc/nginx/conf.d/default.conf

+ 83 - 26
deploy.sh

@@ -1,40 +1,97 @@
-#!/bin/bash
-# CelestiaTrace Frontend Independent Deployment Script
-# Usage: ./deploy.sh (inside ./frontend)
+#!/usr/bin/env bash
+# CelestiaTrace frontend deployment.
+# This updates only the frontend service and never runs docker compose down.
 
-set -e
+set -Eeuo pipefail
 
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+REPO_DIR="$(dirname "$SCRIPT_DIR")"
 SERVER_IP="47.93.193.127"
-PEM_KEY="../ccdw-meishi-1.pem"
+PEM_KEY="$REPO_DIR/ccdw-meishi-1.pem"
 REMOTE_DIR="/opt/celestia-trace-frontend"
 SSH_USER="root"
+COMPOSE_PROJECT="celestia-trace-frontend"
+PROXY_DIR="/opt/meishi_ccdw_life"
+PROXY_CONTAINER="meishi_ccdw_life-proxy-1"
+PROXY_CONFIG="$REPO_DIR/remote_nginx.conf"
+API_BASE_URL="https://api.ccdw.life/celestia-trace/v1"
+PUBLIC_URL="http://c.lessncosmos.com"
+SSH_OPTS=(-i "$PEM_KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
 
-echo "🚀 Deploying CelestiaTrace Frontend independently..."
+if [[ ! -f "$PEM_KEY" ]]; then
+  echo "SSH key not found: $PEM_KEY" >&2
+  exit 1
+fi
+if [[ ! -f "$PROXY_CONFIG" ]]; then
+  echo "Nginx config not found: $PROXY_CONFIG" >&2
+  exit 1
+fi
 
-# Ensure PEM key permissions
-chmod 400 "$PEM_KEY" 2>/dev/null || true
+chmod 400 "$PEM_KEY"
+ssh "${SSH_OPTS[@]}" "$SSH_USER@$SERVER_IP" "mkdir -p '$REMOTE_DIR'"
 
-# Create remote directory
-ssh -i "$PEM_KEY" -o StrictHostKeyChecking=no "$SSH_USER@$SERVER_IP" "mkdir -p $REMOTE_DIR"
+rsync -avz --delete \
+  --exclude '.git' \
+  --exclude '.env' \
+  --exclude 'node_modules' \
+  --exclude 'dist' \
+  -e "ssh -i $PEM_KEY -o BatchMode=yes -o StrictHostKeyChecking=accept-new" \
+  "$SCRIPT_DIR/" "$SSH_USER@$SERVER_IP:$REMOTE_DIR/"
 
-# Sync frontend files to server
-rsync -avz --exclude '.git' --exclude 'node_modules' --exclude 'dist' \
-  -e "ssh -i $PEM_KEY -o StrictHostKeyChecking=no" \
-  ./ "$SSH_USER@$SERVER_IP:$REMOTE_DIR/"
+ssh "${SSH_OPTS[@]}" "$SSH_USER@$SERVER_IP" << REMOTE_CMDS
+set -Eeuo pipefail
+cd "$REMOTE_DIR"
 
-# Deploy container on server
-ssh -i "$PEM_KEY" -o StrictHostKeyChecking=no "$SSH_USER@$SERVER_IP" << 'REMOTE_CMDS'
-set -e
-cd /opt/celestia-trace-frontend
+export VITE_API_BASE_URL="$API_BASE_URL"
+docker compose -p "$COMPOSE_PROJECT" config --quiet
+docker compose -p "$COMPOSE_PROJECT" up -d --build --no-deps frontend
 
-echo "📦 Building & starting CelestiaTrace Frontend container..."
-docker rm -f celestia-frontend 2>/dev/null || true
-docker compose down || true
-docker compose up -d --build
+for attempt in \$(seq 1 30); do
+  health=\$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' celestia-frontend)
+  if [[ "\$health" == "healthy" ]]; then
+    docker compose -p "$COMPOSE_PROJECT" ps frontend
+    exit 0
+  fi
+  if [[ "\$health" == "unhealthy" || "\$health" == "exited" ]]; then
+    docker logs --tail 100 celestia-frontend
+    exit 1
+  fi
+  sleep 2
+done
 
-echo "✅ Frontend deployment complete!"
-docker compose ps
+echo "Frontend health check timed out" >&2
+docker logs --tail 100 celestia-frontend
+exit 1
 REMOTE_CMDS
 
-echo ""
-echo "🎉 Frontend is live at http://celestiatrace.lessncosmos.com"
+scp "${SSH_OPTS[@]}" "$PROXY_CONFIG" "$SSH_USER@$SERVER_IP:$PROXY_DIR/nginx.conf.next"
+
+ssh "${SSH_OPTS[@]}" "$SSH_USER@$SERVER_IP" << REMOTE_CMDS
+set -Eeuo pipefail
+cd "$PROXY_DIR"
+
+docker run --rm \
+  --network meishi_ccdw_life_default \
+  -v "$PROXY_DIR/nginx.conf.next:/etc/nginx/nginx.conf:ro" \
+  -v /etc/letsencrypt:/etc/letsencrypt:ro \
+  docker.m.daocloud.io/library/nginx:alpine nginx -t
+
+cp nginx.conf.next nginx.conf
+chmod 644 nginx.conf
+rm -f nginx.conf.next
+docker exec "$PROXY_CONTAINER" nginx -s reload
+
+for attempt in \$(seq 1 15); do
+  if curl --fail --silent --show-error \
+    --resolve c.lessncosmos.com:80:127.0.0.1 \
+    "$PUBLIC_URL/healthz" >/dev/null; then
+    exit 0
+  fi
+  sleep 2
+done
+
+echo "Public HTTP health check failed: $PUBLIC_URL/healthz" >&2
+exit 1
+REMOTE_CMDS
+
+echo "Frontend deployed: $PUBLIC_URL"

+ 30 - 0
design-qa.md

@@ -0,0 +1,30 @@
+# 现场记录详情页设计 QA
+
+## 范围
+
+- 对照当前 iOS `SessionDetailView`、`MultiTrackTimeline` 与其编辑弹窗重建网页端详情页左侧。
+- 右侧“AI 智算与分析中心”结构、文案、视觉样式与占位功能保持不变。
+- 验证环境:Vite 本地预览,桌面端默认浏览器视口(实际截图约 1280 × 720)。
+
+## 验证记录
+
+- [x] 顶部返回、记录信息、分享与关闭入口布局正常。
+- [x] 记录名称可打开独立编辑面板,保存后刷新标题和云端版本。
+- [x] 使用下载到的真实音频 Blob 解码并绘制波形;无音频时明确提示,不生成替代音频。
+- [x] 50 ms RMS 采样、-40 dB / 2 秒静音识别、静音区间背景及“跳过静音”播放跳转正常。
+- [x] 音频、图片、笔记三轨、事件标记、续录标记和可拖动播放头显示正常。
+- [x] 时间轴空白轨点击可带入对应时间点,打开照片或笔记新增面板。
+- [x] 播放/暂停、前后 10 秒、进度条和时间轴播放头联动正常。
+- [x] 事件流按时间排序,笔记、标记、续录、位置和照片组入口状态完整。
+- [x] 笔记新增、查看、编辑、删除确认与云端回写链路正常。
+- [x] 照片拍摄/多选入口、备注、位置、详情编辑、全屏查看与删除入口完整。
+- [x] 记录信息面板包含起止时间、时长、照片/笔记统计、版本与续录入口。
+- [x] 续录面板包含浏览器麦克风、暂停/继续、结束、音频合并及同步流程。
+- [x] 同步状态覆盖已同步、同步中、进度、失败重试和取消。
+- [x] 音频分享优先调用系统文件分享,不支持时回退为保留原扩展名的下载。
+- [x] 右侧 AI 预留区视觉与功能未改动。
+- [x] 生产构建通过,浏览器控制台无本轮修复后的新增运行错误。
+
+## 结论
+
+final result: passed

+ 26 - 3
docker-compose.yml

@@ -1,7 +1,30 @@
 services:
   frontend:
-    build: .
+    build:
+      context: .
+      args:
+        VITE_API_BASE_URL: ${VITE_API_BASE_URL:-https://api.ccdw.life/celestia-trace/v1}
     container_name: celestia-frontend
     restart: unless-stopped
-    ports:
-      - "8081:80"
+    expose:
+      - "80"
+    networks:
+      proxy:
+        aliases:
+          - celestia-frontend
+    healthcheck:
+      test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/healthz >/dev/null || exit 1"]
+      interval: 10s
+      timeout: 3s
+      retries: 5
+      start_period: 10s
+    logging:
+      driver: json-file
+      options:
+        max-size: "10m"
+        max-file: "3"
+
+networks:
+  proxy:
+    name: meishi_ccdw_life_default
+    external: true

+ 526 - 0
docs/PRD-ios-playback-parity.md

@@ -0,0 +1,526 @@
+# 现场记录详情页左侧:iOS 回放功能等价复刻 PRD
+
+版本:v1.0  
+日期:2026-07-25  
+目标项目:`/Users/yuxin/local/code/celestia-trace/frontend`
+
+## 1. 结论
+
+网页端当前左侧仅实现了“看起来像播放器和事件列表”的基础框架,尚未与 iOS 回放页功能等价。必须重做为真实数据驱动版本:
+
+- 只播放服务器实际音频;不得生成模拟音频掩盖文件缺失或下载失败。
+- 波形必须从实际音频按与 iOS 相同的规则提取;不得使用正弦函数绘制装饰波形。
+- 必须复刻三轨时间线、可拖动播放头、真实静音分析与跳过、照片/笔记/位置的增删改查、事件详情、全屏图片、标题持久化、同步状态和分享。
+- 网页端所有编辑必须写回云端,并能被 iOS 下一次同步正确拉取。
+- 页面结构、配色、字号、边框、圆角、图标语义和空状态应与 iOS 保持统一;网页右侧 AI 区域不在本 PRD 范围内。
+
+## 2. 产品目标
+
+用户在网页端打开一条已同步现场记录后,能够在左侧完成与 iOS `SessionDetailView` 相同的查看、回放、定位和事件编辑任务;同一条记录在网页与 iOS 之间应保持数据、时间位置和操作结果一致。
+
+## 3. 范围与基准
+
+### 3.1 iOS 功能基准
+
+以当前工作区源码为准:
+
+- `SessionDetailView.swift`
+- `MultiTrackTimeline.swift`
+- `PlaybackViewModel.swift`
+- `ChronoFeedRow.swift`
+- `ImagePicker.swift`
+- `TimelineLocationPicker.swift`
+- `CelestiaSession.swift`
+- `CelestiaTimelineEvent.swift`
+- `RemoteNetworkService.swift`
+
+注意:审计时 iOS 工作区的 `SessionDetailView.swift` 和 `MultiTrackTimeline.swift` 存在未提交修改,本 PRD按当前文件内容而非仅按 `master` 最新提交定义功能。
+
+### 3.2 页面范围
+
+网页详情页继续保留左右双栏:
+
+- 左侧:完整复刻 iOS 回放与事件管理能力,本 PRD范围。
+- 右侧:AI 逐字稿与报告区域,维持现状,不得影响左侧布局和滚动。
+- 顶部全局栏可保留“返回/关闭”,但记录标题、同步、Info、分享的布局和状态表达必须与 iOS 统一。
+
+## 4. 信息架构与页面顺序
+
+左侧内容从上到下固定为:
+
+1. 记录标题 + 紧凑同步状态。
+2. 三轨时间线:音频、图片、笔记。
+3. 回放控制卡。
+4. 事件列表。
+
+顶部或标题区同时提供:
+
+- 记录信息 Info。
+- 分享录音。
+- 返回/关闭网页详情。
+
+不得继续在左侧增加“与 iOS App 保持一致”等自我说明标签,也不得用独立的“添加网页端标记”表单替代轨道点击交互。
+
+## 5. PRD 功能清单
+
+### A. 页面加载与真实数据
+
+#### IOS-WEB-001 获取最新详情
+
+- 打开详情时必须调用 `GET /sessions/{id}`,不得只依赖列表页传入的旧对象。
+- 加载期间显示骨架或进度状态;失败时显示可重试错误。
+- 成功后统一建立 `session / events / assets / revision` 页面状态。
+- 事件按 `relativeTimeMs` 升序排列。
+
+验收:
+
+- 在另一端修改记录后,重新打开网页能读取新版本。
+- 详情请求失败时不展示伪造事件、音频、统计或同步状态。
+
+#### IOS-WEB-002 实际音频加载
+
+- 从 `assets` 中筛选 `kind=AUDIO` 的资源。
+- 若存在多个音频资产,按 iOS 同步规则选择最新有效资产,不得直接使用数组第一个。
+- 通过鉴权接口下载或流式读取真实音频。
+- 音频不存在、下载失败或解码失败时,显示明确不可播放状态。
+- 禁止创建合成 WAV 作为回退。
+
+验收:
+
+- 播放内容与用户实际现场录音一致。
+- 缺少音频时播放按钮不可伪装成功,并显示“当前记录没有可播放的录音文件”。
+
+### B. 标题、信息与分享
+
+#### IOS-WEB-010 记录标题展示与编辑
+
+- 标题左对齐,单行显示,过长截断。
+- 点击标题进入独立编辑浮层/弹窗,不使用常驻行内输入框挤压布局。
+- 自动聚焦;若标题以“现场记录”结尾,首次进入时只选中这四个字。
+- 保存前去除首尾空白;空标题禁止保存。
+- 保存后写回云端并更新 `revision`,不能只修改前端内存对象。
+- 取消时恢复原值。
+
+#### IOS-WEB-011 记录信息 Info
+
+Info 面板必须展示:
+
+- 开始日期与时间,中文格式,精确到秒。
+- 结束日期与时间;无结束时间时显示“进行中”。
+- 总时长。
+- 图片数量。
+- 笔记数量;普通 `MARKER` 计入,续录标记不计入。
+- 当前本地内容版本/网页正在编辑的版本。
+- “续录”入口。
+
+视觉要求:
+
+- 内容为紧凑卡片,不在主页面正文中常驻展开。
+- 分隔线、10–13px 辅助字号、等宽数字、1px 低对比边框与 iOS 一致。
+
+#### IOS-WEB-012 分享/导出实际录音
+
+- 分享文件名取记录标题,替换 `/:\?%*|"<>`、控制字符和换行,清除尾部句点与空白。
+- 空结果回退为“现场记录”。
+- 支持 Web Share API 时调用系统分享;不支持时直接下载。
+- 分享/下载的是实际音频文件。
+- 无音频时显示不可分享原因;不得仅显示“资源已准备”Toast。
+
+### C. 同步状态
+
+#### IOS-WEB-020 紧凑同步按钮
+
+状态至少包括:
+
+- 正在同步:圆环进度 + 旋转同步图标 + 百分比。
+- 已同步:金色云端完成图标。
+- 未同步:灰色上传云图标。
+- 失败:错误状态与重试入口。
+- 冲突:明确提示版本冲突,禁止静默覆盖。
+
+行为:
+
+- 未同步点击后执行保存/上传。
+- 正在同步点击后允许暂停或取消当前上传。
+- 已同步点击后打开同步信息。
+- 其他记录正在同步时,本记录按钮禁用或排队。
+
+#### IOS-WEB-021 同步信息面板
+
+展示:
+
+- 最近同步时间,中文,精确到秒。
+- 云端版本 `v{revision}`。
+- 云端记录 ID,可选择复制。
+
+#### IOS-WEB-022 网络、登录和大文件边界
+
+- 无网络:提示连接网络后重试。
+- 登录失效:进入重新登录流程,不得丢失未提交草稿。
+- 预计上传量达到 70 MiB:同步前显示文件大小、网络类型和确认。
+- 版本冲突:使用 `baseRevision` 触发 409 保护,不做最后写入者静默覆盖。
+
+### D. 三轨时间线
+
+#### IOS-WEB-030 总体结构
+
+时间线包含:
+
+1. 时间刻度。
+2. 音频轨:真实波形、静音背景、续录标记。
+3. 图片轨:照片记录点。
+4. 笔记轨:笔记和普通标记。
+5. 一条贯穿三轨的播放头。
+6. 下方图例:音频、图片、笔记;存在续录标记时增加“续录”。
+
+轨道左侧保留约 24–30px 图标区,分别使用波形、相机、文档图标。
+
+#### IOS-WEB-031 横向尺寸与刻度
+
+- 时间线最小内容宽度 400px。
+- 基准比例为约 2.5px/秒,并在左右各保留约 30px。
+- 长记录允许横向滚动,隐藏原生滚动条但保留触控板、滚轮和触摸拖动。
+- 小于等于 60 秒:每 5 秒刻度。
+- 60–300 秒:每 10 秒刻度。
+- 大于 300 秒:每 60 秒刻度。
+- 刻度格式为 `m:ss`。
+
+#### IOS-WEB-032 真实波形
+
+- 从实际音频以 50ms 为时间窗提取 RMS。
+- 振幅按 iOS 相同的 `-50dB → 0...1` 线性规则归一化。
+- 按可见宽度聚合采样,每列取该区间最大振幅。
+- 视觉规则:1px 柱宽、2px 间距、围绕中心线绘制、最大约占音轨高度 75%。
+- 音轨高度约 90px;事件轨高度约 34px。
+- 音频实际时长是回放、波形和时间线的权威总时长,不能优先使用墙钟时长。
+
+#### IOS-WEB-033 静音区间
+
+- 音频分析同一遍同时产出波形和静音区间。
+- 静音区间在音频轨用低对比背景矩形标示。
+- 分析中显示加载状态。
+- 分析完成显示“检测到 N 处静音”;没有静音时不伪造数量。
+
+#### IOS-WEB-034 播放头
+
+- 播放头是 1px 高对比竖线,贯穿时间线。
+- 可直接拖动;有效拖动热区至少 28px。
+- 拖动位置限制在 `0...实际音频时长`。
+- 拖动时实时同步音频位置和下方进度条。
+- 正常播放时播放头自动滚动到可视区域中央。
+- 用户拖动期间关闭自动居中和缓动,避免跳动。
+- 键盘可聚焦,方向键每次调整 1 秒,并暴露当前时间给辅助技术。
+
+#### IOS-WEB-035 轨道事件点
+
+- 同一毫秒位置的多张照片合并成一个相机点,并显示数量徽标。
+- 笔记显示文档图标。
+- 普通 `MARKER` 显示警示/标记图标。
+- 文本以“续录时间:”开头的 `MARKER` 显示在音频轨,使用续录图标。
+- 点击事件点先跳到对应音频时间,再打开事件详情。
+- 事件按钮应覆盖空白轨点击层,不能误触发新增。
+
+### E. 回放控制
+
+#### IOS-WEB-040 时间与进度
+
+- 左侧显示当前时间,右侧显示总时长。
+- 小于一小时为 `MM:SS`;达到一小时为 `H:MM:SS`。
+- 提供独立进度 Slider,与时间线播放头和 `<audio>` 双向同步。
+- 进度范围以实际解码音频时长为准。
+
+#### IOS-WEB-041 播放操作
+
+- 快退 10 秒,最低到 0。
+- 播放/暂停主按钮,48px 圆形高对比样式。
+- 快进 10 秒,最高到实际音频末尾。
+- 播放结束后停止播放并停在末尾,与 iOS 一致。
+- 播放或暂停状态必须由实际音频元素状态驱动。
+
+#### IOS-WEB-042 跳过静音
+
+- 默认开启,与 iOS 一致。
+- 播放进入静音区间时,立即跳到该区间结束时间。
+- 开关必须实际影响播放逻辑。
+- 图标、文案、加载状态和静音区间数量与 iOS 一致。
+
+### F. 事件列表
+
+#### IOS-WEB-050 列表排序与分组
+
+- 所有事件按 `relativeTimeMs` 升序。
+- 同一时间点的照片在列表中合并成一个记录项。
+- 不同时间点分别显示。
+- 无事件时显示托盘图标和“暂无会话事件”。
+
+#### IOS-WEB-051 事件卡片
+
+卡片展示:
+
+- 时间徽标。
+- 类型图标和中文类型:照片、笔记、标记、音频、续录。
+- 笔记/标记内容,最多两行。
+- 照片缩略图最多叠放三张,并显示总张数。
+- 第一条非空照片备注。
+- 地点名称。
+- 右侧进入箭头。
+
+点击卡片:
+
+- 音频跳转到事件时间。
+- 打开相应事件详情。
+
+### G. 新增笔记
+
+#### IOS-WEB-060 从笔记轨空白处新增
+
+- 点击笔记轨空白处,将横坐标换算为记录时间。
+- 立即跳转播放器到该时间。
+- 打开“添加笔记”弹窗,顶部显示“添加到 MM:SS”。
+- 多行文本输入,占位文案“这一刻的想法”。
+- 可添加位置。
+- 空白内容禁止保存。
+- 保存后创建 `NOTE` 事件、更新版本、刷新时间线和事件列表。
+- 失败时回滚页面乐观更新并保留草稿。
+
+### H. 新增照片
+
+#### IOS-WEB-070 从图片轨空白处新增
+
+- 点击图片轨空白处确定事件时间并打开“添加照片记录”。
+- 支持调用摄像头拍照;浏览器不支持或未授权时显示明确状态。
+- 支持一次从本地选择多张图片。
+- 每张照片独立预览、移除和填写备注。
+- 多张照片共用一个位置。
+- 读取中显示进度;部分读取失败时保留成功项并提示失败数量。
+- 没有照片时显示“还没有照片”空状态。
+- 保存按钮显示“保存 N 张”,无照片或读取中时禁用。
+- 上传前采用与 iOS 等价的 JPEG 质量目标;不得无上限上传原始超大图片。
+
+数据要求:
+
+- 同一批照片的 `relativeTimeMs` 相同。
+- 每张照片创建独立 `PHOTO` 事件和独立照片资产。
+- 照片资产 `clientId` 必须与事件稳定关联,遵循 `{eventClientId}-photo`。
+- 事件或资源任一步失败时执行补偿,不能留下只有事件没有图片的半成品。
+
+### I. 查看、编辑与删除事件
+
+#### IOS-WEB-080 照片点详情
+
+- 同一时间点的所有照片在一个详情弹窗中展示。
+- 顶部显示时间、照片总数和共同位置。
+- 每张照片展示序号、图片、独立备注和删除按钮。
+- 备注与位置有变化时才启用保存。
+- 保存成功后更新所有相关事件并刷新版本。
+- 图片资源缺失时显示“图片不可用”,不显示破图。
+
+#### IOS-WEB-081 全屏图片
+
+- 点击图片进入全屏黑底预览。
+- 支持 1–5 倍缩放。
+- 放大后支持拖动,并限制在图片可移动边界内。
+- 展示照片创建时间和非空备注。
+- 提供明确关闭按钮,Esc 也可关闭。
+
+#### IOS-WEB-082 笔记详情
+
+- 显示事件时间。
+- 笔记内容可编辑。
+- 位置可添加、修改或清除。
+- 内容为空禁止保存。
+- 没有变化时禁用保存。
+
+#### IOS-WEB-083 标记与续录详情
+
+- 普通标记内容只读展示,并可删除。
+- 续录标记标题为“续录详情”,展示记录内容,并可删除。
+- 不得把续录标记计入笔记数量。
+
+#### IOS-WEB-084 删除事件
+
+- 删除照片、笔记和标记前必须二次确认。
+- 删除照片时同时删除事件记录和对应云端照片资产。
+- 删除失败时恢复 UI,不得先永久移除本地状态。
+- 操作完成后更新照片/笔记计数、版本、时间线和列表。
+
+### J. 位置
+
+#### IOS-WEB-090 位置入口
+
+- 无位置时显示“所在位置 / 点击后根据 GPS 选择当前位置”。
+- 有位置时显示地点名称和地址。
+- 图标在有位置时使用品牌金色。
+- 支持修改或清除位置。
+
+#### IOS-WEB-091 位置选择器
+
+- 进入后请求浏览器定位权限;仅在用户主动打开位置选择器后请求。
+- 展示地图、当前位置、已选地点标记和回到当前位置按钮。
+- 默认加载当前位置 2km 内的附近地点。
+- 搜索范围以当前位置/已选位置为中心约 10km。
+- 提供“不显示位置”选项。
+- 地点条目显示名称、地址和选中状态。
+- 定位拒绝、定位失败、附近地点失败、无搜索结果分别显示对应错误和重试/设置指引。
+
+网页适配:
+
+- 地图和地点检索服务必须明确选型;不得在 PRD 实施阶段临时用静态占位图替代。
+- 保存字段统一为 `locationName / locationAddress / latitude / longitude`。
+
+### K. 续录
+
+#### IOS-WEB-100 续录入口与环境检查
+
+- 从 Info 面板点击“续录”。
+- 先暂停当前回放。
+- 网页端使用浏览器麦克风继续录音;如支持多输入设备,允许选择输入源。
+- 麦克风不可用或权限拒绝时明确说明原因。
+- 续录开始时以原音频实际时长作为偏移,创建续录标记:
+  - `eventType=MARKER`
+  - `relativeTimeMs=原音频时长`
+  - `textContent=续录时间:yyyy年M月d日 HH:mm:ss`
+- 结束后生成新的完整或可拼接音频资产,更新时长、波形、静音分析、版本和同步状态。
+
+平台限制:
+
+- 网页端不得假装支持 iOS 原生 Spark BLE 录音。
+- 若产品要求 Spark 设备完全等价,需另立 Web Bluetooth/设备网关技术方案;不支持的平台显示“当前浏览器不支持 Spark 续录”,并允许改用浏览器麦克风。
+
+## 6. UI 统一规范
+
+- 背景:跟随系统浅色/深色,分别接近 iOS `systemBackground`。
+- 卡片:接近 `secondarySystemBackground`,主内容使用约 15%–50% 的轻透明层级。
+- 边框:主文本色 12% 透明度,1px。
+- 圆角:主卡片 10px,事件卡片/输入 8px。
+- 主色:系统高对比前景色;不把品牌金色用于普通播放进度。
+- 品牌金:`#E6C687`,只用于已同步、选中位置等强调状态。
+- 录音红:`#E53935`。
+- 字体:系统 SF/等价系统字体;时间、版本和技术数字使用等宽字体。
+- 图标:网页使用 Lucide 中最接近的线性图标;线宽和尺寸保持 9–18px 的克制层级。
+- 禁止装饰性发光、脉冲和紫色 AI 视觉渗入左侧。
+- 左侧桌面宽度下完整显示;窄屏时独立纵向滚动,不允许控制区相互遮挡。
+
+## 7. 可访问性与键盘
+
+- 所有纯图标按钮必须有可读名称和 Tooltip。
+- 播放、同步、跳过静音、保存和删除状态须通过 `aria-live` 或等价方式通知。
+- 所有弹窗具备焦点锁定、Esc 关闭和关闭后的焦点恢复。
+- 时间线事件、播放头、Slider、照片删除和位置选择均可键盘操作。
+- 触控目标建议至少 44×44px;播放头视觉可为 1px,但命中区至少 28px,网页建议 32px。
+- 不依赖颜色单独表达同步、选中、错误或播放状态。
+- 图片提供语义化替代文本;地图存在文本列表替代。
+- 200% 缩放下左侧不得出现不可操作的横向页面溢出;只有时间线内部允许横向滚动。
+
+## 8. 当前网页实现差距
+
+### P0:阻止“完全一致”的问题
+
+- 使用 `createSyntheticFieldAudio()` 在无音频或下载失败时生成假音频。
+- 波形由 `Math.sin()` 生成,与真实录音无关。
+- 没有 iOS 三轨时间线、时间刻度、静音区间和可拖动播放头。
+- “跳过静音”只切换样式,不执行检测或跳转,且默认值与 iOS 不一致。
+- 标题编辑只修改 `session.title` 内存对象,不写后端。
+- `RecordDetailModal` 调用不存在的 `sessionAPI.addEvent`;并且 `App.jsx` 未传入 `onEventAdded`。
+- 分享按钮只显示 Toast,没有分享或下载实际音频。
+- 缺少照片新增、批量照片、照片备注、照片查看、全屏缩放、位置、编辑和删除。
+- 缺少笔记编辑、删除和位置。
+- 缺少事件按同一照片时间点分组。
+- 缺少续录。
+
+### P1:状态和体验不一致
+
+- 详情未在打开时重新请求最新数据。
+- 同步状态固定显示“已同步”,无真实进度、失败、冲突或暂停。
+- 信息面板字段和结构与 iOS 不一致。
+- 左侧存在额外说明标题、胶囊标签、装饰金色进度和网页专属表单。
+- 事件类型直接显示英文。
+- 音频结束后网页回到 0,iOS 停在末尾。
+- 对大文件使用一次性 Blob 下载,可能造成明显内存压力。
+
+## 9. 后端与接口缺口
+
+现有后端已具备:
+
+- 获取列表与详情。
+- `POST /sessions` 全量幂等 Upsert,支持 `baseRevision` 和 `deletedEventClientIds`。
+- 新增单个事件。
+- 上传/下载 AUDIO 与 PHOTO 资产。
+- 记录软删除。
+
+为了稳定完成网页/iOS 双端编辑,建议:
+
+### 必须补齐
+
+1. 前端 API 封装:
+   - `getSessionDetail`
+   - `syncSession`
+   - `uploadAsset`
+   - `addEvent`(若仍保留单事件接口)
+   - 统一错误码、409 和 413 处理。
+2. 删除照片资产接口:
+   - 当前没有 `DELETE /sessions/{sessionId}/assets/{assetId}`。
+   - 仅删除 PHOTO 事件会留下云端孤儿文件,无法达到 iOS“图片文件和记录一起删除”的语义。
+3. 资产与事件关联返回:
+   - 网页需用事件 `clientId` 对应 `{clientId}-photo` 资产。
+   - 建议详情响应直接返回 `event.asset` 或 `event.assetId`,减少前端猜测。
+4. 音频访问优化:
+   - 支持 Range/流式响应或安全的鉴权媒体 URL,避免超大录音全部进入内存 Blob。
+
+### 建议修正
+
+- `POST /sessions/{id}/events` 新增事件后应同步更新 `photoCount / noteCount / updatedAt / revision`。
+- 增加事件 PATCH/DELETE 接口,或明确网页统一使用全量 `POST /sessions` Upsert;不要两套写入语义混用。
+- 照片上传和事件创建提供事务化/补偿协议。
+- 续录后多个 AUDIO 资产的“当前权威音频”规则应在后端明确,不依赖数组顺序。
+- 提供可选的波形/静音分析缓存,减少每次打开大音频都重新解码。
+
+## 10. 推荐实施顺序
+
+### 阶段 1:真实回放底座
+
+- 详情重新获取。
+- 去除假音频。
+- 真实音频选择、错误态与分享下载。
+- 真实波形、静音检测、播放控制和可拖动播放头。
+- 三轨只读展示和事件列表分组。
+
+完成标准:用户看到和听到的内容全部来自真实记录。
+
+### 阶段 2:事件完整编辑
+
+- 标题云端保存。
+- 新增/编辑/删除笔记。
+- 新增/查看/编辑/删除照片。
+- 位置选择。
+- 全屏图片。
+- 版本冲突与失败回滚。
+
+完成标准:网页编辑后 iOS 同步能得到完全相同的事件、照片、备注和位置。
+
+### 阶段 3:同步、续录与大文件
+
+- 精确同步状态、进度、暂停、认证和大文件确认。
+- 浏览器麦克风续录和音频资产更新。
+- 流式/Range 音频、分析缓存和性能优化。
+
+完成标准:长录音、大照片批次、续录与冲突场景可可靠完成。
+
+## 11. 总体验收标准
+
+只有同时满足以下条件,才能宣称“网页左侧与 iOS 回放页功能统一”:
+
+1. 无任何模拟音频、模拟波形、固定同步状态或无效开关。
+2. 同一记录在 iOS 与网页显示相同标题、时长、照片数、笔记数、事件时间、备注、地点和版本。
+3. 两端播放同一实际音频,波形时间位置可对应听到的内容。
+4. 网页拖动三轨播放头、进度条或点击事件,音频位置一致。
+5. 跳过静音在真实静音区间生效。
+6. 网页新增/编辑/删除笔记和照片后,iOS 同步结果一致;反向亦然。
+7. 照片删除不残留云端孤儿资产。
+8. 标题和所有事件编辑均有版本保护、失败提示和回滚。
+9. 信息、同步、分享、位置、全屏图片和续录均具备完整状态,而非视觉占位。
+10. 桌面、窄屏、键盘和辅助技术下核心流程均可完成。
+
+## 12. 审计证据限制
+
+本 PRD完成了当前 iOS 与网页源码、数据模型、同步逻辑和后端路由的逐项核对。当前已启动模拟器停在麦克风权限请求,未在未获授权的情况下改变权限,因此本轮没有取得回放详情页的完整运行截图。视觉尺寸以 SwiftUI 当前源码为基准,实施时仍需用包含真实音频、照片、笔记、位置和续录标记的测试记录,分别在 iOS 和网页逐状态截图对照验收。

+ 22 - 6
nginx.conf

@@ -1,16 +1,32 @@
 server {
     listen 80;
-    server_name localhost;
+    server_name _;
+    server_tokens off;
 
     root /usr/share/nginx/html;
     index index.html;
 
-    location / {
-        try_files $uri $uri/ /index.html;
+    add_header X-Content-Type-Options nosniff always;
+    add_header X-Frame-Options DENY always;
+    add_header Referrer-Policy strict-origin-when-cross-origin always;
+
+    location = /healthz {
+        access_log off;
+        add_header Content-Type text/plain;
+        return 200 "ok\n";
+    }
+
+    location = /index.html {
+        add_header Cache-Control "no-cache, no-store, must-revalidate";
     }
 
-    error_page 500 502 503 504 /5x.x.html;
-    location = /5x.x.html {
-        root /usr/share/nginx/html;
+    location /assets/ {
+        try_files $uri =404;
+        expires 1y;
+        add_header Cache-Control "public, immutable";
+    }
+
+    location / {
+        try_files $uri $uri/ /index.html;
     }
 }

BIN
public/app-icon.png


+ 2 - 1
tailwind.config.js

@@ -16,7 +16,7 @@ export default {
           border: 'rgba(0, 0, 0, 0.08)',
           darkBorder: 'rgba(255, 255, 255, 0.10)',
           red: '#E53935',
-          gold: '#C5A059',
+          gold: '#E6C687',
           subtle: '#8E8E93',
           text: '#1D1D1F',
           darkText: '#F5F5F7',
@@ -39,6 +39,7 @@ export default {
       boxShadow: {
         'glow-cyan': '0 0 20px rgba(0, 242, 254, 0.25)',
         'glow-red': '0 0 20px rgba(229, 57, 53, 0.3)',
+        'glow-gold': '0 0 20px rgba(230, 198, 135, 0.3)',
         'ios-soft': '0 4px 20px -2px rgba(0, 0, 0, 0.06), 0 2px 6px -1px rgba(0, 0, 0, 0.04)',
         'ios-float': '0 12px 32px -4px rgba(0, 0, 0, 0.12), 0 4px 12px -2px rgba(0, 0, 0, 0.08)',
       },

+ 21 - 11
vite.config.js

@@ -1,16 +1,26 @@
-import { defineConfig } from 'vite'
+import { defineConfig, loadEnv } from 'vite'
 import react from '@vitejs/plugin-react'
 
 // https://vitejs.dev/config/
-export default defineConfig({
-  plugins: [react()],
-  server: {
-    port: 3000,
-    proxy: {
-      '/v1': {
-        target: 'http://localhost:8080',
-        changeOrigin: true,
-      }
-    }
+export default defineConfig(({ mode }) => {
+  const env = loadEnv(mode, process.cwd(), '')
+  const proxyTarget = env.VITE_PROXY_TARGET || 'http://47.93.193.127'
+  const proxyHost = env.VITE_PROXY_HOST || 'c.lessncosmos.com'
+
+  return {
+    plugins: [react()],
+    server: {
+      port: 3000,
+      proxy: {
+        '/v1': {
+          target: proxyTarget,
+          changeOrigin: true,
+          headers: {
+            Host: proxyHost,
+          },
+        },
+      },
+    },
   }
 })
+