Files
Commilitia-Drop/FRONTEND_DESIGN.md
admin 19f8f7588e docs: 架构文档与变更记录
cdrop 的架构 / 协议 / 鉴权设计(PROJECT_BRIEF)、前端设计(FRONTEND_DESIGN)、变更记录(CHANGELOG)。

公开发布初始提交:完整文档历史留存于私有归档,公开分支自此干净起步。
2026-06-15 21:52:15 +08:00

15 KiB
Raw Permalink Blame History

Commilitia Drop — 前端设计

补充 PROJECT_BRIEF.md,专注 web 前端实现。后端、协议、鉴权见主 brief。

本文记录 MVP 初版前端设计(Mantine + 浅色)。其后前端经「Theme B」重塑:自定义设计 token、品牌 web font、Dracula 深色、聚珍(Juzhen)CJK 综合排版——视觉部分以 CHANGELOG 与实际代码为准。


1. 技术栈

Vite                       构建
React 18 + TypeScript      框架
TanStack Router            路由(类型安全 > React Router
Zustand                    state 管理
Mantine v7                 UI 组件库(CSS modules,零运行时开销)
@mantine/dropzone          文件拖拽
@mantine/notifications     toast
Tailwind CSS               utility 微调(不主导样式)
lucide-react               图标
原生 RTCPeerConnection     P2P

不引入 starter 模板。从 npm create vite@latest -- --template react-ts 起步,按需装包。

不引入MUI / Ant Designbundle 过大)、shadcn/ui(手搓成本与 MVP 速度冲突)、TanStack QueryMVP 阶段 SSE + Zustand 已够,需要时再加)、Storybook / i18next / Husky 等企业级配套。


2. 资源占用

前端是纯静态资源,部署在 Caddy 静态目录。服务器开销可忽略:

  • gzipped bundle 估计 400-600 KB
  • 服务器内存 / CPU 接近零(无 SSR)
  • 真正吃资源的是后端 Go 进程的 ring buffer 和 SSE 长连接

构建在本地完成,部署只 push 静态文件。


3. 设计语言

中性、克制、工具感。参考点:

  • Linear:留白、灰阶、克制的强调色
  • Cloudflare Dashboard:信息密度合理的卡片式布局
  • Tailscale Admin:设备列表、状态指示

避开:

  • AirDrop 圆圈布局(不打算抄 Apple 的视觉语言,且圆圈在设备数 > 8 时退化严重)
  • Snapdrop / PairDrop 极简主义(看着是玩具,不是工具)
  • Material You(不适合自托管极简风)

配色:浅色优先,且 MVP 阶段仅实现浅色。深色模式属阶段三 UX 完善的范围,不在 MVP 内。Mantine 配置 defaultColorScheme="light",不读取 prefers-color-scheme,不提供切换控件。

强调色 cdrop 蓝(#3b82f6 量级,待最终选定)。文本主色深灰(#1f2937),次级灰阶按 Mantine gray.6 / gray.7 走。背景纯白或近白(#fafafa)以减少屏幕反光强度。

字体与 CJK 排版

原 MVP 计划用系统字体栈、不引入 web font;前端重塑(Theme B)后改为品牌 web font CJK 综合排版。

字体栈web/src/styles/fonts.css,逐 region 由 :lang() 切 SC/TC/JP/KR:root 默认 SC,亦兼英文 / 无 lang 回退):

  • Sans(正文):Orbit Gothic CJK <R> 系统回退
  • SerifNoto Serif CJK <R>(本地合一、全字覆盖)→ Noto Serif <R>Google Fonts Web 子集,跨区并集补字)→ 系统
  • MonoMaple Mono Orbit Gothic CJK <R> 系统

web font 由三个 CDN 的 @font-face 提供(index.html 三条 <link>),均 font-display: swap。因这些 @font-face 不含 local()、family 名又与本机安装同名 → 浏览器被 @font-face 捕获、永远走网络(同名本地副本不被用)。故 web/src/styles/fontface.css本地优先覆盖层:在 CDN <link> 之后、对项目用到的字重重声明 @font-facesrc: local(...), url(<cdn>) —— 本机命中即零网络瞬时渲染,未装才回退 CDN(swap)。

