cdrop — 跨 OS 剪贴板与文件传输服务
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。 - 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。 - 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。 - 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。 - 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。 架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
import type { CandidateBreakdown, TransferRecord } from "../../store";
|
||||
import { t } from "../../i18n";
|
||||
import { formatBytes } from "../../utils/format";
|
||||
import s from "./TransferDetails.module.css";
|
||||
|
||||
export interface TransferDetailsProps
|
||||
{
|
||||
record: TransferRecord;
|
||||
}
|
||||
|
||||
// 二级详情面板。常态下被折叠在 TransferRow 末端「详情」之后,仅工程视角访问。
|
||||
// 排版克制:mono 字号、文本对齐为主,不做花哨可视化。
|
||||
export function TransferDetails(props: TransferDetailsProps)
|
||||
{
|
||||
const { record: tr } = props;
|
||||
const st = tr.iceStats;
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
{/* —— 顶部一行 chip:模式 + 状态 + 文件大小 —— */}
|
||||
<div className={s.headline}>
|
||||
{tr.mode && (
|
||||
<span className={[
|
||||
s.modeBadge,
|
||||
tr.mode === "p2p" ? s.modeP2P : s.modeRelay,
|
||||
].join(" ")}>
|
||||
{tr.mode === "p2p" ? t("transfer.mode.p2p") : t("transfer.mode.relay")}
|
||||
</span>
|
||||
)}
|
||||
<KV k="state" v={tr.state} />
|
||||
<KV k="size" v={formatBytes(tr.fileSize)} />
|
||||
<KV k="started" v={isoSeconds(tr.startedAt)} />
|
||||
</div>
|
||||
|
||||
{/* —— ICE 状态 —— */}
|
||||
{st && (
|
||||
<>
|
||||
<Section title={t("transfer.debug.iceGathering") + " / " + t("transfer.debug.iceConnection")}>
|
||||
<div className={s.kvRow}>
|
||||
<span className={s.k}>{t("transfer.debug.iceGathering")}</span>
|
||||
<span className={s.v}>{st.gathering}</span>
|
||||
<span className={s.k}>{t("transfer.debug.iceConnection")}</span>
|
||||
<span className={s.v}>{st.connection}</span>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* —— 候选明细表格 —— */}
|
||||
<Section title="候选">
|
||||
<CandidateTable
|
||||
local={st.candidates.local}
|
||||
remote={st.candidates.remote}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* —— 选中候选对 —— */}
|
||||
<Section title={t("transfer.debug.selectedPair")}>
|
||||
<div className={s.pairRow}>
|
||||
<span className={s.pairLabel}>pair</span>
|
||||
{st.selectedPair ? (
|
||||
<>
|
||||
<span className={s.pairHas}>{st.selectedPair.local}</span>
|
||||
<span className={s.pairArrow}>↔</span>
|
||||
<span className={s.pairHas}>{st.selectedPair.remote}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={s.pairNone}>{t("transfer.debug.noPair")}</span>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* —— DataChannel —— */}
|
||||
{st.dataChannel && (
|
||||
<Section title={t("transfer.debug.dataChannel")}>
|
||||
<div className={s.kvRow}>
|
||||
<span className={s.k}>readyState</span>
|
||||
<span className={s.v}>{st.dataChannel.readyState}</span>
|
||||
<span className={s.k}>buffered</span>
|
||||
<span className={s.v}>{formatBytes(st.dataChannel.bufferedAmount)}</span>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* —— phase 当前 —— */}
|
||||
{tr.phase && (
|
||||
<Section title="阶段">
|
||||
<div className={s.kvRow}>
|
||||
<span className={s.k}>phase</span>
|
||||
<span className={s.v}>{tr.phase}</span>
|
||||
{tr.lastProgressAt && (
|
||||
<>
|
||||
<span className={s.k}>last tick</span>
|
||||
<span className={s.v}>{isoSeconds(Math.floor(tr.lastProgressAt / 1000))}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section(props: { title: string; children: React.ReactNode })
|
||||
{
|
||||
return (
|
||||
<div className={s.section}>
|
||||
<div className={s.sectionTitle}>{props.title}</div>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KV(props: { k: string; v: string | number })
|
||||
{
|
||||
return (
|
||||
<span style={{ display: "inline-flex", gap: 4 }}>
|
||||
<span className={s.k}>{props.k}</span>
|
||||
<span className={s.v}>{props.v}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface CandidateTableProps
|
||||
{
|
||||
local: CandidateBreakdown;
|
||||
remote: CandidateBreakdown;
|
||||
}
|
||||
|
||||
function CandidateTable(props: CandidateTableProps)
|
||||
{
|
||||
const { local, remote } = props;
|
||||
const cols = [ "host", "mdns", "srflx", "prflx", "relay" ] as const;
|
||||
return (
|
||||
<div className={s.candTable}>
|
||||
<span className={s.candHead}></span>
|
||||
{cols.map((c) => <span key={c} className={s.candHead}>{c}</span>)}
|
||||
|
||||
<span className={s.candCell}>本端</span>
|
||||
{cols.map((c) => (
|
||||
<span
|
||||
key={"l-" + c}
|
||||
className={[ s.candCell, local[c] === 0 ? s.candZero : "" ].join(" ")}
|
||||
>
|
||||
{local[c]}
|
||||
</span>
|
||||
))}
|
||||
|
||||
<span className={s.candCell}>对端</span>
|
||||
{cols.map((c) => (
|
||||
<span
|
||||
key={"r-" + c}
|
||||
className={[ s.candCell, remote[c] === 0 ? s.candZero : "" ].join(" ")}
|
||||
>
|
||||
{remote[c]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 把 unix 秒转 "HH:MM:SS" 本地时间,更适合诊断面板对照浏览器日志时间戳。
|
||||
function isoSeconds(secs: number): string
|
||||
{
|
||||
const d = new Date(secs * 1000);
|
||||
if (Number.isNaN(d.getTime())) { return String(secs); }
|
||||
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
}
|
||||
Reference in New Issue
Block a user