f21fa5b5e8
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)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
169 lines
6.3 KiB
TypeScript
169 lines
6.3 KiB
TypeScript
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" });
|
|
}
|