CJK 综合排版(聚珍 / Juzhensubmodule web/src/lib/cjk-autospace(上游 git.commilitia.net/admin/cjk-autospace;引擎机制见其 README.md / ARCHITECTURE.md)。web/src/utils/cjkAutospace.ts 为 cdrop 侧薄壳:

  • createJuzhen({ lang:{style:"quanjiao"}, scope:{include:".juzhen"}, level:{text:".juzhen"}, pillSelector:"code, kbd, .cjk-pill" })render / revert MutationObserver 增量(render 幂等)。
  • 全角式quanjiao):句内点号不挤、占全角,仅承载 justify 单份间距与行端收缩。
  • 作用域 .juzhen#root 加该类):处理与 normalize 收敛于此子树,body 上 Mantine portal 不受扰。
  • 功能分级:文本级(中西间隙 / 标点挤压,任意文本片段适用)全树默认跑;段落级(禁则避头尾 / 垂悬字避免 / 长英文断词,需行段布局)仅带 data-jz-level="paragraph" 的流动正文(消息 / 剪贴板 / Callout / Toast / 登录说明等)跑。cdrop 绝大多数是 UI chrome(短标签),故默认文本级、正文 opt-in,失败模式良性。
  • copy-clean:注入的间隙 / 结构元素不入剪贴板,复制得紧凑原文。

4. 核心交互:选择目标设备

放弃圆圈布局,采用单行可横向滚动的设备条(接近 AirDrop iOS 分享面板)。要求同时支持两种顺序:

  • 先选文件,再选设备(拖入 → 高亮可用设备 → 点击设备发送)
  • 先选设备,再选文件(点击设备打钩 → 拖入或点击"选择文件" → 自动发送)

布局(Home 页)

┌──────────────────────────────────────────────────────────────┐
│  Commilitia Drop                          [Settings] [User]  │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  Devices  (3 online)                                         │
│  ┌──────────┬──────────┬──────────┬──────────┬─────         │
│  │  ●       │  ●       │  ○       │  ●       │   …          │
│  │ MBA M4   │ Win-Desk │ iPhone   │ Mac mini │              │
│  │ desktop  │ desktop  │ mobile   │ desktop  │              │
│  │ this dev │  ✓       │          │          │              │
│  └──────────┴──────────┴──────────┴──────────┴─────         │
│                                                              │
│  ┌────────────────────────────────────────────────────────┐  │
│  │                                                        │  │
│  │           Drop files here, or click to browse          │  │
│  │                                                        │  │
│  │           Selected: report.pdf  (4.2 MB)   [×]         │  │
│  │                                                        │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  [   Send to Win-Desk   ]   ← primary CTA, 状态联动          │
│                                                              │
├──────────────────────────────────────────────────────────────┤
│  Recent transfers                                            │
│  • report.pdf       → Win-Desk    ✓ 2 min ago                │
│  • photo.zip        ← iPhone       ✓ 5 min ago               │
│  • video.mp4        → Mac mini     × failed (timeout)        │
└──────────────────────────────────────────────────────────────┘

设备卡片状态

每张卡片宽度约 120-140 px,固定不随屏幕拉伸。横向 overflow-x: auto,触控板 / 鼠标滚轮可推。

状态 视觉
this device 灰色边框 + "this device" 标签,不可选
online idle 白底 + 浅灰边框 + 绿点,可选
online selected 蓝色描边 + ✓ 角标 + 浅蓝底
online incompatible(文件已选但目标设备 capability 不允许) 灰底 + 50% 透明 + 不可选
online incompatible(被选后用户拖入了不兼容文件) 同上,自动取消选中 + Notification 提示
online busy(已在收发) 黄色环 + 进度数字
offline 灰底 + 50% 透明 + 不可选(默认隐藏,"显示离线"开关展开)

每张卡片显示:状态点 + 设备名 + 类型小字。不显示头像 / 图标(避免维护设备图标资源)。设备名超长截断为省略号,hover 显示 tooltip 全名。

双向选择顺序的状态机

文件和目标设备分别独立可选,CTA 状态由两者组合:

state = (file: File | null, target: Device | null)

(null, null)            → CTA: disabled "Select file and device"
(file, null)            → CTA: disabled "Select a device"
(null, device)          → CTA: disabled "Select a file"
(file, device, ok)      → CTA: enabled "Send to {device.name}"
(file, device, !compat) → CTA: disabled "Cannot send {file.type} to {device.type}"

CTA 按钮永远可见在拖拽区下方,文案随状态变化。点击 CTA 即触发 POST /api/transfer/initiate

拖拽优化

  • Tab 键支持:设备条可用方向键导航,Space 切换选中。无障碍是免费的,Mantine 默认就有
  • 拖入页面任意位置触发全屏 dropzone overlay,提示松手位置(默认行为继承 dropzone)
  • 文件拖到具体设备卡片上 → 该设备自动选中并立即发送("快捷路径",跳过 CTA)。这是 AirDrop 没有的额外便捷
  • 选了不止一个文件 → MVP 拒绝(提示"目前每次只支持一个文件")。多文件留到阶段三

为什么不用圆圈

  • 圆圈布局在设备数 > 8 时退化为环形排列,视觉混乱
  • 圆圈区分度低,必须靠头像区分设备,但头像维护成本高
  • 单行卡片在桌面浏览器(cdrop 主要场景)上信息密度更合适
  • 一行卡片可以承载更多信息(设备名 + 类型 + 状态 + 选中标),圆圈只能放头像和小字
  • AirDrop 单行布局是 Apple 在分享面板里给出的样式,本身就是为"多目标快速选择"优化过的

5. 路由

/                  Home(设备 + 拖拽 + 历史)
/login             Casdoor PKCE 跳转中转
/oauth/callback    Casdoor 回调
/setup             首次填设备名(设备名未设时强制跳转此页)
/settings          设备名修改、退出登录
                   后续:shortcut token 管理、外观(深色切换)

TanStack Router file-based 路由模式,类型安全 + 自动代码分割。


6. 状态管理

Zustand 一个 store,分 slice

useStore = {
  auth:     { tokens, user, login, logout, refresh }
  device:   { selfName, selfType, set, hydrate }
  hub:      { connected, devices, signal, sse-handlers }
  transfer: { current, history, initiate, accept, cancel }
  ui:       { selectedDevice, selectedFile, notifications }
}

不引入 Redux 或 Jotai。Zustand 的 selector + useShallow 处理性能问题足够。


7. 组件清单

业务组件(自己写):

DeviceCard           单设备卡片(状态联动)
DeviceStrip          横向滚动设备条
FileDropzone         拖拽区(@mantine/dropzone 包一层)
SendCTA              主按钮(状态机文案)
TransferRow          历史记录单行
TransferProgress     进度条 / RingProgress
DeviceTypeBadge      desktop / mobile / browser 标签

页面级:

HomePage
LoginPage
SetupPage
SettingsPage
OAuthCallbackPage

通用 lib(与组件无关):

lib/auth.ts          PKCE flow, refresh, fetch wrapper
lib/api.ts           带 Authorization + X-Device-Name 的 fetch 封装
lib/hub.ts           自实现 SSE 客户端(fetch + ReadableStream
lib/p2p.ts           P2PSession(裸 RTCPeerConnection
lib/relay.ts         RelaySession(流式 GET + 分块 POST
lib/transfer.ts      Orchestrator(决定 P2P / Relay / fallback

8. SSE 客户端要点

浏览器原生 EventSource 不支持自定义 header,所以不能直接带 Authorization。自实现:

const res = await fetch('/api/hub/events', {
  headers: { Authorization: `Bearer ${token}`, 'X-Device-Name': name },
  signal: abortController.signal
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buf += decoder.decode(value, { stream: true });
  // 按 '\n\n' 切分 SSE 事件,按 'event:' / 'data:' 字段解析
}

重连:指数退避(1s → 2s → 4s → 8s → 16s 上限),断开时 UI 显示 "Reconnecting..."。

token 接近过期时(< 5min)先 refresh,再重建 SSE 连接,避免连接中途 401。


9. 性能 / Bundle 优化

  • TanStack Router 自动代码分割:每个路由独立 chunk
  • Mantine 按需 import,不要 import { Button } from '@mantine/core/dist/...'
  • 使用 vite-plugin-imp 或依赖 Mantine 自身的 ESM tree-shakev7 默认就好)
  • recharts / d3 不需要(cdrop 不画图)
  • react-helmet 不需要(应用很简单,document.title 直接改)

构建产物期望:

index.html              ~ 2 KB
assets/index.[hash].js  ~ 250-350 KB (gzipped)
assets/index.[hash].css ~ 30-50 KB (gzipped)
其他 chunk              按路由懒加载

10. 浏览器兼容

目标:

  • Chrome / Edge ≥ 110(涵盖 99% Chromium 用户)
  • Firefox ≥ 110
  • Safari ≥ 16.4macOS 13+ / iOS 16.4+WebRTC 完整支持)

不支持 IE / 旧 Edge / iOS 15 及以下。原因:WebRTC DataChannel 行为差异和 fetch streaming 兼容问题不值得投入精力。


11. 与主 brief 的对齐

§3 技术栈一行更新为:

**前端**Vite + React 18 + TypeScript / Mantine v7 + Tailwind / TanStack Router / Zustand / 原生 RTCPeerConnection / lucide-react

§7 MVP 验收标准的 "Home 页看到对方在线" 默认指本文件 §4 描述的设备条。

§8 阶段三 UX 完善中"暗色模式"对应本文件 §3 提到的 MVP 不实现深色——阶段三再做。


12. 你(Claude Code)需要决策的事

  1. 设备卡片的具体尺寸、间距、状态色值,等 §3 强调色锁定后再敲定
  2. CTA 按钮的具体文案(中文 vs 英文 vs 双语)。MVP 用英文,i18n 走阶段三
  3. 历史记录显示几条 / 是否分页 / 是否支持过滤。MVP 默认显示最近 20 条
  4. 有更好的设计参考时,可以反驳 §3 的方向选择