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:
2026-06-15 21:38:28 +08:00
commit f21fa5b5e8
239 changed files with 29010 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# 复制为 web/.env.local,与后端 CDROP_DEV_TOKEN 保持一致
VITE_CDROP_DEV_TOKEN=replace-with-32-byte-random-base64
# 可选:自定义 STUN(逗号分隔)。默认 stun:stun.cloudflare.com:3478
# Cloudflare 全球 anycast,国内连通性优于 Google STUN)。
# 自建 coturn 时改为 stun:your-turn.example:3478。
# VITE_CDROP_STUN_URLS=stun:stun.cloudflare.com:3478
+44
View File
@@ -0,0 +1,44 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 站点图标:吉祥物品牌资产(favicon 透明头像 avatar-head + app-icon 金底整身)。
Google 搜索要求 favicon 为 48px 整数倍,故提供 96/48;16/32 供浏览器标签;
apple-touch 走 iOS 主屏(金底不透明);manifest 供 PWA 安装。 -->
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96.png" />
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-180.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#E8B923" />
<!-- 预取品牌 logo:登录跳转回来后首页 header 立即用到;早取 + 后端一周缓存,
避免那一刻才发起请求导致的闪烁重载。 -->
<link rel="preload" as="image" href="/logo-mark.png" />
<!-- 远程字体:Orbit Gothic CJK / Maple Mono / Fredoka(品牌字标)/ Noto Serif
(含 CJK SC/TC/JP/KR)。三个 CDN 各自 host,预连接以并行 TCP/TLS。woff2 由各
CSS 内的 @font-face 声明拉取。gfonts.commilitia.net 是 Google Fonts 的
Cloudflare Worker 代理(uri 与 fonts.googleapis.com 一致,仅域名改写);
Fredoka 与 Noto Serif 子集均经此镜像,与品牌资产 README 指定的字标字体一致。 -->
<link rel="preconnect" href="https://orbit-gothic.cdn.commilitia.net" crossorigin />
<link rel="preconnect" href="https://maple-mono.cdn.commilitia.net" crossorigin />
<link rel="preconnect" href="https://gfonts.commilitia.net" crossorigin />
<link rel="stylesheet" href="https://orbit-gothic.cdn.commilitia.net/orbit-gothic-face.css" />
<link rel="stylesheet" href="https://maple-mono.cdn.commilitia.net/maple-mono.css" />
<link
rel="stylesheet"
href="https://gfonts.commilitia.net/css2?family=Fredoka:wght@300..700&family=Noto+Serif+JP:wght@200..900&family=Noto+Serif+KR:wght@200..900&family=Noto+Serif+SC:wght@200..900&family=Noto+Serif+TC:wght@200..900&family=Noto+Serif:ital,wght@0,100..900;1,100..900&display=swap"
/>
<title>Commilitia Drop</title>
</head>
<body>
<!-- class="juzhen" 启用聚珍作用域:薄壳 scope.include ".juzhen" 仅处理此
子树,normalize(关原生 text-autospace)亦收敛于此,不外溢到 body 上的
Mantine portal。 -->
<div id="root" class="juzhen"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3671
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "cdrop-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@mantine/core": "^7.13.5",
"@mantine/dropzone": "^7.13.5",
"@mantine/hooks": "^7.13.5",
"@mantine/notifications": "^7.13.5",
"@tanstack/react-router": "^1.82.0",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^4.5.5"
},
"devDependencies": {
"@tanstack/router-plugin": "^1.82.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"tailwindcss": "^3.4.15",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}
+16
View File
@@ -0,0 +1,16 @@
export default {
plugins: {
"postcss-preset-mantine": {},
"postcss-simple-vars": {
variables: {
"mantine-breakpoint-xs": "36em",
"mantine-breakpoint-sm": "48em",
"mantine-breakpoint-md": "62em",
"mantine-breakpoint-lg": "75em",
"mantine-breakpoint-xl": "88em",
},
},
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 850 KiB

+11
View File
@@ -0,0 +1,11 @@
{
"name": "Commilitia Drop",
"short_name": "CDrop",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
],
"theme_color": "#E8B923",
"background_color": "#E8B923",
"display": "standalone"
}
+162
View File
@@ -0,0 +1,162 @@
.shell
{
min-height: 100vh;
background: var(--surface-sunken);
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-5) var(--space-4);
position: relative;
}
.topbar
{
position: absolute;
top: var(--space-4);
left: var(--space-5);
right: var(--space-5);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-2);
}
/* 页面级 logo 容器:仅定字号(驱动内部 Wordmark 的图文整体大小),
字体 / 字重 / 双色由内部 Wordmark 负责。 */
.brand
{
font-size: var(--fs-22);
}
.center
{
flex: 1;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.card
{
width: 100%;
max-width: 440px;
background: var(--surface-raised);
border-radius: var(--radius-card);
box-shadow: var(--shadow-raised);
border: 1px solid var(--divider);
padding: var(--space-6) var(--space-6) var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-4);
position: relative;
}
/* 顶部 3px accent 条:整张卡的语义锚,在浅 / 深主题里都能突出。 */
.card::before
{
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--accent);
border-radius: var(--radius-card) var(--radius-card) 0 0;
}
/* 卡内品牌字标:表单 hero 上方的小号 logo,内部 Wordmark 负责字体 / 双色。
字号小于顶栏 .brand —— 顶栏是页面级 logo,这里是表单 hero 的一部分。 */
.cardBrand
{
display: inline-flex;
align-items: center;
font-size: var(--fs-18);
margin-bottom: calc(var(--space-1) * -1);
}
.hero
{
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.title
{
margin: 0;
font-size: var(--fs-28);
font-weight: 600;
line-height: var(--lh-title);
letter-spacing: -0.01em;
color: var(--text);
}
.subtitle
{
margin: 0;
font-size: var(--fs-13);
line-height: var(--lh-body);
color: var(--text-muted);
}
.body
{
display: flex;
flex-direction: column;
gap: var(--space-3);
}
/* 卡内分隔线:用于"或"切换不同登录方式时。 */
.divider
{
display: flex;
align-items: center;
gap: var(--space-3);
color: var(--text-muted);
font-size: var(--fs-11);
letter-spacing: var(--ls-label);
text-transform: uppercase;
margin: var(--space-1) 0;
}
.divider::before,
.divider::after
{
content: "";
flex: 1;
height: 1px;
background: var(--divider);
}
/* footer:卡片底部留给版权 / 链接行,与卡内主行为隔开。 */
.footer
{
text-align: center;
color: var(--text-muted);
font-size: var(--fs-11);
padding: var(--space-4) 0 0;
}
.footerLinks
{
display: inline-flex;
align-items: center;
gap: var(--space-3);
justify-content: center;
}
.footerLinks a
{
color: var(--text-muted);
text-decoration: none;
transition: color var(--dur-fast) var(--ease-out);
}
.footerLinks a:hover { color: var(--accent); text-decoration: underline; }
.footerLinks .sep { color: var(--divider); }
@media (max-width: 480px)
{
.card { padding: var(--space-5) var(--space-4); }
.title { font-size: var(--fs-22); }
.cardBrand { font-size: var(--fs-11); }
}
+83
View File
@@ -0,0 +1,83 @@
import type { ReactNode } from "react";
import { IconButton, Tooltip } from "../../ui/primitives";
import { Wordmark } from "../../ui/brand";
import { Monitor, Moon, Sun } from "lucide-react";
import { useAppStore, type ThemeMode } from "../../store";
import { t } from "../../i18n";
import s from "./AuthShell.module.css";
export interface AuthShellProps
{
title: ReactNode;
subtitle?: ReactNode;
children: ReactNode;
footer?: ReactNode;
/** 卡内品牌字标。默认不渲染——顶栏已有 logo,卡内再放会一屏两 logo;
* 需要时显式传入一个节点即可。 */
cardBrand?: ReactNode | null;
/** 隐藏顶栏 logo。过渡型认证屏(OAuth 回调 / 首次设置)用:它们会 SPA
* 跳转到主界面,跳转帧里顶栏 logo 会与主界面 header 的 logo 重叠成“两个
* logo”,隐藏即可消除。登录页是整页跳 IdP、无此重叠,保留 logo。 */
hideBrand?: boolean;
}
// 全屏外壳:登录、首次设置、OAuth 回调共用。带轻量品牌 + 主题切换,
// 让用户在登录前也能调主题。
export function AuthShell(props: AuthShellProps)
{
const { title, subtitle, children, footer, cardBrand, hideBrand } = props;
const theme = useAppStore((s) => s.theme);
const setTheme = useAppStore((s) => s.setTheme);
const next: ThemeMode = theme === "system" ? "light" : theme === "light" ? "dark" : "system";
const icon = theme === "light" ? <Sun size={16} />
: theme === "dark" ? <Moon size={16} />
: <Monitor size={16} />;
// 默认不在卡内重复 logo(顶栏已有),避免一屏两 logo;需要时显式传入。
const brandNode = cardBrand === undefined ? null : cardBrand;
return (
<div className={s.shell}>
<div className={s.topbar}>
{/* 空 span 占位保持 space-between,主题切换仍靠右。 */}
{hideBrand ? <span /> : <span className={s.brand}><Wordmark /></span>}
<Tooltip label={t("nav.themeToggle")}>
<IconButton aria-label={t("nav.themeToggle")} size="sm" onClick={() => setTheme(next)}>
{icon}
</IconButton>
</Tooltip>
</div>
<div className={s.center}>
<div className={s.card}>
{brandNode && <div className={s.cardBrand}>{brandNode}</div>}
<div className={s.hero}>
<h1 className={s.title}>{title}</h1>
{subtitle && <p className={s.subtitle} data-jz-level="paragraph">{subtitle}</p>}
</div>
<div className={s.body}>
{children}
</div>
</div>
</div>
{footer && <div className={s.footer}>{footer}</div>}
</div>
);
}
// 公共 footer 链接组件:登录页用,setup / oauth 不一定要。
export function AuthFooterLinks(props: { items: { label: string; href: string }[] })
{
const { items } = props;
return (
<div className={s.footerLinks}>
{items.map((it, i) => (
<span key={it.href} style={{ display: "inline-flex", gap: "var(--space-3)", alignItems: "center" }}>
{i > 0 && <span className={s.sep}>·</span>}
<a href={it.href} target="_blank" rel="noreferrer noopener">{it.label}</a>
</span>
))}
</div>
);
}
+220
View File
@@ -0,0 +1,220 @@
import { useAppStore, type User } from "../../store";
import { t } from "../../i18n";
import { apiFetch } from "../../net/api";
import { isDesktop, desktopRefresh, clearDesktopSession } from "../../net/desktop";
// ---- dev mode -------------------------------------------------------------
// Dev-mode "login": read ?dev_user=alice (or fall back to localStorage),
// stamp the store with a synthetic User and the build-time dev token.
// This bypasses Casdoor entirely. The middleware on the backend accepts
// any X-Dev-User as long as the bearer matches CDROP_DEV_TOKEN.
export function loginDev(searchParams: URLSearchParams): User
{
const devUser = searchParams.get("dev_user")
?? window.localStorage.getItem("cdrop.dev_user")
?? "dev-user";
window.localStorage.setItem("cdrop.dev_user", devUser);
const token = import.meta.env.VITE_CDROP_DEV_TOKEN;
if (!token)
{
throw new Error(t("errors.devTokenMissing"));
}
const user: User = { id: devUser, name: devUser };
useAppStore.getState().setAuth({ accessToken: token, user });
return user;
}
export function logout()
{
// Fire-and-forget 通知后端立刻把本设备从 Hub 踢掉 + 广播 presence,让对端
// 不必等 30s 宽限期。apiFetch 同步抓取 access_token 后才让出(fetch 已发出),
// 即使紧随其后 clearAuth 也不会污染这次请求的 Authorization 头。失败忽略
// ——只是体验降级回宽限期路径,不影响登出本身。
void apiFetch("/api/me/disconnect", { method: "POST" }).catch(() => { /* ignore */ });
useAppStore.getState().clearAuth();
// 桌面端:删除 Go 侧持久化 session 文件,否则下次启动仍会注入已登录态。
if (isDesktop()) { clearDesktopSession(); }
}
// ---- prod (OIDC PKCE) -----------------------------------------------------
interface AuthConfig
{
auth_mode: "dev" | "prod";
authorize_url: string;
client_id: string;
redirect_uri: string;
scopes: string;
}
let cachedConfig: AuthConfig | null = null;
export async function fetchAuthConfig(): Promise<AuthConfig>
{
if (cachedConfig) { return cachedConfig; }
const r = await fetch("/api/auth/config");
if (!r.ok) { throw new Error(`auth config ${r.status}`); }
cachedConfig = await r.json() as AuthConfig;
return cachedConfig;
}
const PKCE_VERIFIER_KEY = "cdrop.pkce_verifier";
const OAUTH_STATE_KEY = "cdrop.oauth_state";
// loginProd kicks off OIDC PKCE: generate verifier+challenge+state, stash the
// secrets in sessionStorage, navigate the browser to the provider's
// /authorize endpoint. The callback page completes the flow.
export async function loginProd(): Promise<void>
{
const cfg = await fetchAuthConfig();
if (cfg.auth_mode !== "prod")
{
throw new Error(`server reports auth_mode=${cfg.auth_mode}, expected prod`);
}
if (!cfg.authorize_url || !cfg.client_id || !cfg.redirect_uri)
{
throw new Error("OIDC config incomplete (authorize_url / client_id / redirect_uri)");
}
const verifier = generateRandomBase64url(32);
const challenge = await sha256Base64url(verifier);
const state = generateRandomBase64url(16);
sessionStorage.setItem(PKCE_VERIFIER_KEY, verifier);
sessionStorage.setItem(OAUTH_STATE_KEY, state);
const params = new URLSearchParams({
client_id: cfg.client_id,
redirect_uri: cfg.redirect_uri,
response_type: "code",
scope: cfg.scopes || "openid profile email",
state,
code_challenge: challenge,
code_challenge_method: "S256",
});
window.location.href = `${cfg.authorize_url}?${params}`;
}
interface ExchangeResp
{
access_token: string;
refresh_token: string;
expires_in: number;
user: { id: string; name: string; avatar?: string };
}
// completeOAuthLogin runs on /oauth/callback after the provider redirects back.
// Verifies state (CSRF), trades code+verifier for tokens via the backend
// proxy, and stamps the store with the user identity.
//
// Idempotent:若 store 中已有 user + accessToken(典型场景:useEffect 在 React
// 渲染管线中重入,首次已成功 setAuth + 消耗 sessionStorage,二次入场不能再被
// "PKCE verifier missing" / "state mismatch" 当作失败处理)→ 当作 noop 直接
// resolve,由上层 navigate 接管。
export async function completeOAuthLogin(code: string, state: string): Promise<void>
{
const cur = useAppStore.getState();
if (cur.user && cur.accessToken)
{
return;
}
const expectedState = sessionStorage.getItem(OAUTH_STATE_KEY);
if (!expectedState || state !== expectedState)
{
throw new Error("OAuth state mismatch (possible CSRF or stale tab)");
}
const verifier = sessionStorage.getItem(PKCE_VERIFIER_KEY);
if (!verifier)
{
throw new Error("PKCE verifier missing — restart the login flow");
}
const r = await fetch("/api/auth/exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, code_verifier: verifier }),
});
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`token exchange ${r.status}: ${text}`);
}
const data = await r.json() as ExchangeResp;
sessionStorage.removeItem(PKCE_VERIFIER_KEY);
sessionStorage.removeItem(OAUTH_STATE_KEY);
useAppStore.getState().setAuth({
accessToken: data.access_token,
refreshToken: data.refresh_token,
user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar },
});
}
// refreshTokens proxies to /api/auth/refresh. Returns true on success.
// api.ts calls this lazily when a request comes back 401.
export async function refreshTokens(): Promise<boolean>
{
const store = useAppStore.getState();
if (store.authMode !== "prod") { return false; }
// 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 GoJS 不持有),故
// 无需 store.refreshToken,直接无参调用。
if (isDesktop())
{
const res = await desktopRefresh();
if (!res?.access_token) { return false; }
const cur = useAppStore.getState();
if (!cur.user) { return false; }
cur.setAuth({ accessToken: res.access_token, user: cur.user });
return true;
}
const refreshToken = store.refreshToken;
if (!refreshToken) { return false; }
const r = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!r.ok) { return false; }
const data = await r.json() as { access_token: string; refresh_token?: string };
if (!data.access_token) { return false; }
const cur = useAppStore.getState();
if (!cur.user) { return false; }
cur.setAuth({
accessToken: data.access_token,
refreshToken: data.refresh_token ?? refreshToken,
user: cur.user,
});
return true;
}
// ---- PKCE helpers ---------------------------------------------------------
function generateRandomBase64url(numBytes: number): string
{
const arr = new Uint8Array(numBytes);
crypto.getRandomValues(arr);
return base64urlEncode(arr);
}
async function sha256Base64url(input: string): Promise<string>
{
const buf = new TextEncoder().encode(input);
const hash = await crypto.subtle.digest("SHA-256", buf);
return base64urlEncode(new Uint8Array(hash));
}
function base64urlEncode(bytes: Uint8Array): string
{
let s = "";
for (let i = 0; i < bytes.length; i += 1) { s += String.fromCharCode(bytes[i]); }
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
@@ -0,0 +1,104 @@
.root
{
display: flex;
flex-direction: column;
gap: var(--space-3);
min-width: 0; /* grid 子项默认 min-width auto,长内容会撑破列宽 */
}
.cloud
{
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.cloudEmpty
{
color: var(--text-muted);
font-size: var(--fs-13);
padding: var(--space-3) 0;
}
/* meta 行(大小 · 来源 · 时间)总是可见,是用户决定要不要 "显示" 内容的依据。 */
.metaRow
{
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
font-size: var(--fs-11);
color: var(--text-muted);
padding: var(--space-2) var(--space-3);
background: var(--surface-sunken);
border: 1px solid var(--divider);
border-radius: var(--radius-control);
}
.metaSize { font-family: var(--font-mono); }
.metaFrom { color: var(--success); font-weight: 600; }
/* shade:默认隐藏态的占位。dashed 边 + muted 文字。 */
.shade
{
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-4) var(--space-3);
background: var(--surface-sunken);
border: 1px dashed var(--divider);
border-left: 3px solid var(--text-muted);
border-radius: var(--radius-control);
color: var(--text-muted);
font-size: var(--fs-12);
text-align: center;
}
.shade svg { flex-shrink: 0; }
/* 显示态内容:mono 字体,让 URL / token / 代码片段对齐;
长文本可滚动,防止撑高 panel。 */
.content
{
background: var(--surface-sunken);
border: 1px solid var(--divider);
border-left: 3px solid var(--success);
border-radius: var(--radius-control);
padding: var(--space-3);
font-family: var(--font-mono);
font-size: var(--fs-12);
line-height: 1.55;
color: var(--text);
white-space: pre-wrap;
word-break: break-all;
max-height: 220px;
overflow: auto;
}
.divider
{
height: 1px;
background: var(--divider);
margin: var(--space-2) 0;
}
.uploadHint { color: var(--text-muted); font-size: var(--fs-11); }
/* 按钮组:占满 panel 宽,多按钮均分;单按钮即 block。 */
.actions
{
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
width: 100%;
}
.actions > *
{
flex: 1 1 auto;
min-width: 0;
}
/* 按钮内文字允许换行(覆盖 Button 默认 nowrap),窄列宽下不溢出。 */
.actions button
{
white-space: normal;
line-height: 1.25;
}
@@ -0,0 +1,216 @@
import { useEffect, useRef, useState } from "react";
import {
ClipboardCopy,
ClipboardPaste,
Eye,
EyeOff,
RefreshCw,
Trash2,
} from "lucide-react";
import { Button, IconButton, Panel, Tooltip } from "../../ui/primitives";
import { toast } from "../../ui/feedback";
import type { ClipboardState } from "../../store";
import { t } from "../../i18n";
import { formatBytes, formatRelative } from "../../utils/format";
import {
fetchClipboard,
readClipboardText,
uploadClipboard,
utf8ByteLength,
} from "./clipboard";
import s from "./ClipboardPanel.module.css";
export interface ClipboardPanelProps
{
state: ClipboardState | null;
selfDevice: string | null;
}
// 默认隐藏内容(隐私默认):仅展示 meta(大小 · 类型 · 来自 X · 时间),
// 用户必须点 "显示" 才能看到原文。新内容到达(SSE 推送 / 上传回声 / 切换设备)
// 自动收回隐藏态,防止上一次显示的暴露面延续到不同内容。
//
// 操作结果走 toast。
export function ClipboardPanel(props: ClipboardPanelProps)
{
const { state, selfDevice } = props;
const [ busy, setBusy ] = useState(false);
const [ revealed, setRevealed ] = useState(false);
const hasContent = !!state && state.content.length > 0;
const isSelf = hasContent && !!selfDevice && state.sourceDevice === selfDevice;
// updatedAt 变化即视为"新内容"——任何来源(远端 SSE、自己上传后的回声、
// 切换 selfDevice 后从 ETag 拉回的不同内容)都会触发收回。
const lastUpdatedRef = useRef<number>(state?.updatedAt ?? 0);
useEffect(() =>
{
const cur = state?.updatedAt ?? 0;
if (cur !== lastUpdatedRef.current)
{
lastUpdatedRef.current = cur;
setRevealed(false);
}
}, [ state?.updatedAt ]);
const sizeText = hasContent
? formatBytes(utf8ByteLength(state.content))
: "";
const fromText = hasContent
? ( isSelf
? t("home.clipboard.cloudFromSelf", { time: formatRelative(state.updatedAt) })
: t("home.clipboard.cloudFrom", {
name: state.sourceDevice || "?",
time: formatRelative(state.updatedAt),
}) )
: "";
const handleUpload = async () =>
{
setBusy(true);
try
{
const text = await readClipboardText();
if (!text) { toast.warn(t("home.clipboard.uploadEmpty")); return; }
await uploadClipboard(text);
toast.ok("已上传到云端");
}
catch (e)
{
toast.error("剪贴板上传失败", e instanceof Error ? e.message : String(e));
}
finally { setBusy(false); }
};
const handleCopy = async () =>
{
if (!state) { return; }
try
{
await navigator.clipboard.writeText(state.content);
toast.ok(t("home.clipboard.copyToLocalSuccess"));
}
catch (e)
{
toast.error(t("errors.clipboardWriteFailed", {
message: e instanceof Error ? e.message : String(e),
}));
}
};
const handleRefresh = async () =>
{
try { await fetchClipboard(); }
catch (e)
{
toast.error("刷新失败", e instanceof Error ? e.message : String(e));
}
};
const handleClear = async () =>
{
if (!hasContent) { return; }
if (!window.confirm(t("home.clipboard.clearConfirm"))) { return; }
setBusy(true);
try
{
await uploadClipboard("");
toast.ok(t("home.clipboard.clearSuccess"));
}
catch (e)
{
toast.error("清空失败", e instanceof Error ? e.message : String(e));
}
finally { setBusy(false); }
};
const toggleReveal = () => { setRevealed((v) => !v); };
return (
<Panel
padding="default"
title={t("home.tabs.clipboard")}
actions={
<div style={{ display: "inline-flex", gap: 4 }}>
<Tooltip label={t("home.clipboard.refresh")}>
<IconButton
aria-label={t("home.clipboard.refresh")}
size="sm"
onClick={handleRefresh}
>
<RefreshCw size={14} />
</IconButton>
</Tooltip>
<Tooltip label={t("home.clipboard.clear")}>
<IconButton
aria-label={t("home.clipboard.clear")}
size="sm"
onClick={handleClear}
disabled={!hasContent || busy}
>
<Trash2 size={14} />
</IconButton>
</Tooltip>
</div>
}
>
<div className={s.root}>
{hasContent ? (
<div className={s.cloud}>
<div className={s.metaRow}>
<span className={s.metaSize}>{sizeText}</span>
<span className={s.metaFrom}>{fromText}</span>
</div>
{revealed ? (
<div className={s.content} data-jz-level="paragraph">{state.content}</div>
) : (
<div className={s.shade}>
<EyeOff size={16} />
<span>{t("home.clipboard.hidden")}</span>
</div>
)}
<div className={s.actions}>
<Button
variant={revealed ? "secondary" : "primary"}
size="sm"
leftIcon={revealed ? <EyeOff size={14} /> : <Eye size={14} />}
onClick={toggleReveal}
>
{revealed
? t("home.clipboard.hide")
: t("home.clipboard.reveal")}
</Button>
<Button
variant="secondary"
size="sm"
leftIcon={<ClipboardCopy size={14} />}
onClick={handleCopy}
>
{t("home.clipboard.copyToLocal")}
</Button>
</div>
</div>
) : (
<div className={s.cloudEmpty}>{t("home.clipboard.cloudEmpty")}</div>
)}
<div className={s.divider} />
<div className={s.uploadHint}>{t("home.clipboard.uploadHint")}</div>
<div className={s.actions}>
<Button
variant="primary"
size="sm"
leftIcon={<ClipboardPaste size={14} />}
loading={busy}
onClick={handleUpload}
>
{busy ? t("home.clipboard.uploading") : t("home.clipboard.uploadButton")}
</Button>
</div>
</div>
</Panel>
);
}
+174
View File
@@ -0,0 +1,174 @@
import { apiFetch } from "../../net/api";
import { writeNativeClipboard } from "../../net/desktop";
import { useAppStore, type ClipboardState } from "../../store";
import { t } from "../../i18n";
// 字节上限要与后端 CDROP_CLIPBOARD_MAX_BYTES 同步(默认 64 KB)。
// 前端按 UTF-8 字节算长度(不是 string.length,后者是 UTF-16 code unit)。
export const CLIPBOARD_MAX_BYTES = 64 * 1024;
interface PutBody
{
content: string;
content_type: "text/plain";
// origin_ts 是本机复制时刻(ms),服务端据它做 LWW。浏览器拿不到精确复制时间,
// 用读取/上传瞬间的 Date.now() 近似——比服务端「收到时刻」更贴近真实复制时刻。
origin_ts: number;
}
// readClipboardText 主路径:readText() 拿浏览器为常规复制场景自动合成的
// text/plain;只有当 readText 返回空且 read() 可用时,才 fallback 到读取
// ClipboardItem,按 text/plain → text/html (DOMParser 提 textContent) 顺序
// 取文本,避免"源应用只塞 text/html 时丢失文本"。
export async function readClipboardText(): Promise<string>
{
if (!navigator.clipboard)
{
throw new Error(t("errors.clipboardUnavailable"));
}
try
{
const text = await navigator.clipboard.readText();
if (text) { return text; }
}
catch
{
// 主路径不可用(权限 / 不支持)→ 降级到 read()
}
if (typeof navigator.clipboard.read !== "function")
{
return "";
}
const items = await navigator.clipboard.read();
for (const item of items)
{
if (item.types.includes("text/plain"))
{
const blob = await item.getType("text/plain");
const text = await blob.text();
if (text) { return text; }
}
if (item.types.includes("text/html"))
{
const blob = await item.getType("text/html");
const html = await blob.text();
const text = htmlToPlainText(html);
if (text) { return text; }
}
}
return "";
}
// htmlToPlainText 用 DOMParser 解析 HTML(不执行脚本),按块级元素补换行后
// 取 textContent。XSS 风险为零——我们丢弃所有标签,只保留文本节点内容。
export function htmlToPlainText(html: string): string
{
const doc = new DOMParser().parseFromString(html, "text/html");
doc.querySelectorAll("br").forEach((br) => br.replaceWith("\n"));
doc
.querySelectorAll("p, div, li, h1, h2, h3, h4, h5, h6, tr, blockquote, pre")
.forEach((b) => b.append("\n"));
return ( doc.body.textContent ?? "" ).replace(/\n{3,}/g, "\n\n").trim();
}
export function utf8ByteLength(text: string): number
{
return new TextEncoder().encode(text).length;
}
// uploadClipboard 把一段纯文本作为剪贴板写入推到云端(PUT /api/clipboard)。
// 字节超限 / 空字符串 / 网络错误统一抛 Error,调用点显示给用户。
export async function uploadClipboard(content: string): Promise<void>
{
if (utf8ByteLength(content) > CLIPBOARD_MAX_BYTES)
{
throw new Error(t("errors.clipboardOverflow", { max: CLIPBOARD_MAX_BYTES }));
}
const body: PutBody = { content, content_type: "text/plain", origin_ts: Date.now() };
const r = await apiFetch("/api/clipboard", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
// 409 = LWW 落败:别的设备有更晚的复制赢了。不是错误——胜出内容会经 SSE 到达,
// 这里静默返回即可,不打扰用户。
if (r.status === 409) { return; }
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`PUT /api/clipboard ${r.status}: ${text}`);
}
// 立即把自己的写入合入本地 store(用服务端回的权威 version / updated_at),
// 上传后 UI 立刻反映"云端最新";同步窗口结束后 SSE 回声内容相同、被自回声挡掉。
const accepted = await r.json().catch(() => null) as { version?: number; updated_at?: number } | null;
const state = useAppStore.getState();
state.setClipboard({
content,
contentType: "text/plain",
sourceDevice: state.selfDeviceName ?? "",
updatedAt: accepted?.updated_at ?? Math.floor(Date.now() / 1000),
version: accepted?.version ?? 0,
});
}
// fetchClipboard 用 If-None-Match 拉一次最新;304 表示与本地内存一致,不动 store。
// ETag 直接从 store.clipboard.updatedAt 派生(与"本地内存"等价),刷新后内存清零
// → 不发 If-None-Match → 总是拉到最新内容。后端在尚未写入时返回 200 + 空 State
// + ETag "0",所以这里不需要再处理 404。
export async function fetchClipboard(): Promise<void>
{
const headers = new Headers();
const cur = useAppStore.getState().clipboard;
if (cur && cur.version > 0)
{
// ETag 是版本号(与后端 etagFor(version) 对齐);版本未变即 304、省下整份内容。
headers.set("If-None-Match", `"${cur.version}"`);
}
const r = await apiFetch("/api/clipboard", { headers });
if (r.status === 304) { return; }
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`GET /api/clipboard ${r.status}: ${text}`);
}
const data = await r.json() as {
content: string;
content_type: string;
source_device: string;
updated_at: number;
version: number;
};
applyRemoteClipboard({
content: data.content,
contentType: data.content_type,
sourceDevice: data.source_device,
updatedAt: data.updated_at,
version: data.version,
});
}
// applyRemoteClipboard 由 SSE clipboard:update 事件 / fetchClipboard 调用。
// 自回声(source_device == 本机)时不覆盖——本机 PUT 后已即时写过 store。
export function applyRemoteClipboard(state: ClipboardState): void
{
const selfDevice = useAppStore.getState().selfDeviceName;
const cur = useAppStore.getState().clipboard;
if (selfDevice && state.sourceDevice === selfDevice && cur?.updatedAt === state.updatedAt)
{
return;
}
useAppStore.getState().setClipboard(state);
// 桌面端:把来自其他设备的更新写回本机原生剪贴板(下行同步)。自身来源的回声
// 不写(Go 侧 Monitor 亦会再挡一层);浏览器里 writeNativeClipboard 为 no-op。
if (!selfDevice || state.sourceDevice !== selfDevice)
{
writeNativeClipboard(state.content, state.sourceDevice);
}
}
@@ -0,0 +1,140 @@
import { useEffect, useState } from "react";
import { Group, Stack, Switch, Text } from "@mantine/core";
import { Button, Panel } from "../../ui/primitives";
import { toast } from "../../ui/feedback";
import { t } from "../../i18n";
import {
chooseDownloadDir,
effectiveDownloadDir,
loadDesktopSettings,
saveDesktopSettings,
type DesktopConfig,
} from "../../net/desktop";
// DesktopSettings 是桌面专属的设置区块,仅在 Wails 壳内渲染(调用点用 isDesktop()
// 门控)。复用 web 设计系统(Panel + Mantine Switch)。
export function DesktopSettings()
{
const [ cfg, setCfg ] = useState<DesktopConfig | null>(null);
const [ saving, setSaving ] = useState(false);
// 当前实际落盘目录(配置覆盖或系统默认),仅用于显示;config 里 download_dir
// 为空时这里仍展示解析后的默认路径。
const [ effectiveDir, setEffectiveDir ] = useState("");
useEffect(() =>
{
let alive = true;
void loadDesktopSettings().then((c) => { if (alive && c) { setCfg(c); } });
void effectiveDownloadDir().then((d) => { if (alive) { setEffectiveDir(d); } });
return () => { alive = false; };
}, []);
const update = async (patch: Partial<DesktopConfig>) =>
{
if (!cfg) { return; }
const prev = cfg;
const next = { ...cfg, ...patch };
setCfg(next);
setSaving(true);
try
{
await saveDesktopSettings(next);
}
catch (e)
{
setCfg(prev); // 回滚 UI 到保存前状态
toast.error(e instanceof Error ? e.message : String(e));
}
finally
{
setSaving(false);
}
};
const chooseDir = async () =>
{
const picked = await chooseDownloadDir(t("settings.desktop.downloadDirPicker"));
if (!picked) { return; } // 用户取消
await update({ download_dir: picked });
setEffectiveDir(picked);
};
const resetDir = async () =>
{
await update({ download_dir: "" }); // 空=回到系统默认目录
setEffectiveDir(await effectiveDownloadDir());
};
if (!cfg) { return null; }
return (
<Panel title={t("settings.desktop.title")}>
<Stack gap="md">
<ToggleRow
title={t("settings.desktop.clipboardSync")}
hint={t("settings.desktop.clipboardSyncHint")}
checked={cfg.clipboard_sync_enabled}
disabled={saving}
onChange={(v) => void update({ clipboard_sync_enabled: v })}
/>
<ToggleRow
title={t("settings.desktop.launchAtLogin")}
hint={t("settings.desktop.launchAtLoginHint")}
checked={cfg.launch_at_login}
disabled={saving}
onChange={(v) => void update({ launch_at_login: v })}
/>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Stack gap={2} style={{ minWidth: 0 }}>
<Text size="sm" fw={500}>{t("settings.desktop.downloadDir")}</Text>
<Text size="xs" c="dimmed">{t("settings.desktop.downloadDirHint")}</Text>
<Text
size="xs"
style={{ fontFamily: "var(--font-mono, ui-monospace, monospace)", wordBreak: "break-all" }}
>
{effectiveDir}
</Text>
</Stack>
<Stack gap={4} style={{ flexShrink: 0 }}>
<Button variant="secondary" size="sm" disabled={saving} onClick={() => void chooseDir()}>
{t("settings.desktop.downloadDirChoose")}
</Button>
{cfg.download_dir !== "" && (
<Button variant="ghost" size="sm" disabled={saving} onClick={() => void resetDir()}>
{t("settings.desktop.downloadDirReset")}
</Button>
)}
</Stack>
</Group>
<div style={{ height: 1, background: "var(--divider)" }} />
<Text size="xs" c="dimmed">
{t("settings.desktop.ttlNote")}
</Text>
</Stack>
</Panel>
);
}
function ToggleRow(props: {
title: string;
hint: string;
checked: boolean;
disabled: boolean;
onChange: (value: boolean) => void;
})
{
const { title, hint, checked, disabled, onChange } = props;
return (
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Stack gap={2} style={{ minWidth: 0 }}>
<Text size="sm" fw={500}>{title}</Text>
<Text size="xs" c="dimmed">{hint}</Text>
</Stack>
<Switch
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
</Group>
);
}
@@ -0,0 +1,175 @@
.strip
{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--space-3);
width: 100%;
}
.port
{
position: relative;
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--surface);
border: 1px solid var(--divider);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
cursor: pointer;
text-align: left;
font: inherit;
color: var(--text);
/* grid 单元兜底:长设备名 / 长 meta 不能撑破单元宽度;内层 body
会用 min-width:0 + ellipsis 收敛。 */
overflow: hidden;
min-width: 0;
transition:
background-color var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out),
box-shadow var(--dur-base) var(--ease-out),
transform var(--dur-fast) var(--ease-out);
}
/* selected 标记图标(<Check>)在 body 之后,锁宽防被挤压。 */
.port > svg:last-child { flex-shrink: 0; }
.port:hover:not(:disabled)
{
background: var(--surface-raised);
box-shadow: var(--shadow-raised);
transform: translateY(-2px);
}
.port:focus-visible
{
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.portSelected
{
background: var(--accent-softer);
border-color: var(--accent);
box-shadow:
0 0 0 1px var(--accent),
var(--shadow-raised);
}
.portOffline
{
opacity: 0.55;
cursor: not-allowed;
box-shadow: none;
background: var(--surface-sunken);
}
.portDragOver
{
border-color: var(--accent);
background: var(--accent-soft);
transform: scale(1.015);
}
.icon
{
flex-shrink: 0;
color: var(--text-muted);
transition: color var(--dur-fast) var(--ease-out);
}
.portSelected .icon { color: var(--accent); }
.body
{
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.name
{
font-size: var(--fs-13);
font-weight: 600;
color: var(--text);
line-height: var(--lh-ui);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta
{
display: flex;
align-items: center;
gap: 5px;
font-size: var(--fs-11);
color: var(--text-muted);
line-height: 1;
min-width: 0;
}
/* StateDot 锁宽;文本 span 走 ellipsis,避免"上次活跃 X 分钟前"这类长串溢出。 */
.meta > :first-child { flex-shrink: 0; }
.meta > span
{
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.selfBadge
{
margin-left: auto;
font-size: var(--fs-11);
color: var(--accent);
text-transform: uppercase;
letter-spacing: var(--ls-label);
}
.empty
{
padding: var(--space-5);
background: var(--surface-sunken);
border-radius: var(--radius-card);
color: var(--text-muted);
font-size: var(--fs-13);
text-align: center;
}
.header
{
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-2);
}
.headerTitle
{
font-size: var(--fs-13);
font-weight: 600;
letter-spacing: var(--ls-label);
text-transform: uppercase;
color: var(--text-muted);
}
.headerCount { color: var(--text-muted); font-size: var(--fs-12); margin-left: 4px; }
.headerSwitch
{
background: transparent;
border: 0;
color: var(--text-muted);
font: inherit;
font-size: var(--fs-12);
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius-control);
transition: background-color var(--dur-fast) var(--ease-out);
}
.headerSwitch:hover { background: var(--accent-soft); color: var(--accent); }
+159
View File
@@ -0,0 +1,159 @@
import { useMemo, useState, type DragEvent } from "react";
import { Check } from "lucide-react";
import { DeviceTypeIcon, StateDot } from "../../ui/glyphs";
import type { DeviceKind } from "../../ui/glyphs";
import type { DeviceInfo } from "../../store";
import { formatRelative } from "../../utils/format";
import { t } from "../../i18n";
import s from "./DeviceStrip.module.css";
export interface DeviceStripProps
{
devices: DeviceInfo[];
selfDeviceName: string | null;
selectedDevice: string | null;
onSelect: (name: string | null) => void;
/** 拖拽文件落到设备卡上,跳过 composer 直接发送。 */
onDropFile?: (deviceName: string, file: File) => void;
}
export function DeviceStrip(props: DeviceStripProps)
{
const { devices, selfDeviceName, selectedDevice, onSelect, onDropFile } = props;
const [ showOffline, setShowOffline ] = useState(false);
const peers = useMemo(
() => devices.filter((d) => d.name !== selfDeviceName),
[ devices, selfDeviceName ],
);
const visible = useMemo(
() => (showOffline ? peers : peers.filter((d) => d.online)),
[ peers, showOffline ],
);
const onlineCount = peers.filter((d) => d.online).length;
const offlineCount = peers.length - onlineCount;
return (
<section>
<div className={s.header}>
<div>
<span className={s.headerTitle}>{t("home.deviceList.title")}</span>
<span className={s.headerCount}>
{t("home.deviceList.onlineSuffix", { count: onlineCount })}
</span>
</div>
{offlineCount > 0 && (
<button
type="button"
className={s.headerSwitch}
onClick={() => setShowOffline((v) => !v)}
>
{showOffline
? "隐藏离线"
: t("home.deviceList.showOffline", { count: offlineCount })}
</button>
)}
</div>
{visible.length === 0 ? (
<div className={s.empty}>{t("home.deviceList.empty")}</div>
) : (
<div className={s.strip}>
{visible.map((d) => (
<DevicePort
key={d.name}
device={d}
selected={selectedDevice === d.name}
onClick={() => onSelect(selectedDevice === d.name ? null : d.name)}
onDropFile={onDropFile}
/>
))}
</div>
)}
</section>
);
}
interface DevicePortProps
{
device: DeviceInfo;
selected: boolean;
onClick: () => void;
onDropFile?: (deviceName: string, file: File) => void;
}
function DevicePort(props: DevicePortProps)
{
const { device, selected, onClick, onDropFile } = props;
const [ dragOver, setDragOver ] = useState(false);
const disabled = !device.online;
const handleDragOver = (e: DragEvent<HTMLButtonElement>) =>
{
if (disabled || !onDropFile) { return; }
if (!Array.from(e.dataTransfer.types).includes("Files")) { return; }
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setDragOver(true);
};
const handleDragLeave = () => { setDragOver(false); };
const handleDrop = (e: DragEvent<HTMLButtonElement>) =>
{
if (disabled || !onDropFile) { return; }
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files[0];
if (f) { onDropFile(device.name, f); }
};
const cls = [
s.port,
selected ? s.portSelected : "",
disabled ? s.portOffline : "",
dragOver ? s.portDragOver : "",
].filter(Boolean).join(" ");
return (
<button
type="button"
className={cls}
onClick={onClick}
disabled={disabled}
aria-pressed={selected}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className={s.icon}>
<DeviceTypeIcon kind={mapKind(device.type)} size={22} />
</div>
<div className={s.body}>
<div className={s.name}>{device.name}</div>
<div className={s.meta}>
<StateDot tone={device.online ? "online" : "offline"} />
<span>
{device.online
? t("settings.online")
: t("settings.lastSeen", { time: formatRelative(device.lastSeen) })}
</span>
</div>
</div>
{selected && <Check size={14} aria-hidden />}
</button>
);
}
// 后端目前所有 web 接入都写 type="browser";客户端时代会扩展为 macos/windows/...
function mapKind(type: string): DeviceKind
{
const t = type.toLowerCase();
if (t === "shortcut") { return "shortcut"; }
if (t.includes("mac") || t === "darwin") { return "macos"; }
if (t.includes("win")) { return "windows"; }
if (t.includes("linux")) { return "linux"; }
if (t.includes("ios") || t.includes("iphone") || t.includes("ipad")) { return "ios"; }
if (t.includes("android")) { return "android"; }
return "unknown";
}
@@ -0,0 +1,88 @@
.section
{
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.head
{
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
.title
{
font-size: var(--fs-12);
font-weight: 600;
letter-spacing: var(--ls-label);
text-transform: uppercase;
color: var(--text-muted);
}
.count { color: var(--text-muted); font-size: var(--fs-12); margin-left: 4px; }
.list
{
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.row
{
display: flex;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--surface);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
/* 描边:1px divider 全包,左侧 3px 角色色覆盖。
消息走 emphasis(pink)区分于 device / activity / clipboard;
默认(outgoing,自发) = muted 左条,incoming = emphasis 左条
让"刚收到的消息"在视觉上更跳。 */
border: 1px solid var(--divider);
border-left: 3px solid var(--text-muted);
}
.rowIncoming { border-left-color: var(--emphasis); }
.body
{
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.meta
{
font-family: var(--font-mono);
font-size: var(--fs-11);
color: var(--text-muted);
}
.text
{
font-size: var(--fs-13);
line-height: var(--lh-body);
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
}
.dismiss
{
flex-shrink: 0;
background: transparent;
border: 0;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
border-radius: var(--radius-control);
transition: color var(--dur-fast) var(--ease-out), background-color var(--dur-fast) var(--ease-out);
}
.dismiss:hover { background: var(--accent-soft); color: var(--accent); }
@@ -0,0 +1,65 @@
import { X } from "lucide-react";
import { Button } from "../../ui/primitives";
import type { MessageRecord } from "../../store";
import { t } from "../../i18n";
import { formatRelative } from "../../utils/format";
import s from "./MessageList.module.css";
export interface MessageListProps
{
messages: MessageRecord[];
onDismiss: (id: string) => void;
onClearAll: () => void;
}
export function MessageList(props: MessageListProps)
{
const { messages, onDismiss, onClearAll } = props;
if (messages.length === 0) { return null; }
return (
<section className={s.section}>
<div className={s.head}>
<div>
<span className={s.title}>{t("home.message.title")}</span>
<span className={s.count}>
{t("home.message.countSuffix", { count: messages.length })}
</span>
</div>
<Button variant="ghost" size="sm" onClick={onClearAll}>
{t("home.message.clearAll")}
</Button>
</div>
<div className={s.list}>
{messages.map((m) => (
<MessageRow key={m.id} m={m} onDismiss={() => onDismiss(m.id)} />
))}
</div>
</section>
);
}
function MessageRow(props: { m: MessageRecord; onDismiss: () => void })
{
const { m, onDismiss } = props;
const arrow = m.direction === "outgoing" ? "→" : "←";
const cls = [ s.row, m.direction === "incoming" ? s.rowIncoming : "" ].join(" ");
return (
<div className={cls}>
<div className={s.body}>
<div className={s.meta}>{arrow} {m.peerName} · {formatRelative(m.sentAt)}</div>
<div className={`${s.text} justify`} data-jz-level="paragraph">{m.text}</div>
</div>
<button
type="button"
className={s.dismiss}
aria-label={t("common.dismiss")}
onClick={onDismiss}
>
<X size={14} />
</button>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { apiFetch } from "../../net/api";
import { useAppStore } from "../../store";
import { t } from "../../i18n";
// One-shot text-only message between same-user devices. Reuses the SSE Hub
// the same way the file-transfer signaling channel does (POST /api/message).
// No DB persistence; the receiver-side store keeps it in memory only.
export async function sendMessage(receiverName: string, text: string): Promise<void>
{
const trimmed = text.trim();
if (!trimmed) { throw new Error(t("errors.messageEmpty")); }
if (trimmed.length > 4096)
{
throw new Error(t("errors.messageOverflow"));
}
if (!receiverName) { throw new Error(t("errors.noReceiver")); }
const r = await apiFetch("/api/message", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ to: receiverName, text: trimmed }),
});
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`message ${r.status}: ${text}`);
}
useAppStore.getState().addMessage({
id: crypto.randomUUID(),
direction: "outgoing",
peerName: receiverName,
text: trimmed,
sentAt: Math.floor(Date.now() / 1000),
});
}
@@ -0,0 +1,336 @@
import { useEffect, useState } from "react";
import { Group, Stack, Text } from "@mantine/core";
import { Copy, KeyRound, Plus } from "lucide-react";
import { apiJSON } from "../../net/api";
import { t } from "../../i18n";
import { formatRelative } from "../../utils/format";
import { Badge, Button, Callout, Panel, TextField } from "../../ui/primitives";
import { toast } from "../../ui/feedback";
type ShortcutTokenView = {
jti: string;
label: string;
scopes: string[];
created_at: number;
expires_at: number;
last_used_at: number | null;
revoked: boolean;
};
type IssueResp = {
token: string;
jti: string;
label: string;
scopes: string[];
expires_at: number;
};
const listTokens = () => apiJSON<{ tokens: ShortcutTokenView[] }>("/api/shortcut");
const issueToken = (label: string) =>
apiJSON<IssueResp>("/api/shortcut/issue", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ label }),
});
const revokeToken = (jti: string) =>
apiJSON<void>(`/api/shortcut/${encodeURIComponent(jti)}`, { method: "DELETE" });
// baseURL 是本服务器的对外地址,快捷指令请求剪贴板端点时用它拼 URL。桌面注入了
// loopback 代理地址,但快捷指令跑在 iPhone 上,应当用真正的对外 origin。
function baseURL(): string
{
return window.location.origin;
}
async function copyText(value: string): Promise<void>
{
try
{
await navigator.clipboard.writeText(value);
toast.ok(t("settings.shortcut.copied"));
}
catch
{
toast.error(t("settings.shortcut.copyFailed"));
}
}
// ShortcutTokens 管理 iOS 快捷指令的长效、仅剪贴板、可吊销令牌:列表 / 签发
// (仅展示一次)/ 吊销 + 安装指引。挂在设置页,需登录会话(用令牌本身不能管理令牌)。
export function ShortcutTokens()
{
const [ tokens, setTokens ] = useState<ShortcutTokenView[] | null>(null);
const [ loadError, setLoadError ] = useState(false);
const [ label, setLabel ] = useState("");
const [ issuing, setIssuing ] = useState(false);
const [ revoking, setRevoking ] = useState<Set<string>>(new Set());
const [ issued, setIssued ] = useState<IssueResp | null>(null);
const reload = async () =>
{
try
{
const r = await listTokens();
setTokens(r.tokens ?? []);
setLoadError(false);
}
catch
{
// 网络波动不该让整页报错;显示重试入口即可(容错要求)。
setLoadError(true);
}
};
useEffect(() =>
{
void reload();
}, []);
const handleIssue = async () =>
{
setIssuing(true);
try
{
const res = await issueToken(label.trim());
setIssued(res); // 内联展示一次性令牌 + 指引
setLabel("");
await reload();
}
catch (e)
{
const msg = e instanceof Error ? e.message : String(e);
if (msg.startsWith("503")) { toast.error(t("settings.shortcut.unavailable")); }
else if (msg.startsWith("409")) { toast.error(t("settings.shortcut.tooMany")); }
else { toast.error(t("settings.shortcut.issueError", { message: msg })); }
}
finally
{
setIssuing(false);
}
};
const handleRevoke = async (token: ShortcutTokenView) =>
{
if (!window.confirm(t("settings.shortcut.revokeConfirm"))) { return; }
setRevoking((prev) => new Set(prev).add(token.jti));
try
{
await revokeToken(token.jti);
toast.ok(t("settings.shortcut.revokedToast"));
await reload();
}
catch (e)
{
toast.error(t("settings.shortcut.issueError", {
message: e instanceof Error ? e.message : String(e),
}));
}
finally
{
setRevoking((prev) =>
{
const next = new Set(prev);
next.delete(token.jti);
return next;
});
}
};
return (
<Panel title={t("settings.shortcut.title")}>
<Stack gap="sm">
<Text size="sm" c="dimmed">{t("settings.shortcut.intro")}</Text>
<Group align="flex-end" gap="sm" wrap="nowrap">
<div style={{ flex: 1, minWidth: 0 }}>
<TextField
label={t("settings.shortcut.labelField")}
placeholder={t("settings.shortcut.labelPlaceholder")}
value={label}
onChange={(e) => setLabel(e.currentTarget.value)}
onKeyDown={(e) =>
{
if (e.key === "Enter" && !issuing)
{
e.preventDefault();
void handleIssue();
}
}}
/>
</div>
<Button
variant="primary"
leftIcon={<Plus size={14} />}
onClick={handleIssue}
loading={issuing}
>
{t("settings.shortcut.issue")}
</Button>
</Group>
{issued && (
<IssuedReveal
issued={issued}
base={baseURL()}
onDismiss={() => setIssued(null)}
/>
)}
{loadError && (
<Group gap="sm">
<Text size="sm" c="dimmed">{t("settings.shortcut.loadError")}</Text>
<Button size="sm" variant="ghost" onClick={() => void reload()}>
{t("settings.shortcut.retry")}
</Button>
</Group>
)}
{tokens && tokens.length === 0 && !loadError && (
<Text size="sm" c="dimmed">{t("settings.shortcut.empty")}</Text>
)}
{tokens && tokens.length > 0 && (
<Stack gap="xs">
{tokens.map((tok) => (
<TokenRow
key={tok.jti}
token={tok}
revoking={revoking.has(tok.jti)}
onRevoke={() => handleRevoke(tok)}
/>
))}
</Stack>
)}
</Stack>
</Panel>
);
}
function TokenRow(props: { token: ShortcutTokenView; revoking: boolean; onRevoke: () => void })
{
const { token, revoking, onRevoke } = props;
const expired = token.expires_at * 1000 < Date.now();
const lastUsed = token.last_used_at
? t("settings.shortcut.lastUsed", { time: formatRelative(token.last_used_at) })
: t("settings.shortcut.neverUsed");
return (
<Panel variant="sunken" padding="tight">
<Group justify="space-between" wrap="nowrap" align="center">
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
<Group gap={6} wrap="nowrap">
<Text fw={500} size="sm" truncate>{token.label}</Text>
{token.revoked && <Badge tone="neutral">{t("settings.shortcut.revoked")}</Badge>}
{!token.revoked && expired && (
<Badge tone="neutral">{t("settings.shortcut.expired")}</Badge>
)}
</Group>
<Text size="xs" c="dimmed" truncate>
{t("settings.shortcut.expiresAt", {
date: new Date(token.expires_at * 1000).toLocaleDateString(),
})}
{" · "}
{lastUsed}
</Text>
</Stack>
{!token.revoked && (
<Button
size="sm"
variant="ghost"
loading={revoking}
onClick={onRevoke}
style={{ color: "var(--state-error)" }}
>
{revoking ? t("settings.shortcut.revoking") : t("settings.shortcut.revoke")}
</Button>
)}
</Group>
</Panel>
);
}
// IssuedReveal 内联展示刚签发的令牌(仅此一次)+ 服务器地址 + 手动搭建指引。
// 沿用设置页一贯的「下沉面板 + Callout + Button」语汇,不引入弹窗。
function IssuedReveal(props: { issued: IssueResp; base: string; onDismiss: () => void })
{
const { issued, base, onDismiss } = props;
return (
<Panel variant="sunken" padding="tight">
<Stack gap="sm">
<Callout
tone="warn"
icon={<KeyRound size={16} />}
title={t("settings.shortcut.issuedTitle")}
>
{t("settings.shortcut.onceWarning")}
</Callout>
<Field label={t("settings.shortcut.tokenLabel")} value={issued.token} mono />
<Field label={t("settings.shortcut.baseLabel")} value={base} />
<Stack gap={6}>
<Text size="sm" fw={600}>{t("settings.shortcut.guideTitle")}</Text>
<Text size="sm" c="dimmed">{t("settings.shortcut.guideIntro")}</Text>
<Text size="sm" c="dimmed">{t("settings.shortcut.guideDeviceName")}</Text>
<Text size="sm" fw={500}>{t("settings.shortcut.guidePullTitle")}</Text>
<Stack gap={2}>
<Text size="sm">1. {t("settings.shortcut.guidePull1")}</Text>
<Text size="sm">2. {t("settings.shortcut.guidePull2")}</Text>
<Text size="sm">3. {t("settings.shortcut.guidePull3")}</Text>
</Stack>
<Text size="sm" fw={500}>{t("settings.shortcut.guidePushTitle")}</Text>
<Stack gap={2}>
<Text size="sm">1. {t("settings.shortcut.guidePush1")}</Text>
<Text size="sm">2. {t("settings.shortcut.guidePush2")}</Text>
<Text size="sm">3. {t("settings.shortcut.guidePush3")}</Text>
</Stack>
<Text size="xs" c="dimmed">{t("settings.shortcut.guideToleranceNote")}</Text>
</Stack>
<Group justify="flex-end">
<Button variant="ghost" onClick={onDismiss}>
{t("settings.shortcut.done")}
</Button>
</Group>
</Stack>
</Panel>
);
}
// Field 是一行「标签 + 单行只读值 + 复制按钮」,值用等宽字体、可断行。
function Field(props: { label: string; value: string; mono?: boolean })
{
const { label, value, mono } = props;
return (
<Stack gap={4}>
<Text size="xs" c="dimmed">{label}</Text>
<Group gap="xs" wrap="nowrap" align="flex-start">
<Text
size="sm"
style={{
flex: 1,
minWidth: 0,
wordBreak: "break-all",
fontFamily: mono ? "var(--font-mono)" : undefined,
}}
>
{value}
</Text>
<Button
size="sm"
variant="ghost"
leftIcon={<Copy size={14} />}
onClick={() => void copyText(value)}
>
{t("settings.shortcut.copy")}
</Button>
</Group>
</Stack>
);
}
@@ -0,0 +1,30 @@
.section
{
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.subhead
{
font-size: var(--fs-12);
font-weight: 600;
letter-spacing: var(--ls-label);
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: var(--space-2);
}
.list
{
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.empty
{
color: var(--text-muted);
font-size: var(--fs-12);
padding: var(--space-3) 0;
}
@@ -0,0 +1,58 @@
import { useEffect, useState } from "react";
import type { TransferRecord } from "../../store";
import { TransferRow } from "./TransferRow";
import { t } from "../../i18n";
import s from "./ActivityFeed.module.css";
export interface ActivityFeedProps
{
active: TransferRecord[];
history: TransferRecord[];
}
// 1Hz now tick:仅在有 active 时启动,避免空转。
function useNowTick(activeKey: string)
{
const [ now, setNow ] = useState(() => Date.now());
useEffect(() =>
{
if (!activeKey) { return; }
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, [ activeKey ]);
return now;
}
export function ActivityFeed(props: ActivityFeedProps)
{
const { active, history } = props;
const activeKey = active.map((t) => t.sessionId).sort().join("|");
const now = useNowTick(activeKey);
if (active.length === 0 && history.length === 0) { return null; }
return (
<section className={s.section}>
{active.length > 0 && (
<div>
<div className={s.subhead}>{t("home.transfer.active")}</div>
<div className={s.list}>
{active.map((tr) => (
<TransferRow key={tr.sessionId} record={tr} active now={now} />
))}
</div>
</div>
)}
{history.length > 0 && (
<div>
<div className={s.subhead}>{t("home.transfer.history")}</div>
<div className={s.list}>
{history.map((tr) => (
<TransferRow key={tr.sessionId} record={tr} active={false} now={now} />
))}
</div>
</div>
)}
</section>
);
}
@@ -0,0 +1,89 @@
.root
{
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.targetLine
{
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
font-size: var(--fs-12);
color: var(--text-muted);
line-height: var(--lh-ui);
}
.targetName { color: var(--accent); font-weight: 500; }
.targetMuted { color: var(--text-muted); }
.dropzone
{
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
min-height: 140px;
padding: var(--space-4);
background: var(--surface-sunken);
border: 1.5px dashed var(--divider);
border-radius: var(--radius-card);
color: var(--text-muted);
text-align: center;
font-size: var(--fs-13);
cursor: pointer;
transition:
background-color var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out);
}
.dropzone:hover
{
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.dropzoneActive
{
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.dropzoneHasFile
{
border-style: solid;
border-color: var(--divider);
background: var(--surface);
color: var(--text);
}
.fileLine
{
display: flex;
align-items: center;
gap: 8px;
word-break: break-all;
}
.fileMeta { color: var(--text-muted); font-size: var(--fs-12); }
.removeBtn
{
margin-top: 4px;
background: transparent;
border: 0;
color: var(--text-muted);
font: inherit;
font-size: var(--fs-11);
text-decoration: underline;
cursor: pointer;
}
.removeBtn:hover { color: var(--state-error); }
.hint { color: var(--text-muted); font-size: var(--fs-11); }
+232
View File
@@ -0,0 +1,232 @@
import { useRef, useState, type DragEvent } from "react";
import { FileText, MessageSquare, Send, Upload } from "lucide-react";
import { Button, Panel, Tabs, TextArea } from "../../ui/primitives";
import { toast } from "../../ui/feedback";
import { t } from "../../i18n";
import { formatBytes } from "../../utils/format";
import s from "./Composer.module.css";
export type ComposerMode = "file" | "message";
export interface ComposerProps
{
targetDevice: string | null;
selectedFile: File | null;
onFileChange: (f: File | null) => void;
onSendFile: () => Promise<void> | void;
onSendMessage: (text: string) => Promise<void> | void;
/** 默认 'file'。 */
initialMode?: ComposerMode;
}
export function Composer(props: ComposerProps)
{
const {
targetDevice, selectedFile,
onFileChange, onSendFile, onSendMessage,
initialMode = "file",
} = props;
const [ mode, setMode ] = useState<ComposerMode>(initialMode);
const [ msgText, setMsgText ] = useState("");
const fileInput = useRef<HTMLInputElement>(null);
// 目标缺失时用 muted 提示而非 error 红 —— 提示职责与发送按钮的 disabled
// 状态重合时不应双倍渲染告警。允许预输入文件/消息,等用户选完设备再点发送。
const targetLabel = targetDevice
? <> <span className={s.targetName}>{targetDevice}</span></>
: <span className={s.targetMuted}>{t("home.cta.selectDevice")}</span>;
const canSendFile = !!targetDevice && !!selectedFile;
const canSendMsg = !!targetDevice && msgText.trim().length > 0;
const handleSubmitFile = async () =>
{
if (!canSendFile) { return; }
await onSendFile();
};
const handleSubmitMsg = async () =>
{
if (!targetDevice) { toast.warn(t("home.cta.selectDevice")); return; }
if (!msgText.trim()) { return; }
try
{
await onSendMessage(msgText);
setMsgText("");
}
catch (e)
{
toast.error("消息发送失败", e instanceof Error ? e.message : String(e));
}
};
return (
<Panel
variant="primary"
padding="default"
title={t("home.tabs.file") + " · " + t("home.tabs.message")}
actions={
<Tabs<ComposerMode>
items={[
{ value: "file", label: <span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}><Upload size={13} />{t("home.tabs.file")}</span> },
{ value: "message", label: <span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}><MessageSquare size={13} />{t("home.tabs.message")}</span> },
]}
value={mode}
onChange={setMode}
aria-label="compose mode"
/>
}
>
<div className={s.root}>
{/* targetLabel 必须包在单个 span 里,防聚珍(Juzhenshim 拆段后
flex gap 在 CJK ↔ Latin 文本中间叠加 ——
同 Button.tsx 的 label span。 */}
<div className={s.targetLine}><span>{targetLabel}</span></div>
{mode === "file" ? (
<FilePane
selectedFile={selectedFile}
onFileChange={onFileChange}
fileInputRef={fileInput}
/>
) : (
<TextArea
placeholder={targetDevice
? t("home.message.placeholder", { name: targetDevice })
: t("home.message.placeholderEmpty")}
value={msgText}
onChange={(e) => setMsgText(e.currentTarget.value)}
rows={3}
hint={t("home.message.hint")}
onKeyDown={(e) =>
{
if (e.key === "Enter" && !e.shiftKey)
{
e.preventDefault();
void handleSubmitMsg();
}
}}
/>
)}
{mode === "file" ? (
<Button
variant="primary"
size="md"
block
leftIcon={<Send size={14} />}
disabled={!canSendFile}
onClick={handleSubmitFile}
>
{targetDevice
? t("home.cta.sendTo", { name: targetDevice })
: "发送"}
</Button>
) : (
<Button
variant="primary"
size="md"
block
leftIcon={<Send size={14} />}
disabled={!canSendMsg}
onClick={handleSubmitMsg}
>
{targetDevice
? t("home.cta.sendTo", { name: targetDevice })
: "发送"}
</Button>
)}
</div>
</Panel>
);
}
interface FilePaneProps
{
selectedFile: File | null;
onFileChange: (f: File | null) => void;
fileInputRef: React.RefObject<HTMLInputElement>;
}
function FilePane(props: FilePaneProps)
{
const { selectedFile, onFileChange, fileInputRef } = props;
const [ dragOver, setDragOver ] = useState(false);
const onDragOver = (e: DragEvent<HTMLDivElement>) =>
{
if (!Array.from(e.dataTransfer.types).includes("Files")) { return; }
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setDragOver(true);
};
const onDrop = (e: DragEvent<HTMLDivElement>) =>
{
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files[0];
if (f) { onFileChange(f); }
};
const cls = [
s.dropzone,
dragOver ? s.dropzoneActive : "",
selectedFile ? s.dropzoneHasFile : "",
].filter(Boolean).join(" ");
return (
<>
<div
className={cls}
onClick={() => fileInputRef.current?.click()}
onDragOver={onDragOver}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
role="button"
tabIndex={0}
onKeyDown={(e) =>
{
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
fileInputRef.current?.click();
}
}}
>
{selectedFile ? (
<>
<div className={s.fileLine}>
<FileText size={16} />
<span>{selectedFile.name}</span>
</div>
<div className={s.fileMeta}>{formatBytes(selectedFile.size)}</div>
<button
type="button"
className={s.removeBtn}
onClick={(e) => { e.stopPropagation(); onFileChange(null); }}
>
</button>
</>
) : (
<>
<Upload size={20} />
<span>{t("home.file.dropzone")}</span>
</>
)}
</div>
<input
ref={fileInputRef}
type="file"
style={{ display: "none" }}
onChange={(e) =>
{
const f = e.currentTarget.files?.[0];
if (f) { onFileChange(f); }
e.currentTarget.value = "";
}}
/>
</>
);
}
@@ -0,0 +1,133 @@
.root
{
background: var(--surface-sunken);
border-radius: var(--radius-control);
padding: var(--space-3);
font-family: var(--font-mono);
font-size: var(--fs-11);
color: var(--text-muted);
line-height: 1.5;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.headline
{
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: center;
}
.kvRow
{
display: grid;
grid-template-columns: 84px 1fr;
gap: 2px var(--space-3);
}
.k { color: var(--text-muted); }
.v { color: var(--text); font-weight: 500; word-break: break-word; }
.section
{
display: flex;
flex-direction: column;
gap: 4px;
}
.sectionTitle
{
font-family: var(--font-sans);
font-size: var(--fs-11);
letter-spacing: var(--ls-label);
text-transform: uppercase;
color: var(--text-muted);
}
.candTable
{
display: grid;
grid-template-columns: 64px repeat(5, 1fr);
gap: 2px 0;
background: var(--surface);
border-radius: var(--radius-control);
overflow: hidden;
}
.candCell
{
padding: 4px 8px;
text-align: right;
color: var(--text);
}
.candCell:first-child
{
text-align: left;
color: var(--text-muted);
}
.candHead
{
color: var(--text-muted);
background: transparent;
text-align: right;
font-size: 10px;
padding: 4px 8px;
border-bottom: 1px solid var(--divider);
}
.candHead:first-child { text-align: left; }
.candZero { color: var(--text-muted); opacity: 0.45; }
.pairRow
{
display: flex;
align-items: center;
gap: var(--space-2);
background: var(--surface);
border-radius: var(--radius-control);
padding: 6px 10px;
}
.pairHas { color: var(--accent); }
.pairNone { color: var(--text-muted); font-style: italic; }
.pairArrow { color: var(--text-muted); }
.pairLabel
{
font-family: var(--font-sans);
font-size: var(--fs-11);
letter-spacing: var(--ls-label);
text-transform: uppercase;
color: var(--text-muted);
margin-right: auto;
}
.modeBadge
{
display: inline-flex;
align-items: center;
gap: 4px;
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--ls-label);
text-transform: uppercase;
padding: 2px 7px;
border-radius: var(--radius-pill);
}
.modeP2P
{
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--accent);
}
.modeRelay
{
background: color-mix(in srgb, var(--state-warn) 16%, transparent);
color: var(--state-warn);
}
@@ -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" });
}
@@ -0,0 +1,186 @@
.row
{
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: var(--surface);
border-radius: var(--radius-card);
/* 描边:1px divider 全包,左侧 3px 状态色覆盖。 */
border: 1px solid var(--divider);
border-left: 3px solid var(--divider);
box-shadow: var(--shadow-card);
transition: border-color var(--dur-fast) var(--ease-out);
}
.rowSunken
{
background: var(--surface-sunken);
box-shadow: none;
}
/* 状态色编码:左色条按当前状态着色。
- rowActive 传输中 → info (cyan)
- rowStalled 卡住 → warn (yellow)
- rowFailed 失败 / 取消 → error (red)
- rowDone 完成 → success (green) */
.rowActive { border-left-color: var(--info); }
.rowStalled { border-left-color: var(--warn); }
.rowFailed { border-left-color: var(--error); }
.rowDone { border-left-color: var(--success); }
.head
{
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.arrow
{
color: var(--text-muted);
font-family: var(--font-mono);
flex-shrink: 0;
}
.fileName
{
font-size: var(--fs-13);
font-weight: 500;
color: var(--text);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.peer
{
font-size: var(--fs-12);
color: var(--text-muted);
flex-shrink: 0;
}
.barRow
{
display: flex;
align-items: center;
gap: var(--space-3);
}
.bar
{
flex: 1;
height: 3px;
background: var(--divider);
border-radius: 999px;
overflow: hidden;
position: relative;
}
.barFill
{
position: absolute;
inset: 0 auto 0 0;
background: var(--accent);
border-radius: 999px;
transition: width 220ms linear;
}
.barFillStalled { background: var(--state-warn); }
.barFillError { background: var(--state-error); }
.barFillDone { background: var(--state-online); }
.pct
{
font-family: var(--font-mono);
font-size: var(--fs-12);
color: var(--text-muted);
min-width: 40px;
text-align: right;
}
.meta
{
display: flex;
align-items: center;
gap: var(--space-2);
font-family: var(--font-mono);
font-size: var(--fs-12);
color: var(--text-muted);
flex-wrap: wrap;
}
.metaItem { white-space: nowrap; }
.dot { color: var(--divider); }
.footer
{
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.actions
{
display: flex;
align-items: center;
gap: 4px;
}
.actionBtn
{
background: transparent;
border: 0;
color: var(--text-muted);
font-size: var(--fs-11);
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius-control);
transition: color var(--dur-fast) var(--ease-out);
}
.actionBtn:hover { color: var(--accent); }
.actionDanger:hover { color: var(--state-error); }
.detailToggle
{
background: transparent;
border: 0;
color: var(--text-muted);
font-size: var(--fs-11);
cursor: pointer;
padding: 2px 4px;
border-radius: var(--radius-control);
display: inline-flex;
align-items: center;
gap: 4px;
transition: color var(--dur-fast) var(--ease-out);
}
.detailToggle:hover { color: var(--accent); }
.detail
{
background: var(--surface-sunken);
border-radius: var(--radius-control);
padding: var(--space-3);
font-family: var(--font-mono);
font-size: var(--fs-11);
color: var(--text-muted);
display: grid;
grid-template-columns: max-content 1fr;
gap: 4px var(--space-3);
line-height: var(--lh-ui);
}
.detail strong { color: var(--text); font-weight: 500; }
.errMsg
{
color: var(--state-error);
font-size: var(--fs-12);
}
.stalled .meta { color: var(--state-warn); }
+203
View File
@@ -0,0 +1,203 @@
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import type { TransferPhase, TransferRecord } from "../../store";
import { useAppStore } from "../../store";
import { t, type TranslationKey } from "../../i18n";
import { formatBytes } from "../../utils/format";
import { cancelTransfer, skipWaitRelay } from "./transfer";
import { TransferDetails } from "./TransferDetails";
import s from "./TransferRow.module.css";
// 8 秒内字节没增长且当前 phase 是字节流动阶段 → 视为卡住。
const FLOW_PHASES: ReadonlySet<TransferPhase> = new Set([
"transferring", "relay_uploading", "relay_downloading",
]);
const STALLED_THRESHOLD_MS = 8_000;
const STATE_KEYS: Record<string, TranslationKey> = {
PENDING: "transfer.state.PENDING",
ACCEPTED: "transfer.state.ACCEPTED",
P2P_ACTIVE: "transfer.state.P2P_ACTIVE",
RELAY_ACTIVE: "transfer.state.RELAY_ACTIVE",
DONE: "transfer.state.DONE",
FAILED: "transfer.state.FAILED",
CANCELLED: "transfer.state.CANCELLED",
};
export interface TransferRowProps
{
record: TransferRecord;
active: boolean;
now: number;
}
export function TransferRow(props: TransferRowProps)
{
const { record: tr, active, now } = props;
const [ detailOpen, setDetailOpen ] = useState(false);
const removeTransfer = useAppStore((st) => st.removeTransfer);
const arrow = tr.direction === "outgoing" ? "→" : "←";
const sinceMs = tr.lastProgressAt ? now - tr.lastProgressAt : 0;
const stalled = active
&& tr.state !== "DONE"
&& tr.phase !== undefined
&& FLOW_PHASES.has(tr.phase)
&& sinceMs >= STALLED_THRESHOLD_MS;
const pct = computeProgress(tr);
const failed = tr.state === "FAILED";
const done = tr.state === "DONE";
const barFillCls = [
s.barFill,
stalled ? s.barFillStalled : "",
failed ? s.barFillError : "",
done ? s.barFillDone : "",
].filter(Boolean).join(" ");
// 左色条状态编码:active / stalled / failed / done 各对应一个语义色。
// 优先级 failed > done > stalled > active,与 progress bar 颜色一致。
const cancelled = tr.state === "CANCELLED";
const rowStateCls
= failed || cancelled ? s.rowFailed
: done ? s.rowDone
: stalled ? s.rowStalled
: active ? s.rowActive
: "";
// 终态记录可删除(从列表移除);非终态记录可取消(驱动后端 /cancel)。
const terminal = done || failed || cancelled;
// 发送方仍在试 P2P(已接受 / P2P 中、尚未切中继)时,给一个「立即中继」入口,
// 跳过 30s ICE 等待——只对 outgoing 有意义(驱动后端 /fallback)。
const canRelayNow = tr.direction === "outgoing"
&& (tr.state === "ACCEPTED" || tr.state === "P2P_ACTIVE")
&& tr.phase !== "fallback_pending"
&& tr.phase !== "relay_uploading"
&& tr.phase !== "relay_downloading";
return (
<div className={[ s.row, done ? s.rowSunken : "", rowStateCls ].filter(Boolean).join(" ")}>
<div className={s.head}>
<span className={s.arrow}>{arrow}</span>
<span className={s.fileName}>{tr.fileName}</span>
<span className={s.peer}>{tr.peerName}</span>
</div>
<div className={s.barRow}>
<div className={s.bar}>
<div className={barFillCls} style={{ width: `${pct}%` }} />
</div>
<span className={s.pct}>{Math.round(pct)}%</span>
</div>
<div className={s.meta}>
<span className={s.metaItem}>{userFriendlyStatus(tr, stalled, sinceMs)}</span>
{bytesText(tr) && (
<>
<span className={s.dot}>·</span>
<span className={s.metaItem}>{bytesText(tr)}</span>
</>
)}
</div>
<div className={s.footer}>
<div className={s.actions}>
{canRelayNow && (
<button
type="button"
className={s.actionBtn}
onClick={() => void skipWaitRelay(tr.sessionId)}
>
{t("transfer.action.relayNow")}
</button>
)}
{terminal ? (
<button
type="button"
className={`${s.actionBtn} ${s.actionDanger}`}
onClick={() => removeTransfer(tr.sessionId)}
>
{t("transfer.action.delete")}
</button>
) : (
<button
type="button"
className={`${s.actionBtn} ${s.actionDanger}`}
onClick={() => void cancelTransfer(tr.sessionId)}
>
{t("transfer.action.cancel")}
</button>
)}
</div>
<button
type="button"
className={s.detailToggle}
onClick={() => setDetailOpen((v) => !v)}
aria-expanded={detailOpen}
>
<ChevronDown
size={11}
style={{
transform: detailOpen ? "rotate(0deg)" : "rotate(-90deg)",
transition: "transform 120ms",
}}
/>
{t("transfer.action.details")}
</button>
</div>
{detailOpen && <TransferDetails record={tr} />}
</div>
);
}
function userFriendlyStatus(tr: TransferRecord, stalled: boolean, sinceMs: number): string
{
if (tr.state === "DONE") { return t("transfer.state.DONE"); }
if (tr.state === "FAILED") { return "连接失败"; }
if (tr.state === "CANCELLED") { return t("transfer.state.CANCELLED"); }
if (stalled) { return t("transfer.stalled", { seconds: Math.floor(sinceMs / 1000) }); }
if (tr.phase)
{
const key = `transfer.phase.${tr.phase}` as TranslationKey;
return t(key);
}
const k = STATE_KEYS[tr.state];
return k ? t(k) : tr.state;
}
function bytesText(tr: TransferRecord): string
{
if (!FLOW_PHASES.has(tr.phase ?? "initializing")) { return ""; }
return t("transfer.bytesProgress", {
sent: formatBytes(tr.bytesTransferred ?? 0),
total: formatBytes(tr.fileSize),
});
}
function computeProgress(tr: TransferRecord): number
{
if (tr.state === "DONE") { return 100; }
if (tr.state === "FAILED" || tr.state === "CANCELLED")
{
// 失败/取消时若有进度则保留,无则给极小值占位
if (typeof tr.bytesTransferred === "number" && tr.fileSize > 0)
{
return Math.min(100, (tr.bytesTransferred / tr.fileSize) * 100);
}
return 0;
}
if (typeof tr.bytesTransferred === "number" && tr.fileSize > 0)
{
return Math.min(100, Math.max(2, (tr.bytesTransferred / tr.fileSize) * 100));
}
switch (tr.state)
{
case "PENDING": return 5;
case "ACCEPTED": return 15;
case "P2P_ACTIVE":
case "RELAY_ACTIVE": return 50;
default: return 0;
}
}
+328
View File
@@ -0,0 +1,328 @@
import { connectSSE, type SseEvent } from "../../net/sse";
import { useAppStore, type DeviceInfo, type TransferRecord } from "../../store";
import { apiFetch } from "../../net/api";
import { p2pHandleSignal, p2pCleanup } from "./p2p";
import { rememberIncoming, handleRelayReady, cleanupTransfer } from "./transfer";
import { applyRemoteClipboard } from "../clipboard/clipboard";
const HUB_URL = "/api/hub/events";
// startHub opens an SSE channel and dispatches frames into the Zustand store.
// Pass an AbortSignal to cancel cleanly (e.g. on logout or React unmount).
//
// Reconnect: the loop NEVER gives up on its own — a flaky or briefly-offline
// network must heal without user action (it also backs the desktop client, which
// must tolerate sleep/Wi-Fi drops without erroring out). Backoff grows 1s → 3s →
// 9s → 18s and caps at 30s, so we keep retrying forever at a sane cadence until
// the signal aborts (logout / unmount). After a few straight failures we set
// sseReconnecting=true so the UI can show a "reconnecting…" banner; it clears the
// moment the stream reopens.
const RECONNECT_DELAYS_MS = [1000, 3000, 9000];
const MAX_RECONNECT_DELAY_MS = 30000;
const RECONNECTING_AFTER_FAILURES = 3;
export async function startHub(signal: AbortSignal): Promise<void>
{
useAppStore.setState({ sseConnected: false, sseReconnecting: false });
let consecutiveFailures = 0;
while (!signal.aborted)
{
let cleanEnd = false;
try
{
await connectSSE(HUB_URL, dispatch, signal, onOpen);
// Stream ended cleanly (server closed it, or a proxy recycled it).
// Do NOT flip the indicator to "connecting" for the brief reconnect
// gap: on some platforms (notably Windows WebView2 behind the asset
// proxy) the stream gets recycled frequently, which would otherwise
// show "connecting" almost all the time even while events keep
// flowing. Keep sseConnected as-is and reconnect promptly.
consecutiveFailures = 0;
cleanEnd = true;
}
catch (e)
{
if (signal.aborted) { return; }
if ((e as { name?: string }).name === "AbortError") { return; }
consecutiveFailures++;
// A real failure: drop to "connecting" and back off. Once we've failed
// a few times in a row, surface the "reconnecting" banner — but keep
// looping; we never stop trying on our own.
const patch: Record<string, boolean> = { sseConnected: false };
if (consecutiveFailures >= RECONNECTING_AFTER_FAILURES) { patch.sseReconnecting = true; }
useAppStore.setState(patch);
// eslint-disable-next-line no-console
console.warn("hub: SSE attempt failed", consecutiveFailures, e);
}
const delay = cleanEnd ? RECONNECT_DELAYS_MS[0] : backoffDelay(consecutiveFailures);
try { await sleep(delay, signal); }
catch { return; }
}
}
// backoffDelay grows with the failure count and caps at MAX_RECONNECT_DELAY_MS,
// so the loop keeps retrying indefinitely at a bounded cadence (never gives up).
function backoffDelay(failures: number): number
{
if (failures <= RECONNECT_DELAYS_MS.length)
{
return RECONNECT_DELAYS_MS[failures - 1] ?? RECONNECT_DELAYS_MS[0];
}
const last = RECONNECT_DELAYS_MS[RECONNECT_DELAYS_MS.length - 1];
const grown = last * Math.pow(2, failures - RECONNECT_DELAYS_MS.length);
return Math.min(grown, MAX_RECONNECT_DELAY_MS);
}
function sleep(ms: number, signal: AbortSignal): Promise<void>
{
return new Promise((resolve, reject) =>
{
const t = setTimeout(resolve, ms);
const onAbort = () =>
{
clearTimeout(t);
reject(new Error("aborted"));
};
if (signal.aborted) { onAbort(); return; }
signal.addEventListener("abort", onAbort, { once: true });
});
}
// onOpen marks the channel connected the moment the stream opens (see
// connectSSE) — independent of whether any frame has arrived yet. Reopening also
// clears any lingering "reconnecting" banner.
function onOpen(): void
{
markConnected();
}
function markConnected(): void
{
const s = useAppStore.getState();
if (!s.sseConnected || s.sseReconnecting)
{
useAppStore.setState({ sseConnected: true, sseReconnecting: false });
}
}
function dispatch(ev: SseEvent): void
{
markConnected();
switch (ev.type)
{
case "presence":
handlePresence(ev.data);
return;
case "transfer:incoming":
void handleIncoming(ev.data);
return;
case "transfer:state":
handleState(ev.data);
return;
case "transfer:relay_ready":
{
const p = ev.data as { session_id?: string };
if (p.session_id) { void handleRelayReady(p.session_id); }
return;
}
case "signal":
handleSignal(ev.data);
return;
case "message":
handleIncomingMessage(ev.data);
return;
case "clipboard:update":
handleClipboardUpdate(ev.data);
return;
case "ping":
return;
default:
// eslint-disable-next-line no-console
console.warn("hub: unknown event", ev.type, ev.data);
}
}
function handleIncomingMessage(data: unknown): void
{
const p = data as { from?: string; text?: string; sent_at?: number };
if (!p.from || typeof p.text !== "string") { return; }
useAppStore.getState().addMessage({
id: crypto.randomUUID(),
direction: "incoming",
peerName: p.from,
text: p.text,
sentAt: typeof p.sent_at === "number" ? p.sent_at : Math.floor(Date.now() / 1000),
});
}
function handleClipboardUpdate(data: unknown): void
{
const p = data as {
content?: string;
content_type?: string;
source_device?: string;
updated_at?: number;
version?: number;
};
if (typeof p.content !== "string" || typeof p.updated_at !== "number") { return; }
applyRemoteClipboard({
content: p.content,
contentType: p.content_type ?? "text/plain",
sourceDevice: p.source_device ?? "",
updatedAt: p.updated_at,
version: typeof p.version === "number" ? p.version : 0,
});
}
function handlePresence(data: unknown): void
{
if (typeof data !== "object" || data === null) { return; }
const items = (data as { devices?: unknown }).devices;
if (!Array.isArray(items)) { return; }
const devices: DeviceInfo[] = [];
for (const raw of items)
{
if (typeof raw !== "object" || raw === null) { continue; }
const r = raw as Record<string, unknown>;
if (typeof r.name !== "string" || typeof r.type !== "string") { continue; }
devices.push({
name: r.name,
type: r.type,
online: Boolean(r.online),
lastSeen: typeof r.last_seen === "number" ? r.last_seen : 0,
});
}
const store = useAppStore.getState();
const prevOnline = new Map(store.devices.map((d) => [d.name, d.online] as const));
store.setDevices(devices);
// Detect peers that just transitioned to offline; if any active transfer
// is targeting them, ask the backend to fail it. completeTransfer (driven
// by transfer:state FAILED) will remove the entry, so we don't double-fire.
for (const d of devices)
{
if (!d.online && prevOnline.get(d.name))
{
for (const t of Object.values(store.activeTransfers))
{
if (t.peerName === d.name)
{
void apiFetch(`/api/transfer/${t.sessionId}/fail`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: "peer_offline" }),
});
}
}
}
}
}
interface IncomingPayload
{
session?: {
id?: string;
sender_name?: string;
file_name?: string;
file_size?: number;
};
auto_accept?: boolean;
}
async function handleIncoming(data: unknown): Promise<void>
{
const p = data as IncomingPayload;
const s = p.session;
if (!s?.id || !s.sender_name || !s.file_name || typeof s.file_size !== "number")
{
return;
}
const rec: TransferRecord = {
sessionId: s.id,
direction: "incoming",
fileName: s.file_name,
fileSize: s.file_size,
state: "PENDING",
peerName: s.sender_name,
startedAt: Date.now(),
phase: "waiting_accept",
bytesTransferred: 0,
lastProgressAt: Date.now(),
};
useAppStore.getState().upsertTransfer(rec);
// brief §2 写明 "> 100 MB 二次确认",但 MVP 没有该 UI,且 auto_accept=false
// 时跳过 rememberIncoming + /accept 会让 sender 等不到任何 ICE 候选 → 30s
// 后 fallback 到 relay → ring buffer 64 MiB 写满 → 整个传输死锁。所以这里
// 一律 pre-arm + accept;后端 auto_accept 字段保留给未来 UI 阻塞 prompt。
void p.auto_accept;
rememberIncoming(s.id, s.file_name, s.file_size, s.sender_name);
try
{
const r = await apiFetch(`/api/transfer/${s.id}/accept`, { method: "POST" });
if (!r.ok)
{
// eslint-disable-next-line no-console
console.warn("auto-accept HTTP", r.status, await r.text().catch(() => ""));
}
}
catch (e)
{
// eslint-disable-next-line no-console
console.warn("auto-accept failed", e);
}
}
function handleState(data: unknown): void
{
const p = data as { session_id?: string; state?: string; mode?: string };
if (!p.session_id || !p.state) { return; }
const store = useAppStore.getState();
const cur = store.activeTransfers[p.session_id];
const terminal = p.state === "DONE" || p.state === "FAILED" || p.state === "CANCELLED";
if (terminal)
{
if (cur) { p2pCleanup(cur.peerName); }
cleanupTransfer(p.session_id);
// Preserve mode so history can show "DONE via relay" etc.
if (cur && p.mode && cur.mode !== p.mode)
{
store.upsertTransfer({ ...cur, mode: p.mode });
}
store.completeTransfer(p.session_id, p.state);
return;
}
if (cur)
{
store.upsertTransfer({
...cur,
state: p.state,
mode: p.mode ?? cur.mode,
});
}
}
interface SignalEvent
{
from?: string;
payload?: { type: string; sdp?: RTCSessionDescriptionInit; candidate?: RTCIceCandidateInit };
}
function handleSignal(data: unknown): void
{
const ev = data as SignalEvent;
if (!ev.from || !ev.payload?.type)
{
return;
}
const t = ev.payload.type;
if (t !== "offer" && t !== "answer" && t !== "ice") { return; }
p2pHandleSignal(ev.from, ev.payload as { type: "offer" | "answer" | "ice" } & typeof ev.payload);
}
+75
View File
@@ -0,0 +1,75 @@
import { apiJSON } from "../../net/api";
// ICE servers used by every WebRTC RTCPeerConnection.
//
// Backend (/api/calls/credentials) returns either:
// - a Cloudflare Realtime TURN bundle (TLS-capable, has username/credential), or
// - a STUN-only fallback when CDROP_CF_TURN_* isn't configured.
//
// We fetch on login, cache the result, and refresh ahead of credential expiry.
interface ICEServer
{
urls: string | string[];
username?: string;
credential?: string;
}
interface ICEResp
{
iceServers: ICEServer[];
}
const FALLBACK: RTCIceServer[] = [
{ urls: "stun:stun.cloudflare.com:3478" },
];
let cached: RTCIceServer[] = FALLBACK;
let refreshTimer: number | null = null;
export function getICEServers(): RTCIceServer[]
{
return cached;
}
// refreshICEServers fetches a fresh bundle from the backend. Call on login;
// it self-schedules a refresh ahead of credential TTL so long-lived sessions
// keep working. Falls back silently to STUN if the call fails.
export async function refreshICEServers(): Promise<void>
{
try
{
const r = await apiJSON<ICEResp>("/api/calls/credentials");
if (r?.iceServers && r.iceServers.length > 0)
{
cached = r.iceServers.map((s) => ({
urls: s.urls,
username: s.username,
credential: s.credential,
}));
}
}
catch (e)
{
// eslint-disable-next-line no-console
console.warn("iceServers fetch failed; using fallback", e);
cached = FALLBACK;
}
if (refreshTimer !== null)
{
window.clearTimeout(refreshTimer);
}
// Refresh every ~22h to stay well clear of the 24h Cloudflare TTL.
refreshTimer = window.setTimeout(() => { void refreshICEServers(); }, 22 * 60 * 60 * 1000);
}
export function stopICEServerRefresh(): void
{
if (refreshTimer !== null)
{
window.clearTimeout(refreshTimer);
refreshTimer = null;
}
cached = FALLBACK;
}
+165
View File
@@ -0,0 +1,165 @@
// 接收端的字节落地槽,用来避免把整个文件累积到 JS 堆。
//
// 背景:iOS Safari 单 tab 内存预算紧;超过后系统会节流 / 暂停 JS 执行,
// 导致 SCTP 不再 ack → sender bufferedAmount 撑满 → 死锁(用户实测 24.4 MB
// 卡住的根因)。改成流式写入 Origin Private File SystemOPFS)后内存只占
// 一个 chunkiOS 16+ / Chrome / Firefox 都已支持 createWritable。
//
// 老浏览器 / OPFS 不可用时退回到老的内存累积方案;移动端可能仍然受影响,
// 但桌面端无碍。
import { t } from "../../i18n";
import { isDesktop, saveIncomingFileDesktop } from "../../net/desktop";
import { toast } from "../../ui/feedback";
export interface IncomingSink
{
/** 顺序写一个 chunk;返回的 Promise 只有完全落盘后才 resolve,自然 backpressure */
write(chunk: ArrayBuffer): Promise<void>;
/** 收到 done 帧后调用:finalize + 取出最终 Blob 给浏览器下载 */
close(): Promise<Blob>;
/** 失败 / 取消路径上调用,确保 OPFS 临时文件被删 */
cancel(): Promise<void>;
}
/**
* 优先 OPFS,失败回退内存。sessionId 用作 OPFS 临时文件名(确保两台设备
* 同时收不同会话不会撞文件)。
*/
export async function openIncomingSink(sessionId: string): Promise<IncomingSink>
{
// 注意:直接 navigator.storage?.getDirectory 在 TS 下永远 truthyoptional
// chaining 取的是函数引用,不会调用)。要真探测必须 try-catch 调用。
if (typeof navigator !== "undefined" && navigator.storage)
{
try { return await openOPFSSink(sessionId); }
catch (e)
{
// eslint-disable-next-line no-console
console.warn("OPFS unavailable, falling back to in-memory sink", e);
}
}
return openMemorySink();
}
async function openOPFSSink(sessionId: string): Promise<IncomingSink>
{
const dir = await navigator.storage.getDirectory();
const fileName = `cdrop-${sessionId}`;
const handle = await dir.getFileHandle(fileName, { create: true });
// FileSystemWritableFileStream:异步写、支持流式追加(每次 write 都是
// 顺序写到当前位置)。Safari 16+ / Chrome 102+ / Firefox 111+。
const writable = await handle.createWritable();
let closed = false;
const cleanup = async () =>
{
try { await dir.removeEntry(fileName); } catch { /* 已删 / 不存在 */ }
};
return {
async write(chunk)
{
if (closed) { return; }
await writable.write(chunk);
},
async close()
{
if (closed) { throw new Error("sink already closed"); }
closed = true;
await writable.close();
const file = await handle.getFile();
// 文件在 OPFS 里仍占空间;download 触发后再删(finalizeIncoming 里调)
return new Blob([file], { type: file.type || "application/octet-stream" });
},
async cancel()
{
if (closed) { await cleanup(); return; }
closed = true;
try { await writable.close(); } catch { /* writable 已无效 */ }
await cleanup();
},
};
}
function openMemorySink(): IncomingSink
{
const chunks: BlobPart[] = [];
let closed = false;
return {
async write(chunk)
{
if (closed) { return; }
// 复制一份保险:底层 ArrayBuffer 来自 dc.onmessage 的 event.data
// 同步处理时引用安全;异步链上保留独立副本避免被回收。
chunks.push(chunk.slice(0));
},
async close()
{
closed = true;
return new Blob(chunks);
},
async cancel()
{
closed = true;
chunks.length = 0;
},
};
}
/**
* 触发浏览器下载并把 OPFS 临时文件 + ObjectURL 都安排清理。
* 60s 给浏览器把 blob 落到用户磁盘的窗口,过短可能导致下载读到一半源被删。
*/
export function downloadBlob(blob: Blob, fileName: string, sessionId?: string): void
{
// 桌面端把字节交给 Go 写入用户配置的下载目录(路径可配置);浏览器端走原生下载。
if (isDesktop())
{
void saveIncomingFileDesktop(fileName, blob)
.then((savedPath) =>
{
toast.ok(t("transfer.savedTo", { path: savedPath }));
if (sessionId) { void cleanupOPFS(sessionId); }
})
.catch((e) =>
{
// 桌面 WebView 没有可用的浏览器下载回退(WKWebView 不处理 a[download]
// 的 blob),所以写盘失败必须显式报错,绝不能静默丢文件。Go 侧已在配置
// 目录不可写时自动改写系统下载目录,能走到这里说明连默认目录也失败了。
// eslint-disable-next-line no-console
console.error("desktop save failed", e);
toast.error(t("transfer.saveFailed", { error: e instanceof Error ? e.message : String(e) }));
if (sessionId) { void cleanupOPFS(sessionId); }
});
return;
}
browserDownload(blob, fileName, sessionId);
}
function browserDownload(blob: Blob, fileName: string, sessionId?: string): void
{
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() =>
{
URL.revokeObjectURL(url);
if (sessionId) { void cleanupOPFS(sessionId); }
}, 60_000);
}
async function cleanupOPFS(sessionId: string): Promise<void>
{
if (typeof navigator === "undefined" || !navigator.storage?.getDirectory) { return; }
try
{
const dir = await navigator.storage.getDirectory();
await dir.removeEntry(`cdrop-${sessionId}`);
}
catch { /* 已经清掉或没创建过 */ }
}
+813
View File
@@ -0,0 +1,813 @@
import { apiFetch } from "../../net/api";
import { getICEServers } from "./iceServers";
import { useAppStore, type CandidateBreakdown, type IceStats, type TransferPhase } from "../../store";
import { downloadBlob, openIncomingSink, type IncomingSink } from "./incomingSink";
// WebRTC client wired to the brief §2 invariants:
// - iceServers: pulled from lib/iceServers (Cloudflare Realtime TURN over
// TLS when CDROP_CF_TURN_* is configured; STUN-only fallback otherwise).
// Refreshed on login by RootLayout.
// - DataChannel "cdrop-file", ordered:true
// - chunk size 64 KiB, backpressure thresholds 16 MiB / 4 MiB
// - 接收端流式写 OPFSlib/incomingSink),避免 iOS Safari 内存压力
// - control frames as JSON strings (head meta + tail done), payload bytes binary
// - signaling via POST /api/hub/signal, peer routed by deviceName
//
// trickle ICE: each candidate ships in its own signal frame the moment the
// browser surfaces it.
//
// Same-LAN Restricted Cone NAT 排查依据:iceCandidatePoolSize=4 预热候选;
// 收集本/远端 host/mdns/srflx/prflx/relay 计数 + 选中候选对类型推到 store,
// 让 UI 暴露"实际通过哪条路径连上"host:host = 真直连,relay:* = 走了 TURN)。
const CHANNEL_NAME = "cdrop-file";
const CHUNK_SIZE = 64 * 1024;
const HIGH_WATERMARK = 16 * 1024 * 1024;
const LOW_WATERMARK = 4 * 1024 * 1024;
const ICE_CANDIDATE_POOL = 4;
export interface FileMeta
{
name: string;
size: number;
sha256?: string;
}
export type P2PState = "connecting" | "connected" | "completed" | "failed" | "closed";
export interface P2PProgressEvent
{
bytes: number;
total: number;
}
export interface P2PSession
{
sessionId: string;
peerName: string;
state: P2PState;
onState: (cb: (s: P2PState) => void) => () => void;
onProgress: (cb: (e: P2PProgressEvent) => void) => () => void;
cancel: () => void;
}
interface SignalPayload
{
type: "offer" | "answer" | "ice";
sdp?: RTCSessionDescriptionInit;
candidate?: RTCIceCandidateInit;
}
const EMPTY_BREAKDOWN: CandidateBreakdown =
{ host: 0, mdns: 0, srflx: 0, prflx: 0, relay: 0 };
class Session
{
public state: P2PState = "connecting";
public stateListeners = new Set<(s: P2PState) => void>();
public progressListeners = new Set<(p: P2PProgressEvent) => void>();
public pc: RTCPeerConnection;
public dc: RTCDataChannel | null = null;
public canceled = false;
private receivedMeta: FileMeta | null = null;
private receivedBytes = 0;
// 接收端字节槽:onmessage 是同步回调,但 sink.write 是 async;用一根
// promise 链保证 chunk 顺序写入,链尾的 then 处理 close。
private receiveQueue: Promise<IncomingSink | null> = Promise.resolve(null);
// Track total only for sender progress reporting; receiver learns it
// from the meta frame.
private outgoingTotal = 0;
private bytesSent = 0;
private phase: TransferPhase = "initializing";
private localCandidates: CandidateBreakdown = { ...EMPTY_BREAKDOWN };
private remoteCandidates: CandidateBreakdown = { ...EMPTY_BREAKDOWN };
private selectedPair: { local: string; remote: string } | undefined;
private firstByteSent = false;
private firstByteReceived = false;
// 节流:emitProgress 一秒可达数百次,每次 store.upsertTransfer 都触发
// 一次 React 重渲染(含 DebugPanel 的 IceStats)。在 384 次 / 24 MB 的尺度
// 上能把主线程拖到吃掉 dc.onmessage 的程度,使接收方 SCTP 缓冲填满 → rwnd=0
// → sender 的 bufferedAmount 永远卡在 HIGH。把 store push 限到 10 Hz。
private storePushScheduled: number | null = null;
private lastMessageAt = 0;
constructor(public sessionId: string, public peerName: string, public role: "sender" | "receiver")
{
this.pc = new RTCPeerConnection({
iceServers: getICEServers(),
iceCandidatePoolSize: ICE_CANDIDATE_POOL,
});
this.pc.onicecandidate = (ev) =>
{
if (!ev.candidate)
{
// gathering 完成;推一次包含最终计数的快照
this.flushPushUpdate();
return;
}
this.classifyAndCount(ev.candidate, this.localCandidates);
this.flushPushUpdate();
void this.sendSignal({ type: "ice", candidate: ev.candidate.toJSON() });
};
this.pc.onicegatheringstatechange = () =>
{
if (this.pc.iceGatheringState === "gathering" && this.phase === "initializing")
{
this.setPhase("ice_gathering");
}
else
{
this.flushPushUpdate();
}
};
this.pc.oniceconnectionstatechange = () =>
{
if (this.canceled) { return; }
const s = this.pc.iceConnectionState;
if (s === "checking")
{
this.setPhase("ice_checking");
}
else if (s === "connected" || s === "completed")
{
this.setState("connected");
if (this.phase === "ice_checking" || this.phase === "ice_gathering"
|| this.phase === "initializing")
{
this.setPhase("ice_connected");
}
void this.refreshSelectedPair();
// ICE 报 connected 时 nominated pair 经常还没决定,一次 getStats
// 拿不到就要轮询,否则 sender 调试面板永远显示"尚未选定"。
this.startSelectedPairPolling();
}
else if (s === "failed")
{
this.setState("failed");
void this.markServerFail("ice_failed");
}
this.flushPushUpdate();
};
if (role === "receiver")
{
this.pc.ondatachannel = (ev) =>
{
this.bindDataChannel(ev.channel);
this.startReceiverWatchdog();
};
}
}
setState(s: P2PState): void
{
if (this.state === s) { return; }
this.state = s;
for (const cb of this.stateListeners) { cb(s); }
}
private setPhase(p: TransferPhase): void
{
if (this.phase === p) { return; }
this.phase = p;
// phase 变化是稀疏事件(< 10 次 / 整个传输),同步 push 不会拥塞渲染
this.flushPushUpdate();
}
/** 立即 push(绕过节流),并取消任何 pending 节流 push */
private flushPushUpdate(): void
{
if (this.storePushScheduled !== null)
{
window.clearTimeout(this.storePushScheduled);
this.storePushScheduled = null;
}
this.pushUpdate();
}
/** 节流到 ≤10 Hz;高频 emitProgress 走这条 */
private throttledPushUpdate(): void
{
if (this.storePushScheduled !== null) { return; }
this.storePushScheduled = window.setTimeout(() =>
{
this.storePushScheduled = null;
this.pushUpdate();
}, 100);
}
private pushUpdate(): void
{
const store = useAppStore.getState();
const cur = store.activeTransfers[this.sessionId];
if (!cur) { return; }
const isReceiver = this.role === "receiver";
const bytes = isReceiver ? this.receivedBytes : this.bytesSent;
const lastProgressAt = bytes > (cur.bytesTransferred ?? 0)
? Date.now()
: cur.lastProgressAt;
const iceStats: IceStats = {
gathering: this.pc.iceGatheringState,
connection: this.pc.iceConnectionState,
candidates: {
local: { ...this.localCandidates },
remote: { ...this.remoteCandidates },
},
selectedPair: this.selectedPair,
dataChannel: this.dc
? { readyState: this.dc.readyState, bufferedAmount: this.dc.bufferedAmount }
: undefined,
};
store.upsertTransfer({
...cur,
phase: this.phase,
bytesTransferred: bytes,
lastProgressAt,
iceStats,
});
}
private emitProgress(): void
{
const total = this.role === "sender" ? this.outgoingTotal : (this.receivedMeta?.size ?? 0);
const bytes = this.role === "sender" ? this.bytesSent : this.receivedBytes;
for (const cb of this.progressListeners) { cb({ bytes, total }); }
this.throttledPushUpdate();
}
private classifyAndCount(c: RTCIceCandidate | RTCIceCandidateInit, into: CandidateBreakdown): void
{
const sdp = (c as RTCIceCandidate).candidate ?? (c as RTCIceCandidateInit).candidate ?? "";
if (!sdp) { return; }
const typeMatch = sdp.match(/typ (host|srflx|prflx|relay)/);
if (!typeMatch) { return; }
const t = typeMatch[1] as "host" | "srflx" | "prflx" | "relay";
if (t === "host")
{
// candidate:<foundation> <component> <protocol> <priority> <ip> <port> typ host …
const addr = sdp.match(/^candidate:\S+ \d+ \S+ \d+ (\S+) /);
if (addr && addr[1].endsWith(".local")) { into.mdns += 1; return; }
into.host += 1;
return;
}
into[t] += 1;
}
bindDataChannel(dc: RTCDataChannel): void
{
this.dc = dc;
dc.binaryType = "arraybuffer";
dc.bufferedAmountLowThreshold = LOW_WATERMARK;
dc.onopen = () =>
{
// 接收端的 dc.onopen 与发送端被分别绑定(发送端在 startOutgoing 里覆盖
// 这个 handler 来 streamFile)。这里覆盖掉的概率仅在 receiver。
if (this.role === "receiver" && this.phase !== "transferring")
{
this.setPhase("dc_open");
}
};
dc.onmessage = (ev) => { this.handleMessage(ev.data); };
dc.onerror = () => { this.setState("failed"); };
dc.onclose = () =>
{
if (this.state !== "completed" && this.state !== "failed")
{
this.setState("closed");
}
};
}
private handleMessage(data: string | ArrayBuffer | Blob): void
{
this.lastMessageAt = Date.now();
if (typeof data === "string")
{
try
{
const msg = JSON.parse(data);
if (msg.type === "meta")
{
this.receivedMeta = { name: msg.name, size: msg.size, sha256: msg.sha256 };
// 提前打开 sink;后续 chunk 入队等 sink 就绪
this.receiveQueue = openIncomingSink(this.sessionId);
this.emitProgress();
}
else if (msg.type === "done")
{
this.setPhase("completing");
void this.finalizeIncoming();
}
}
catch
{
// ignore malformed control frame
}
return;
}
if (data instanceof ArrayBuffer)
{
if (!this.firstByteReceived) { this.firstByteReceived = true; this.setPhase("transferring"); }
this.receivedBytes += data.byteLength;
// 串到 promise 链尾;sink.write 完成才会处理下一个 chunk,与 SCTP
// rwnd 共同提供 backpressure。链上失败会沿着 finalize 抛出来。
this.receiveQueue = this.receiveQueue.then(async (sink) =>
{
if (sink) { await sink.write(data); }
return sink;
});
this.emitProgress();
}
}
/** 收方看门狗:超过 5s 没收到任何消息(onmessage 没 fire)即 console.warn
* 方便诊断主线程被微任务堵住的情形。仅 receiver 角色起作用。 */
private startReceiverWatchdog(): void
{
if (this.role !== "receiver") { return; }
this.lastMessageAt = Date.now();
const id = window.setInterval(() =>
{
if (this.canceled || this.state === "completed" || this.state === "failed"
|| this.state === "closed")
{
window.clearInterval(id);
return;
}
if (!this.firstByteReceived) { return; }
const since = Date.now() - this.lastMessageAt;
if (since > 5_000)
{
// eslint-disable-next-line no-console
console.warn("p2p receiver: no onmessage for >5s", {
sinceMs: since,
bytesReceived: this.receivedBytes,
bufferedAmount: this.dc?.bufferedAmount,
readyState: this.dc?.readyState,
});
}
}, 5_000);
}
private async finalizeIncoming(): Promise<void>
{
if (!this.receivedMeta) { return; }
try
{
const sink = await this.receiveQueue;
if (!sink) { throw new Error("receive sink missing"); }
const blob = await sink.close();
downloadBlob(blob, this.receivedMeta.name, this.sessionId);
this.setState("completed");
void this.markServerDone(this.receivedBytes);
}
catch (e)
{
// eslint-disable-next-line no-console
console.error("finalize incoming failed", e);
this.setState("failed");
void this.markServerFail((e as Error).message ?? "finalize_failed");
}
}
async sendSignal(payload: SignalPayload): Promise<void>
{
const r = await apiFetch("/api/hub/signal", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ to: this.peerName, payload }),
});
if (!r.ok && r.status !== 410)
{
// 410 means peer offline; signaling fails silently (caller will time out)
// eslint-disable-next-line no-console
console.warn("p2p signal HTTP", r.status, await r.text().catch(() => ""));
}
}
async handleSignal(payload: SignalPayload): Promise<void>
{
if (payload.type === "offer" && payload.sdp)
{
await this.pc.setRemoteDescription(payload.sdp);
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
await this.sendSignal({ type: "answer", sdp: answer });
}
else if (payload.type === "answer" && payload.sdp)
{
await this.pc.setRemoteDescription(payload.sdp);
}
else if (payload.type === "ice" && payload.candidate)
{
this.classifyAndCount(payload.candidate, this.remoteCandidates);
this.pushUpdate();
try { await this.pc.addIceCandidate(payload.candidate); }
catch (e)
{
// eslint-disable-next-line no-console
console.warn("p2p addIceCandidate", e);
}
}
}
async startOutgoing(file: File): Promise<void>
{
this.outgoingTotal = file.size;
const dc = this.pc.createDataChannel(CHANNEL_NAME, { ordered: true });
this.bindDataChannel(dc);
// bindDataChannel 给 receiver 绑了 onopensender 这里覆盖一下,等
// ICE 通道一就绪立刻开始 streamFile(receiver 不会走这条分支)。
dc.onopen = () =>
{
this.setPhase("dc_open");
void this.streamFile(file).catch((e) =>
{
// eslint-disable-next-line no-console
console.error("p2p send failed", e);
this.setState("failed");
});
};
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
await this.sendSignal({ type: "offer", sdp: offer });
}
private async streamFile(file: File): Promise<void>
{
if (!this.dc) { throw new Error("no data channel"); }
const dc = this.dc;
// Tell the backend that the channel is live so /done is later legal:
// the state machine demands ACCEPTED → P2P_ACTIVE → DONE.
await this.markP2PActive();
// Header.
const meta: FileMeta = { name: file.name, size: file.size };
dc.send(JSON.stringify({ type: "meta", ...meta }));
let offset = 0;
while (offset < file.size)
{
// 预测式检查:在 send 之前看"加了这一片之后会不会撞上 HIGH"。
// Chrome WebRTC 的 RTCDataChannel.send 在 bufferedAmount + size 超过
// kMaxQueuedSendDataBytes16 MiB)时直接抛 OperationError;我们 HIGH
// 也设 16 MiB,原本的 `bufferedAmount > HIGH` 检查在 buffer = HIGH - 1
// 时不触发,下一片就会跨过硬上限挂掉。
if (dc.bufferedAmount + CHUNK_SIZE > HIGH_WATERMARK)
{
await waitForBufferLow(dc, LOW_WATERMARK);
}
const slice = file.slice(offset, Math.min(offset + CHUNK_SIZE, file.size));
const buf = await slice.arrayBuffer();
try
{
dc.send(buf);
}
catch (e)
{
// 兜底:极端情况下 bufferedAmount 在 await 与 send 之间漂移,仍可能
// 撞到 OperationError。退一步等 buffer 抽干到 LOW 再重试一次;仍失
// 败说明通道本身有问题,让外层 .catch 走 markServerFail。
if (e instanceof DOMException && e.name === "OperationError")
{
// eslint-disable-next-line no-console
console.warn("p2p send queue full, backing off then retrying", {
bufferedAmount: dc.bufferedAmount,
readyState: dc.readyState,
bytesSent: this.bytesSent,
});
await waitForBufferLow(dc, LOW_WATERMARK);
dc.send(buf);
}
else
{
throw e;
}
}
offset += buf.byteLength;
this.bytesSent = offset;
if (!this.firstByteSent) { this.firstByteSent = true; this.setPhase("transferring"); }
this.emitProgress();
}
dc.send(JSON.stringify({ type: "done" }));
this.setState("completed");
this.setPhase("completing");
// /done is intentionally posted by the *receiver* only — they are the
// authoritative "got it" signal. Doubling it up here just produces
// 409 console noise once the receiver's call lands first.
}
private selectedPairPollId: number | null = null;
private async refreshSelectedPair(): Promise<boolean>
{
try
{
const stats = await this.pc.getStats();
const cands = new Map<string, RTCStats & { candidateType?: string; protocol?: string }>();
let pairLocalId: string | undefined;
let pairRemoteId: string | undefined;
stats.forEach((report) =>
{
const r = report as RTCStats & {
candidateType?: string; protocol?: string;
state?: string; nominated?: boolean;
localCandidateId?: string; remoteCandidateId?: string;
};
if (r.type === "local-candidate" || r.type === "remote-candidate")
{
cands.set(r.id, r);
}
if (r.type === "candidate-pair" && r.state === "succeeded" && r.nominated)
{
pairLocalId = r.localCandidateId;
pairRemoteId = r.remoteCandidateId;
}
});
if (pairLocalId && pairRemoteId)
{
this.selectedPair = {
local: formatCandidateLabel(cands.get(pairLocalId)),
remote: formatCandidateLabel(cands.get(pairRemoteId)),
};
this.flushPushUpdate();
return true;
}
return false;
}
catch
{
// 老浏览器或权限问题,吞掉,调试面板就显示不出 selectedPair
return true; // 不再重试
}
}
/** 一次 refreshSelectedPair 经常拿不到(连接刚 connectednominated pair
* 还没确定)。每 2s 轮询一次直到拿到,或 30s 超时,或会话进入终态。 */
private startSelectedPairPolling(): void
{
if (this.selectedPairPollId !== null) { return; }
const start = Date.now();
this.selectedPairPollId = window.setInterval(async () =>
{
if (this.canceled
|| this.selectedPair
|| this.state === "completed"
|| this.state === "failed"
|| this.state === "closed"
|| Date.now() - start > 30_000)
{
if (this.selectedPairPollId !== null)
{
window.clearInterval(this.selectedPairPollId);
this.selectedPairPollId = null;
}
return;
}
await this.refreshSelectedPair();
}, 2_000);
}
private async markP2PActive(): Promise<void>
{
try
{
// 409 is fine — receiver may have already moved the state, or the
// session might be in a state ineligible for P2P_ACTIVE.
await apiFetch(`/api/transfer/${this.sessionId}/p2p`, { method: "POST" });
}
catch (e)
{
// eslint-disable-next-line no-console
console.warn("p2p mark p2p_active", e);
}
}
private async markServerDone(bytes: number): Promise<void>
{
// 重试到服务端真的接到为止:发完文件之后这条 POST 不到,整个状态机
// 会停在 P2P_ACTIVEUI 永远不进入 history。但 409 表示状态已经不允许
// 转 DONE(多半是已 DONE / FAILED / CANCELLED 在终态),直接放弃。
for (let attempt = 0; attempt < 3; attempt += 1)
{
try
{
const r = await apiFetch(`/api/transfer/${this.sessionId}/done`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bytes_transferred: bytes }),
});
if (r.ok) { return; }
if (r.status === 409) { return; }
const text = await r.text().catch(() => r.statusText);
// eslint-disable-next-line no-console
console.warn(`p2p mark done attempt ${attempt + 1}${r.status}: ${text}`);
}
catch (e)
{
// eslint-disable-next-line no-console
console.warn(`p2p mark done attempt ${attempt + 1} error`, e);
}
if (attempt < 2)
{
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
}
}
}
async markServerFail(reason: string): Promise<void>
{
try
{
await apiFetch(`/api/transfer/${this.sessionId}/fail`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
});
}
catch (e)
{
// eslint-disable-next-line no-console
console.warn("p2p mark fail", e);
}
}
cancel(): void
{
this.canceled = true;
if (this.storePushScheduled !== null)
{
window.clearTimeout(this.storePushScheduled);
this.storePushScheduled = null;
}
if (this.selectedPairPollId !== null)
{
window.clearInterval(this.selectedPairPollId);
this.selectedPairPollId = null;
}
this.dc?.close();
this.pc.close();
this.setState("closed");
// 把可能开着的 OPFS 临时文件删干净
this.receiveQueue = this.receiveQueue.then(async (sink) =>
{
try { await sink?.cancel(); } catch { /* swallow */ }
return null;
});
}
}
function formatCandidateLabel(c: undefined | (RTCStats & { candidateType?: string; protocol?: string })): string
{
if (!c) { return "?"; }
return `${c.candidateType ?? "?"}/${c.protocol ?? "?"}`;
}
// waitForBufferLow 三重保险等 dc.bufferedAmount 落到 threshold 以下:
// 1. bufferedamountlow 事件(标准路径)
// 2. addEventListener 之后再 re-check(防 attach-before-fire 竞态)
// 3. 100ms 轮询(兜底某些 Chromium 版本事件不触发的历史 bug)
// 30s 没下来则 console.warn 但不中断(同时由 polling 接力,最终一定会
// 等到);这条日志能定位"sender 静默卡住"是不是这条 await。
function waitForBufferLow(dc: RTCDataChannel, threshold: number): Promise<void>
{
if (dc.bufferedAmount <= threshold) { return Promise.resolve(); }
return new Promise<void>((resolve) =>
{
let resolved = false;
const start = Date.now();
const cleanup = () =>
{
window.clearInterval(pollId);
window.clearTimeout(watchdog);
dc.removeEventListener("bufferedamountlow", onLow);
};
const finish = () =>
{
if (resolved) { return; }
resolved = true;
cleanup();
resolve();
};
const onLow = () => { finish(); };
const pollId = window.setInterval(() =>
{
if (dc.bufferedAmount <= threshold) { finish(); }
}, 100);
const watchdog = window.setTimeout(() =>
{
// eslint-disable-next-line no-console
console.warn("p2p waitForBufferLow stuck", {
bufferedAmount: dc.bufferedAmount,
threshold,
readyState: dc.readyState,
elapsedMs: Date.now() - start,
});
}, 30_000);
dc.addEventListener("bufferedamountlow", onLow);
// re-check: addEventListener 之后 bufferedAmount 可能已经在 attach
// 前的最近 task 里跨过 threshold(事件已 fire 但我们没有监听),
// 此时 polling 会兜底但有 100ms 延迟;这里同步再看一眼短路掉。
if (dc.bufferedAmount <= threshold) { finish(); }
});
}
const p2pSessions = new Map<string, Session>();
export function p2pHandleSignal(from: string, payload: SignalPayload): void
{
const sess = p2pSessions.get(from);
if (!sess)
{
// eslint-disable-next-line no-console
console.warn("p2p: signal from unknown peer", from, payload?.type);
return;
}
void sess.handleSignal(payload);
}
export function p2pCleanup(peerName: string): void
{
const sess = p2pSessions.get(peerName);
if (sess) { sess.cancel(); }
p2pSessions.delete(peerName);
}
function buildPublicSession(sess: Session): P2PSession
{
return {
sessionId: sess.sessionId,
peerName: sess.peerName,
get state() { return sess.state; },
onState: (cb) =>
{
sess.stateListeners.add(cb);
return () => sess.stateListeners.delete(cb);
},
onProgress: (cb) =>
{
sess.progressListeners.add(cb);
return () => sess.progressListeners.delete(cb);
},
cancel: () => { p2pCleanup(sess.peerName); },
};
}
// p2pStartOutgoing returns *synchronously* — startOutgoing's createOffer /
// setLocalDescription / signal POST are all kicked off as a background promise.
// This is critical for the 30s ICE-fallback watchdog (transfer.ts) to actually
// arm: previously an `await p2pStartOutgoing` could hang forever if e.g.
// createOffer was monkey-patched to never resolve (M11 fallback test scenario).
export function p2pStartOutgoing(
sessionId: string,
receiverName: string,
file: File,
): P2PSession
{
const sess = new Session(sessionId, receiverName, "sender");
p2pSessions.set(receiverName, sess);
sess.stateListeners.add((s) => updateStoreState(sessionId, s));
void sess.startOutgoing(file).catch((e) =>
{
// Cancellation (fallback path closes pc → pending createOffer rejects)
// is expected, not a transport failure — let the relay flow drive state.
if (sess.canceled) { return; }
// eslint-disable-next-line no-console
console.error("p2p start outgoing failed", e);
sess.setState("failed");
void sess.markServerFail((e as Error).message ?? "p2p_start_failed");
});
return buildPublicSession(sess);
}
export function p2pStartIncoming(sessionId: string, senderName: string): P2PSession
{
const sess = new Session(sessionId, senderName, "receiver");
p2pSessions.set(senderName, sess);
sess.stateListeners.add((s) => updateStoreState(sessionId, s));
return buildPublicSession(sess);
}
function updateStoreState(sessionId: string, p2pState: P2PState): void
{
const store = useAppStore.getState();
const cur = store.activeTransfers[sessionId];
if (!cur) { return; }
if (p2pState === "connected") { store.upsertTransfer({ ...cur, state: "P2P_ACTIVE" }); }
else if (p2pState === "completed") { store.completeTransfer(sessionId, "DONE"); }
else if (p2pState === "failed") { store.completeTransfer(sessionId, "FAILED"); }
// closed 不主动移到 history:可能是 fallback 路径主动 cancel,让上层
// /fallback / /fail 流程决定终态。
}
+213
View File
@@ -0,0 +1,213 @@
import { apiFetch } from "../../net/api";
import { downloadBlob, openIncomingSink } from "./incomingSink";
// Relay path (PROJECT_BRIEF.md §M5/M9). Sender uploads sequential 1 MiB chunks
// via POST and receiver streams the response via GET. Streaming POST is NOT
// used because Safari/Firefox don't support ReadableStream upload.
const CHUNK = 1024 * 1024;
export interface RelayProgress
{
bytes: number;
total: number;
}
export type RelayHandle = {
cancel: () => void;
};
const MAX_CHUNK_RETRIES = 3;
const RETRY_BACKOFF_MS = [500, 1500, 4500];
// 单 chunk 最长允许多久。1 MiB chunk 在 relay 通畅时几百 ms;这里给够 60s
// 余量并兜底"receiver 没在读 → ring buffer 写满 → POST 永远不返回"的死锁。
const PER_CHUNK_TIMEOUT_MS = 60_000;
// relaySendFile streams a file as 1 MiB POSTs. Each chunk is retried up to
// MAX_CHUNK_RETRIES times on transient errors. A 409 response means the
// server already has bytes past our offset — typically we already sent
// successfully but the response was lost; resume from server's
// X-Bytes-Received and don't re-send the duplicate.
export async function relaySendFile(
sessionId: string,
file: File,
onProgress?: (p: RelayProgress) => void,
abortSignal?: AbortSignal,
): Promise<void>
{
const url = `/api/relay/${encodeURIComponent(sessionId)}/chunk`;
let offset = 0;
while (offset < file.size)
{
if (abortSignal?.aborted) { throw new Error("relay send aborted"); }
const end = Math.min(offset + CHUNK, file.size);
const slice = file.slice(offset, end);
const isLast = end === file.size;
const result = await postChunkWithRetry(url, slice, offset, isLast, abortSignal);
if (result.skip)
{
// Server says it already has at least up to `serverOffset` bytes.
offset = Math.max(end, result.serverOffset);
}
else
{
offset = end;
}
onProgress?.({ bytes: offset, total: file.size });
}
}
interface ChunkResult
{
skip: boolean;
serverOffset: number;
}
interface ScopedSignal
{
signal: AbortSignal;
cancel: () => void;
}
// withDeadline 把外层 abortcleanup / 用户取消)与 N ms 超时合成一个新的
// AbortSignal。AbortSignal.any() 在所有目标浏览器(Chrome 116 / Firefox 124 /
// Safari 17.4)已可用,但仍写一个手动 fallback 防 polyfill 缺失。
function withDeadline(outer: AbortSignal | undefined, ms: number): ScopedSignal
{
const ctrl = new AbortController();
const timeoutId = window.setTimeout(
() => { ctrl.abort(new DOMException("relay chunk timeout", "TimeoutError")); },
ms,
);
let off = () => {};
if (outer)
{
if (outer.aborted) { ctrl.abort(outer.reason); }
else
{
const onAbort = () => { ctrl.abort(outer.reason); };
outer.addEventListener("abort", onAbort, { once: true });
off = () => { outer.removeEventListener("abort", onAbort); };
}
}
return {
signal: ctrl.signal,
cancel: () =>
{
window.clearTimeout(timeoutId);
off();
},
};
}
async function postChunkWithRetry(
url: string,
slice: Blob,
offset: number,
isLast: boolean,
abortSignal?: AbortSignal,
): Promise<ChunkResult>
{
let lastErr: unknown = null;
for (let attempt = 0; attempt < MAX_CHUNK_RETRIES; attempt += 1)
{
if (abortSignal?.aborted) { throw new Error("relay send aborted"); }
const headers: Record<string, string> = {
"Content-Type": "application/octet-stream",
"X-Resume-Offset": String(offset),
};
if (isLast) { headers["X-End"] = "true"; }
const scoped = withDeadline(abortSignal, PER_CHUNK_TIMEOUT_MS);
try
{
const r = await apiFetch(url, {
method: "POST",
headers,
body: slice,
signal: scoped.signal,
});
if (r.ok)
{
const recv = Number(r.headers.get("X-Bytes-Received") ?? "0");
return { skip: false, serverOffset: recv };
}
if (r.status === 409)
{
// Server's expected offset > ours: previous chunk landed but
// the response was lost. Skip this duplicate.
const recv = Number(r.headers.get("X-Bytes-Received") ?? "0");
if (recv > offset) { return { skip: true, serverOffset: recv }; }
throw new Error(`relay 409 with recv=${recv} <= offset=${offset}`);
}
const text = await r.text().catch(() => r.statusText);
throw new Error(`relay chunk ${r.status}: ${text}`);
}
catch (e)
{
lastErr = e;
if (attempt < MAX_CHUNK_RETRIES - 1)
{
await new Promise((res) => setTimeout(res, RETRY_BACKOFF_MS[attempt] ?? 5000));
}
}
finally
{
scoped.cancel();
}
}
throw lastErr instanceof Error ? lastErr : new Error("relay send retries exhausted");
}
export async function relayReceiveFile(
sessionId: string,
fileName: string,
expectedSize: number,
onProgress?: (p: RelayProgress) => void,
abortSignal?: AbortSignal,
): Promise<Blob>
{
const url = `/api/relay/${encodeURIComponent(sessionId)}/stream`;
const r = await apiFetch(url, { method: "GET", signal: abortSignal });
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`relay stream ${r.status}: ${text}`);
}
if (!r.body) { throw new Error("relay: no response body"); }
const reader = r.body.getReader();
const sink = await openIncomingSink(sessionId);
let received = 0;
try
{
while (true)
{
if (abortSignal?.aborted) { throw new Error("relay receive aborted"); }
const { value, done } = await reader.read();
if (done) { break; }
if (!value) { continue; }
// 与 P2P 路径一致流式落 OPFS / 内存:避免大文件在 iOS 撑爆 JS 堆
await sink.write(value.slice().buffer);
received += value.byteLength;
onProgress?.({ bytes: received, total: expectedSize });
}
}
catch (e)
{
await sink.cancel();
throw e;
}
finally
{
try { reader.cancel(); } catch { /* noop */ }
}
const blob = await sink.close();
downloadBlob(blob, fileName, sessionId);
return blob;
}
+292
View File
@@ -0,0 +1,292 @@
import { apiFetch } from "../../net/api";
import { p2pStartIncoming, p2pStartOutgoing, p2pCleanup, type P2PSession } from "./p2p";
import { relayReceiveFile, relaySendFile } from "./relay";
import { useAppStore, type TransferRecord } from "../../store";
// Orchestrator that picks P2P first, falls back to Relay on ICE timeout
// (plan §M9: 30s after start, if iceConnectionState != connected → /fallback).
const ICE_TIMEOUT_MS = 30_000;
interface OutgoingState
{
sessionId: string;
peerName: string;
file: File;
p2p: P2PSession | null;
fallbackTimer: number | null;
abort: AbortController;
}
const outgoing = new Map<string, OutgoingState>();
const incomingFiles = new Map<string, { name: string; size: number; sender: string }>();
// Sender entry point. Initiates the backend transfer row, kicks off P2P, and
// arms a 30s watchdog that calls /fallback if ICE hasn't reached `connected`.
export async function startOutgoingTransfer(
receiverName: string,
file: File,
): Promise<string>
{
const initR = await apiFetch("/api/transfer/initiate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
receiver_name: receiverName,
file_name: file.name,
file_size: file.size,
}),
});
if (!initR.ok)
{
const text = await initR.text().catch(() => initR.statusText);
throw new Error(`initiate ${initR.status}: ${text}`);
}
const { session_id: sessionId } = (await initR.json()) as { session_id: string };
useAppStore.getState().upsertTransfer({
sessionId,
direction: "outgoing",
fileName: file.name,
fileSize: file.size,
state: "PENDING",
peerName: receiverName,
startedAt: Date.now(),
phase: "waiting_accept",
bytesTransferred: 0,
lastProgressAt: Date.now(),
});
const abort = new AbortController();
const state: OutgoingState = {
sessionId,
peerName: receiverName,
file,
p2p: null,
fallbackTimer: null,
abort,
};
outgoing.set(sessionId, state);
// Arm the fallback watchdog *before* any P2P work. p2pStartOutgoing is
// intentionally synchronous: if createOffer / setLocalDescription hang
// (e.g. injected stub during fallback testing) the timer must still fire.
state.fallbackTimer = window.setTimeout(() =>
{
const cur = outgoing.get(sessionId);
if (!cur) { return; }
const p2pState = cur.p2p?.state;
if (p2pState !== "connected" && p2pState !== "completed")
{
// 把"已经放弃 P2P,等服务端 RELAY_ACTIVE"这个状态显式落到 store
// UI 据此告诉用户「还在通,正在切到中继」而不是干等。
const store = useAppStore.getState();
const r = store.activeTransfers[sessionId];
if (r) { store.upsertTransfer({ ...r, phase: "fallback_pending" }); }
void triggerFallback(sessionId);
}
}, ICE_TIMEOUT_MS);
const p2p = p2pStartOutgoing(sessionId, receiverName, file);
state.p2p = p2p;
p2p.onState((s) =>
{
if (s === "completed" || s === "connected")
{
if (state.fallbackTimer) { clearTimeout(state.fallbackTimer); state.fallbackTimer = null; }
}
});
return sessionId;
}
async function triggerFallback(sessionId: string): Promise<void>
{
const r = await apiFetch(`/api/transfer/${sessionId}/fallback`, { method: "POST" });
if (!r.ok && r.status !== 409)
{
// 409 = transition not allowed (e.g. already DONE). Anything else is unexpected.
// eslint-disable-next-line no-console
console.warn("fallback failed", r.status, await r.text().catch(() => ""));
}
}
// Called from hub.ts when transfer:incoming fires. Records expected file
// metadata so a Relay fallback can name the downloaded file correctly.
export function rememberIncoming(sessionId: string, fileName: string, fileSize: number, sender: string): void
{
incomingFiles.set(sessionId, { name: fileName, size: fileSize, sender });
// Pre-arm the P2P answerer so the sender's offer signal lands in a wired peer.
p2pStartIncoming(sessionId, sender);
}
// Called from hub.ts on transfer:relay_ready. Decides whether this client is
// the sender or the receiver and runs the appropriate relay direction.
export async function handleRelayReady(sessionId: string): Promise<void>
{
const out = outgoing.get(sessionId);
if (out)
{
// Sender side: cancel P2P (cleanup data channel + connection),
// then push the file via the relay endpoint.
if (out.p2p) { out.p2p.cancel(); }
p2pCleanup(out.peerName);
try
{
await relaySendFile(sessionId, out.file, (p) =>
{
const store = useAppStore.getState();
const cur = store.activeTransfers[sessionId]
?? currentRecord(sessionId, "outgoing", out.peerName, out.file.name, out.file.size);
const grew = p.bytes > (cur.bytesTransferred ?? 0);
store.upsertTransfer({
...cur,
state: "RELAY_ACTIVE",
phase: "relay_uploading",
bytesTransferred: p.bytes,
lastProgressAt: grew ? Date.now() : cur.lastProgressAt,
});
}, out.abort.signal);
// Receiver side calls /done from relayReceiveFile's caller; sender
// just signals X-End on the last chunk and leaves state advancement
// to the receiver, mirroring the P2P path.
}
catch (e)
{
await apiFetch(`/api/transfer/${sessionId}/fail`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: (e as Error).message }),
});
}
finally
{
cleanupOutgoing(sessionId);
}
return;
}
const incoming = incomingFiles.get(sessionId);
if (!incoming)
{
// eslint-disable-next-line no-console
console.warn("relay_ready for unknown session", sessionId);
return;
}
// Receiver side: cancel P2P answerer, drain the relay stream into a Blob
// and trigger a download via createObjectURL.
p2pCleanup(incoming.sender);
try
{
await relayReceiveFile(sessionId, incoming.name, incoming.size, (p) =>
{
const store = useAppStore.getState();
const cur = store.activeTransfers[sessionId];
if (!cur) { return; }
const grew = p.bytes > (cur.bytesTransferred ?? 0);
store.upsertTransfer({
...cur,
state: "RELAY_ACTIVE",
phase: "relay_downloading",
bytesTransferred: p.bytes,
lastProgressAt: grew ? Date.now() : cur.lastProgressAt,
});
});
await apiFetch(`/api/transfer/${sessionId}/done`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bytes_transferred: incoming.size }),
});
}
catch (e)
{
await apiFetch(`/api/transfer/${sessionId}/fail`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: (e as Error).message }),
});
}
finally
{
incomingFiles.delete(sessionId);
}
}
function currentRecord(
sessionId: string,
direction: "outgoing" | "incoming",
peerName: string,
fileName: string,
fileSize: number,
): TransferRecord
{
const cur = useAppStore.getState().activeTransfers[sessionId];
if (cur) { return cur; }
return {
sessionId,
direction,
peerName,
fileName,
fileSize,
state: "RELAY_ACTIVE",
startedAt: Date.now(),
};
}
function cleanupOutgoing(sessionId: string): void
{
const s = outgoing.get(sessionId);
if (!s) { return; }
if (s.fallbackTimer) { clearTimeout(s.fallbackTimer); }
s.abort.abort();
outgoing.delete(sessionId);
}
// Hub uses this when transfer:state ends terminally to release any state.
export function cleanupTransfer(sessionId: string): void
{
cleanupOutgoing(sessionId);
incomingFiles.delete(sessionId);
}
// UI-triggered (发送方「跳过等待、立即中继」):发送方不等 30s ICE 超时,直接切到
// 中继。复用 watchdog 的逻辑——先停掉那个定时器以免重复 fire,把记录标成
// fallback_pending 让 UI 显示「正在切到中继」,再请求服务端转 RELAY_ACTIVE。
export async function skipWaitRelay(sessionId: string): Promise<void>
{
const cur = outgoing.get(sessionId);
if (cur?.fallbackTimer)
{
clearTimeout(cur.fallbackTimer);
cur.fallbackTimer = null;
}
const store = useAppStore.getState();
const r = store.activeTransfers[sessionId];
if (r) { store.upsertTransfer({ ...r, phase: "fallback_pending" }); }
await triggerFallback(sessionId);
}
// UI-triggered (取消进行中 / 等待中的传输):先就地拆掉本端的 P2P / 中继工作,再
// POST /cancel。服务端转 CANCELLED 后经 hub 的 transfer:state 把记录归到 history。
export async function cancelTransfer(sessionId: string): Promise<void>
{
const out = outgoing.get(sessionId);
if (out)
{
if (out.p2p) { out.p2p.cancel(); }
p2pCleanup(out.peerName);
}
const inc = incomingFiles.get(sessionId);
if (inc) { p2pCleanup(inc.sender); }
cleanupTransfer(sessionId);
const r = await apiFetch(`/api/transfer/${sessionId}/cancel`, { method: "POST" });
if (!r.ok && r.status !== 409)
{
// 409 = 已是终态(DONE 等),无需处理;其余记一笔。
// eslint-disable-next-line no-console
console.warn("cancel failed", sessionId, r.status, await r.text().catch(() => ""));
}
}
+104
View File
@@ -0,0 +1,104 @@
import { zhCN } from "./locales/zh-CN";
import { zhTW } from "./locales/zh-TW";
import { enUS } from "./locales/en-US";
// t() 仍是纯函数,方便 lib 层非 React 上下文里调用。
// 切换语言由 store 触发:store.setLocale 会同时同步到此模块(写 localStorage
// + 更新 module state);UI 通过 RouterProvider key={locale} 强制重挂载。
// zhCN 用 as const 锁定 key 集合作为类型推导基准,但值仍按 string 处理,
// 否则 Partial<TranslationDict> 会要求其他 locale 重复同样的 literal。
export type TranslationKey = keyof typeof zhCN;
export type TranslationDict = Record<TranslationKey, string>;
export type Locale = "zh-CN" | "zh-TW" | "en-US";
export const SUPPORTED_LOCALES: readonly Locale[] = [ "zh-CN", "zh-TW", "en-US" ] as const;
const dictionaries: Record<Locale, Partial<TranslationDict>> = {
"zh-CN": zhCN,
"zh-TW": zhTW,
"en-US": enUS,
};
const STORAGE_KEY = "cdrop.locale";
function readStorageLocale(): Locale | null
{
if (typeof window === "undefined") { return null; }
const v = window.localStorage.getItem(STORAGE_KEY);
if (v === "zh-CN" || v === "zh-TW" || v === "en-US") { return v; }
return null;
}
// 默认 locale:遍历 navigator.languages,先撞到一个中文标签(zh*)就按下面
// 的细分逻辑判定简繁;先撞到 en 取英文;二者都没有则回退英文。
//
// 中文细分:
// 1. 标签内含 "hans" → 简体;含 "hant" → 繁体
// 2. 区域子标签:cn / sg → 简体;tw / hk / mo → 繁体
// 3. 都不命中 → 简体(覆盖裸 "zh" 与未知地区)
function detectBrowserLocale(): Locale
{
if (typeof navigator === "undefined") { return "en-US"; }
const langs = navigator.languages && navigator.languages.length > 0
? navigator.languages
: ( navigator.language ? [ navigator.language ] : [] );
for (const raw of langs)
{
const l = raw.toLowerCase();
if (l.startsWith("zh")) { return classifyChinese(l); }
if (l.startsWith("en")) { return "en-US"; }
}
return "en-US";
}
function classifyChinese(lower: string): Locale
{
if (lower.includes("hans")) { return "zh-CN"; }
if (lower.includes("hant")) { return "zh-TW"; }
const parts = lower.split("-");
for (const p of parts)
{
if (p === "cn" || p === "sg") { return "zh-CN"; }
if (p === "tw" || p === "hk" || p === "mo") { return "zh-TW"; }
}
return "zh-CN";
}
let currentLocale: Locale = readStorageLocale() ?? detectBrowserLocale();
if (typeof document !== "undefined")
{
document.documentElement.lang = currentLocale;
}
const PLACEHOLDER = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
export function setLocale(locale: Locale): void
{
currentLocale = locale;
if (typeof window !== "undefined")
{
window.localStorage.setItem(STORAGE_KEY, locale);
}
if (typeof document !== "undefined")
{
document.documentElement.lang = locale;
}
}
export function getLocale(): Locale
{
return currentLocale;
}
export function t(key: TranslationKey, vars?: Record<string, string | number>): string
{
// currentLocale 缺失的 key 自动 fallback 到 zh-CN。
const template = dictionaries[currentLocale][key] ?? zhCN[key];
if (!vars) { return template; }
return template.replace(PLACEHOLDER, (_, name: string) =>
{
const v = vars[name];
return v === undefined ? "" : String(v);
});
}
+243
View File
@@ -0,0 +1,243 @@
import type { TranslationDict } from "../index";
// 缺失的 key 会自动 fallback 到 zh-CN(基准词典)。
export const enUS: Partial<TranslationDict> = {
// ---- app / shell -----------------------------------------------------
"app.brand": "Commilitia Drop",
"deviceType.browser": "Browser",
"deviceType.macos": "macOS client",
"deviceType.windows": "Windows client",
"deviceType.linux": "Linux client",
"deviceType.ios": "iOS client",
"deviceType.shortcut": "Shortcut",
"app.connected": "Online",
"app.connecting": "Connecting…",
"app.disconnectedTitle": "Reconnecting",
"app.disconnectedHint":
"Lost connection to the server — retrying automatically. No need to refresh.",
// ---- header / user menu ---------------------------------------------
"nav.accountMenu": "Account menu",
"nav.thisDeviceLabel": "This device: ",
"nav.settings": "Settings",
"nav.signOut": "Sign out",
"nav.backToHome": "← Home",
"nav.language": "语言 / Language",
"nav.theme": "主题 / Theme",
"nav.theme.light": "Light",
"nav.theme.dark": "Dark",
"nav.theme.system": "Follow system",
"nav.themeToggle": "Toggle theme",
// ---- home -----------------------------------------------------------
"home.greeting": "Hello, {{name}}",
"home.deviceList.title": "Devices",
"home.deviceList.onlineSuffix": "({{count}} online)",
"home.deviceList.showOffline": "Show offline ({{count}})",
"home.deviceList.empty":
"No peer devices yet. Open the app in another tab with a different device name (Settings → Device name) to see it here.",
"home.tabs.file": "File",
"home.tabs.message": "Message",
"home.tabs.clipboard": "Clipboard",
"home.file.dropzone": "Drop a file here or click to browse",
"home.file.selectedSize": "{{name}} ({{size}})",
"home.file.rejected": "File rejected",
"home.cta.selectDeviceAndFile": "Select a device and a file",
"home.cta.selectFile": "Select a file",
"home.cta.selectDevice": "Select a device",
"home.cta.sendTo": "Send to {{name}}",
"home.message.placeholder": "Type a message to {{name}}…",
"home.message.placeholderEmpty": "Select a device first",
"home.message.hint": "Enter to send, Shift+Enter for newline. Max 4 KB.",
"home.message.title": "Messages",
"home.message.countSuffix": "({{count}})",
"home.message.clearAll": "Clear all",
"home.transfer.active": "Active",
"home.transfer.history": "Recent transfers",
"home.clipboard.cloudTitle": "Latest from cloud",
"home.clipboard.cloudFrom": "From {{name}} · {{time}}",
"home.clipboard.cloudFromSelf": "From this device · {{time}}",
"home.clipboard.cloudEmpty": "Nothing synced yet.",
"home.clipboard.copyToLocal": "Copy to local clipboard",
"home.clipboard.copyToLocalSuccess": "Copied to local clipboard",
"home.clipboard.uploadTitle": "Sync local clipboard",
"home.clipboard.uploadHint": "Plain text only, max 64 KB. Styles (bold / color) are stripped.",
"home.clipboard.uploadButton": "Upload local clipboard",
"home.clipboard.uploading": "Uploading…",
"home.clipboard.uploadEmpty": "Local clipboard is empty",
"home.clipboard.refresh": "Refresh",
"home.clipboard.clear": "Clear cloud",
"home.clipboard.clearConfirm": "Clear the cloud clipboard? All devices will see empty content.",
"home.clipboard.clearSuccess": "Cloud clipboard cleared",
"home.clipboard.hidden": "Content hidden — click Reveal to show",
"home.clipboard.reveal": "Reveal",
"home.clipboard.hide": "Hide",
// ---- setup ----------------------------------------------------------
"setup.title": "Name this device",
"setup.help": "The name is shown to your other devices. Avoid spaces, ≤32 chars.",
"setup.field": "Device name",
"setup.save": "Save and continue",
// ---- login ----------------------------------------------------------
"login.title": "Sign in",
"login.dev.help":
"Dev mode: pick any user identifier. Two tabs with the same identifier simulate two devices of one user.",
"login.dev.field": "Dev user id",
"login.dev.continue": "Continue",
"login.prod.help": "Sign in via Casdoor.",
"login.prod.button": "Sign in with Casdoor",
// ---- oauth ----------------------------------------------------------
"oauth.failed": "Sign-in failed",
"oauth.signingIn": "Signing in…",
"oauth.missingParams": "Missing 'code' or 'state' query parameter",
// ---- settings -------------------------------------------------------
"settings.title": "Settings",
"settings.currentDevice.title": "This device",
"settings.deviceName.field": "Device name",
"settings.deviceName.save": "Save",
"settings.deviceName.unchanged": "Name is unchanged",
"settings.deviceName.empty": "Name cannot be empty",
"settings.deviceName.asciiOnly": "Device name must use ASCII characters only (letters, numbers, symbols).",
"settings.deviceName.success": "Device name updated",
"settings.unregister.current": "Unregister this device",
"settings.unregister.currentHint":
"Removes this device's registration and signs you out.",
"settings.unregister.currentConfirm":
"Unregistering this device will remove its registration and sign you out. Continue?",
"settings.unregister.peerConfirm": "Remove device \"{{name}}\"?",
"settings.peers.title": "Other devices",
"settings.peers.empty": "No other devices.",
"settings.peers.remove": "Remove",
"settings.peers.removing": "Removing…",
"settings.error.delete": "Action failed: {{message}}",
"settings.online": "Online",
"settings.offline": "Offline",
"settings.lastSeen": "Last seen {{time}}",
"settings.lastSeenNever": "Never connected",
"settings.account.title": "Account",
"settings.account.signOut": "Sign out",
"settings.desktop.title": "Desktop",
"settings.desktop.clipboardSync": "Auto-sync clipboard",
"settings.desktop.clipboardSyncHint": "Copies are uploaded to the cloud, and clipboard updates from your other devices are applied here.",
"settings.desktop.launchAtLogin": "Launch at login",
"settings.desktop.launchAtLoginHint": "Start cdrop in the background after you sign in (lives in the menu bar).",
"settings.desktop.downloadDir": "Download folder",
"settings.desktop.downloadDirHint": "Received files are saved here. Leave unset to use the system Downloads folder.",
"settings.desktop.downloadDirChoose": "Change…",
"settings.desktop.downloadDirReset": "Reset to default",
"settings.desktop.downloadDirPicker": "Choose download folder",
"settings.desktop.ttlNote": "For security, cloud clipboard content is cleared automatically after 5 minutes.",
// ---- iOS Shortcut tokens ---------------------------------------------
"settings.shortcut.title": "iOS Shortcut",
"settings.shortcut.intro": "Issue a long-lived, clipboard-only, revocable token for the iOS Shortcuts app. Even if leaked it can only read/write the clipboard — nothing else.",
"settings.shortcut.labelField": "Label",
"settings.shortcut.labelPlaceholder": "e.g. My iPhone",
"settings.shortcut.issue": "Issue",
"settings.shortcut.empty": "No tokens issued yet.",
"settings.shortcut.loadError": "Failed to load tokens.",
"settings.shortcut.retry": "Retry",
"settings.shortcut.revoke": "Revoke",
"settings.shortcut.revoking": "Revoking…",
"settings.shortcut.revokeConfirm": "Revoking immediately disables any Shortcut using this token. Continue?",
"settings.shortcut.revoked": "Revoked",
"settings.shortcut.revokedToast": "Token revoked",
"settings.shortcut.expired": "Expired",
"settings.shortcut.expiresAt": "Expires {{date}}",
"settings.shortcut.lastUsed": "Last used {{time}}",
"settings.shortcut.neverUsed": "Never used",
"settings.shortcut.unavailable": "Shortcut tokens are not enabled on the server.",
"settings.shortcut.tooMany": "You have reached the active-token limit. Revoke an old one first.",
"settings.shortcut.issueError": "Failed: {{message}}",
"settings.shortcut.issuedTitle": "Token issued",
"settings.shortcut.onceWarning": "This token is shown only once. Copy it now — you won't be able to see it again after closing.",
"settings.shortcut.tokenLabel": "Token (Bearer)",
"settings.shortcut.copy": "Copy",
"settings.shortcut.copyFailed": "Copy failed — select and copy manually.",
"settings.shortcut.copied": "Copied",
"settings.shortcut.baseLabel": "Server address",
"settings.shortcut.guideTitle": "Set up on iOS (manual sync)",
"settings.shortcut.guideIntro": "Build two shortcuts in the iPhone Shortcuts app, filling in the server address and token above on first run:",
"settings.shortcut.guideDeviceName": "Add header X-Device-Name: your-device-name (ASCII only) to both shortcuts so it shows as one device in the list.",
"settings.shortcut.guidePullTitle": "Pull (cloud → local clipboard)",
"settings.shortcut.guidePull1": "'Get Contents of URL': set the URL to your-server-address + /api/clipboard, method GET, add header Authorization: Bearer your-token.",
"settings.shortcut.guidePull2": "'Get Dictionary Value': key content.",
"settings.shortcut.guidePull3": "If non-empty, 'Copy to Clipboard'; optionally add 'Show Notification'.",
"settings.shortcut.guidePushTitle": "Push (local clipboard → cloud)",
"settings.shortcut.guidePush1": "'Get Clipboard'; stop if empty.",
"settings.shortcut.guidePush2": "'Get Contents of URL': same URL, method PUT, headers Authorization: Bearer your-token and Content-Type: application/json, JSON body: {\"content\": clipboard, \"content_type\": \"text/plain\"}.",
"settings.shortcut.guidePush3": "Optionally add 'Show Notification'.",
"settings.shortcut.guideToleranceNote": "A single run may fail on a flaky network — just retry. The automatic polling version (next phase) retries silently in the background.",
"settings.shortcut.done": "Done",
// ---- transfer states / modes ----------------------------------------
"transfer.savedTo": "Saved to {{path}}",
"transfer.saveFailed": "Failed to save file: {{error}}",
"transfer.action.cancel": "Cancel",
"transfer.action.delete": "Delete",
"transfer.action.relayNow": "Relay now",
"transfer.action.details": "Details",
"transfer.state.PENDING": "Pending",
"transfer.state.ACCEPTED": "Accepted",
"transfer.state.P2P_ACTIVE": "Transferring",
"transfer.state.RELAY_ACTIVE": "Relaying",
"transfer.state.DONE": "Done",
"transfer.state.FAILED": "Failed",
"transfer.state.CANCELLED": "Cancelled",
"transfer.mode.p2p": "P2P",
"transfer.mode.relay": "Relay",
// ---- client phase (real-time telemetry; tells "waiting" from "stuck") ----
"transfer.phase.initializing": "Initializing…",
"transfer.phase.waiting_accept": "Waiting for the other side to accept…",
"transfer.phase.ice_gathering": "Gathering network candidates…",
"transfer.phase.ice_checking": "Establishing direct tunnel…",
"transfer.phase.ice_connected": "Tunnel established",
"transfer.phase.dc_open": "Channel open",
"transfer.phase.transferring": "Transferring",
"transfer.phase.completing": "Finalizing…",
"transfer.phase.fallback_pending": "Falling back to relay…",
"transfer.phase.relay_uploading": "Uploading via relay",
"transfer.phase.relay_downloading": "Downloading via relay",
"transfer.stalled": "Looks stalled ({{seconds}}s without progress)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/s",
// ---- debug expansion ------------------------------------------------
"transfer.debug.toggle": "Debug",
"transfer.debug.iceGathering": "Gathering",
"transfer.debug.iceConnection": "Connection",
"transfer.debug.candidatesLocal": "Local candidates",
"transfer.debug.candidatesRemote": "Remote candidates",
"transfer.debug.selectedPair": "Selected pair",
"transfer.debug.noPair": "No nominated pair yet",
"transfer.debug.candidatesLine":
"host {{host}} · mDNS {{mdns}} · srflx {{srflx}} · prflx {{prflx}} · relay {{relay}}",
"transfer.debug.dataChannel": "Data channel",
"transfer.debug.dcLine": "{{state}} · buffered {{buffered}}",
// ---- time -----------------------------------------------------------
"time.justNow": "just now",
"time.secondsAgo": "{{n}}s ago",
"time.minutesAgo": "{{n}}m ago",
"time.hoursAgo": "{{n}}h ago",
// ---- common ---------------------------------------------------------
"common.dismiss": "Dismiss",
"common.cancel": "Cancel",
"common.confirm": "Confirm",
// ---- errors ---------------------------------------------------------
"errors.messageEmpty": "Message is empty",
"errors.messageOverflow": "Message exceeds 4 KB; send a file instead",
"errors.noReceiver": "No receiver selected",
"errors.devTokenMissing":
"Dev mode: VITE_CDROP_DEV_TOKEN is not set in .env.local",
"errors.noAccessToken": "No access token; user must log in",
"errors.clipboardUnavailable": "Browser clipboard API unavailable (requires HTTPS + permission)",
"errors.clipboardOverflow": "Content too large; exceeds {{max}} byte limit",
"errors.clipboardWriteFailed": "Failed to write to local clipboard: {{message}}",
};
+240
View File
@@ -0,0 +1,240 @@
// zh-CN 是 i18n 类型推导的基准词典:必须包含全部 key。其他 locale
// 缺失的 key 会自动 fallback 到此处。
export const zhCN = {
// ---- 应用 / 全局 -----------------------------------------------------
"app.brand": "Commilitia Drop",
"deviceType.browser": "浏览器",
"deviceType.macos": "macOS 客户端",
"deviceType.windows": "Windows 客户端",
"deviceType.linux": "Linux 客户端",
"deviceType.ios": "iOS 客户端",
"deviceType.shortcut": "快捷指令",
"app.connected": "已连接",
"app.connecting": "正在连接…",
"app.disconnectedTitle": "正在重连",
"app.disconnectedHint": "与服务器的连接已断开,正在自动重连,无需刷新页面。",
// ---- 顶部栏 / 用户菜单 ------------------------------------------------
"nav.accountMenu": "账号菜单",
"nav.thisDeviceLabel": "本机:",
"nav.settings": "设置",
"nav.signOut": "退出登录",
"nav.backToHome": "← 主页",
"nav.language": "语言 / Language",
"nav.theme": "主题 / Theme",
"nav.theme.light": "浅色",
"nav.theme.dark": "深色",
"nav.theme.system": "跟随系统",
"nav.themeToggle": "切换主题",
// ---- 主页 ------------------------------------------------------------
"home.greeting": "你好,{{name}}",
"home.deviceList.title": "设备",
"home.deviceList.onlineSuffix": "{{count}} 台在线)",
"home.deviceList.showOffline": "显示离线设备({{count}}",
"home.deviceList.empty":
"暂无其他设备。在另一标签页或设备上以相同账户登录,并设置一个不同的设备名(设置 → 设备名称),即可在此处看到它。",
"home.tabs.file": "文件",
"home.tabs.message": "消息",
"home.tabs.clipboard": "剪贴板",
"home.file.dropzone": "拖放文件到此处或点击选择",
"home.file.selectedSize": "{{name}}{{size}}",
"home.file.rejected": "文件已拒绝",
"home.cta.selectDeviceAndFile": "请选择设备与文件",
"home.cta.selectFile": "请选择文件",
"home.cta.selectDevice": "请选择设备",
"home.cta.sendTo": "发送到 {{name}}",
"home.message.placeholder": "发送消息到 {{name}}…",
"home.message.placeholderEmpty": "请先选择设备",
"home.message.hint": "回车发送,Shift + 回车换行,最长4 KB。",
"home.message.title": "消息",
"home.message.countSuffix": "{{count}}",
"home.message.clearAll": "全部清除",
"home.transfer.active": "进行中",
"home.transfer.history": "最近的传输",
"home.clipboard.cloudTitle": "云端最新",
"home.clipboard.cloudFrom": "来自 {{name}} · {{time}}",
"home.clipboard.cloudFromSelf": "来自本机 · {{time}}",
"home.clipboard.cloudEmpty": "尚未同步任何内容。",
"home.clipboard.copyToLocal": "复制到本机剪贴板",
"home.clipboard.copyToLocalSuccess": "已复制到本机剪贴板",
"home.clipboard.uploadTitle": "同步本机剪贴板",
"home.clipboard.uploadHint": "仅纯文本,最大64 KB;样式(粗体/颜色等)会被剥除。",
"home.clipboard.uploadButton": "上传本机剪贴板",
"home.clipboard.uploading": "上传中…",
"home.clipboard.uploadEmpty": "本机剪贴板为空",
"home.clipboard.refresh": "刷新",
"home.clipboard.clear": "清空云端",
"home.clipboard.clearConfirm": "确认清空云端剪贴板?所有设备将看到空内容。",
"home.clipboard.clearSuccess": "已清空云端剪贴板",
"home.clipboard.hidden": "内容已隐藏 · 点击“显示”查看",
"home.clipboard.reveal": "显示",
"home.clipboard.hide": "隐藏",
// ---- 首次设置 --------------------------------------------------------
"setup.title": "为本机命名",
"setup.help": "此名称会展示给你的其他设备。建议避免空格,长度不超过 32 字符。",
"setup.field": "设备名称",
"setup.save": "保存并继续",
// ---- 登录 ------------------------------------------------------------
"login.title": "登录",
"login.dev.help":
"Dev模式:随意填写一个用户标识。同标识的两个标签页将模拟同一用户的两台设备。",
"login.dev.field": "Dev用户标识",
"login.dev.continue": "继续",
"login.prod.help": "通过Casdoor登录。",
"login.prod.button": "使用Casdoor登录",
// ---- OAuth 回调 -------------------------------------------------------
"oauth.failed": "登录失败",
"oauth.signingIn": "正在登录…",
"oauth.missingParams": "缺少code或state查询参数",
// ---- 设置页 ----------------------------------------------------------
"settings.title": "设置",
"settings.currentDevice.title": "当前设备",
"settings.deviceName.field": "设备名称",
"settings.deviceName.save": "保存",
"settings.deviceName.unchanged": "新名称与当前一致",
"settings.deviceName.empty": "名称不能为空",
"settings.deviceName.asciiOnly": "设备名称只能使用 ASCII 字符(英文字母、数字、符号)。",
"settings.deviceName.success": "设备名称已更新",
"settings.unregister.current": "注销当前设备",
"settings.unregister.currentHint": "将退出登录并移除此设备的注册。",
"settings.unregister.currentConfirm":
"注销当前设备会移除此设备的注册并退出登录,确认继续?",
"settings.unregister.peerConfirm": "确认要移除设备“{{name}}”?",
"settings.peers.title": "其他设备",
"settings.peers.empty": "暂无其他设备。",
"settings.peers.remove": "移除",
"settings.peers.removing": "正在移除…",
"settings.error.delete": "操作失败:{{message}}",
"settings.online": "在线",
"settings.offline": "离线",
"settings.lastSeen": "上次活跃 {{time}}",
"settings.lastSeenNever": "尚未上线",
"settings.account.title": "账号",
"settings.account.signOut": "退出登录",
"settings.desktop.title": "桌面",
"settings.desktop.clipboardSync": "剪贴板自动同步",
"settings.desktop.clipboardSyncHint": "复制即上传到云端,并接收其他设备的剪贴板更新。",
"settings.desktop.launchAtLogin": "开机自启",
"settings.desktop.launchAtLoginHint": "登录系统后自动在后台启动 cdrop(菜单栏常驻)。",
"settings.desktop.downloadDir": "下载目录",
"settings.desktop.downloadDirHint": "接收到的文件保存到此目录;留空则使用系统下载目录。",
"settings.desktop.downloadDirChoose": "更改…",
"settings.desktop.downloadDirReset": "恢复默认",
"settings.desktop.downloadDirPicker": "选择下载目录",
"settings.desktop.ttlNote": "出于安全考虑,云端剪贴板内容会在 5 分钟后自动清除。",
// ---- iOS 快捷指令令牌 ------------------------------------------------
"settings.shortcut.title": "iOS 快捷指令",
"settings.shortcut.intro": "为 iOS“快捷指令”签发长效、仅限剪贴板、可随时吊销的专用令牌。即便令牌泄漏也只能读写剪贴板、无法触及其他数据。",
"settings.shortcut.labelField": "备注名称",
"settings.shortcut.labelPlaceholder": "例如:我的 iPhone",
"settings.shortcut.issue": "签发",
"settings.shortcut.empty": "尚未签发任何令牌。",
"settings.shortcut.loadError": "令牌列表加载失败。",
"settings.shortcut.retry": "重试",
"settings.shortcut.revoke": "吊销",
"settings.shortcut.revoking": "正在吊销…",
"settings.shortcut.revokeConfirm": "吊销后使用此令牌的快捷指令将立即失效,确认继续?",
"settings.shortcut.revoked": "已吊销",
"settings.shortcut.revokedToast": "令牌已吊销",
"settings.shortcut.expired": "已过期",
"settings.shortcut.expiresAt": "到期 {{date}}",
"settings.shortcut.lastUsed": "上次使用 {{time}}",
"settings.shortcut.neverUsed": "尚未使用",
"settings.shortcut.unavailable": "服务器未启用快捷指令令牌。",
"settings.shortcut.tooMany": "活跃令牌已达上限,请先吊销旧令牌。",
"settings.shortcut.issueError": "操作失败:{{message}}",
"settings.shortcut.issuedTitle": "令牌已签发",
"settings.shortcut.onceWarning": "令牌仅显示这一次,请立即复制保存;关闭后无法再次查看。",
"settings.shortcut.tokenLabel": "令牌(Bearer",
"settings.shortcut.copy": "复制",
"settings.shortcut.copyFailed": "复制失败,请手动选择复制",
"settings.shortcut.copied": "已复制",
"settings.shortcut.baseLabel": "服务器地址",
"settings.shortcut.guideTitle": "在 iOS 上手动搭建(手动同步)",
"settings.shortcut.guideIntro": "在 iPhone 的“快捷指令”App 中新建两个指令,首次运行时填入上面的服务器地址与令牌:",
"settings.shortcut.guideDeviceName": "两个指令都加请求头 X-Device-Name: 你的设备名(仅 ASCII),让它在设备列表里统一显示。",
"settings.shortcut.guidePullTitle": "拉取(云端 → 本机剪贴板)",
"settings.shortcut.guidePull1": "“获取 URL 内容”:地址设为 你的服务器地址 加 /api/clipboard,方法 GET,请求头加 Authorization: Bearer 你的令牌。",
"settings.shortcut.guidePull2": "“获取词典值”:取键 content。",
"settings.shortcut.guidePull3": "内容非空时用“拷贝到剪贴板”写入,可再加“显示通知”提示已拉取。",
"settings.shortcut.guidePushTitle": "上传(本机剪贴板 → 云端)",
"settings.shortcut.guidePush1": "“获取剪贴板”;若为空则停止。",
"settings.shortcut.guidePush2": "“获取 URL 内容”:地址同上,方法 PUT,请求头加 Authorization: Bearer 你的令牌 与 Content-Type: application/json,请求体选 JSON{\"content\": 剪贴板, \"content_type\": \"text/plain\"}。",
"settings.shortcut.guidePush3": "可加“显示通知”提示已上传。",
"settings.shortcut.guideToleranceNote": "网络不稳定时单次运行可能失败,重试即可;自动轮询版会在后台静默重试(下一阶段提供)。",
"settings.shortcut.done": "完成",
// ---- 传输状态 / 模式 -------------------------------------------------
"transfer.savedTo": "已保存到 {{path}}",
"transfer.saveFailed": "保存文件失败:{{error}}",
"transfer.action.cancel": "取消",
"transfer.action.delete": "删除",
"transfer.action.relayNow": "立即中继",
"transfer.action.details": "详情",
"transfer.state.PENDING": "等待中",
"transfer.state.ACCEPTED": "已接受",
"transfer.state.P2P_ACTIVE": "传输中",
"transfer.state.RELAY_ACTIVE": "中继中",
"transfer.state.DONE": "已完成",
"transfer.state.FAILED": "失败",
"transfer.state.CANCELLED": "已取消",
"transfer.mode.p2p": "P2P",
"transfer.mode.relay": "中继",
// ---- 客户端 phase(实时遥测,区分等待中和卡住)----------------------
"transfer.phase.initializing": "正在初始化…",
"transfer.phase.waiting_accept": "等待对端接受…",
"transfer.phase.ice_gathering": "收集网络候选…",
"transfer.phase.ice_checking": "建立直连通道…",
"transfer.phase.ice_connected": "通道已连接",
"transfer.phase.dc_open": "信道已打开",
"transfer.phase.transferring": "传输中",
"transfer.phase.completing": "收尾中…",
"transfer.phase.fallback_pending": "切换到中继路径…",
"transfer.phase.relay_uploading": "中继上传中",
"transfer.phase.relay_downloading": "中继下载中",
"transfer.stalled": "似乎卡住了({{seconds}} 秒无进度)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/秒",
// ---- 调试展开区 -----------------------------------------------------
"transfer.debug.toggle": "调试信息",
"transfer.debug.iceGathering": "候选收集",
"transfer.debug.iceConnection": "连接状态",
"transfer.debug.candidatesLocal": "本端候选",
"transfer.debug.candidatesRemote": "对端候选",
"transfer.debug.selectedPair": "选中候选对",
"transfer.debug.noPair": "尚未选定候选对",
"transfer.debug.candidatesLine":
"host {{host}} · mDNS {{mdns}} · srflx {{srflx}} · prflx {{prflx}} · relay {{relay}}",
"transfer.debug.dataChannel": "数据信道",
"transfer.debug.dcLine": "{{state}} · 待发送 {{buffered}}",
// ---- 时间 ------------------------------------------------------------
"time.justNow": "刚刚",
"time.secondsAgo": "{{n}} 秒前",
"time.minutesAgo": "{{n}} 分钟前",
"time.hoursAgo": "{{n}} 小时前",
// ---- 通用 ------------------------------------------------------------
"common.dismiss": "移除",
"common.cancel": "取消",
"common.confirm": "确认",
// ---- 错误(lib/* 中抛出,呈现到 Alert-------------------------------
"errors.messageEmpty": "消息为空",
"errors.messageOverflow": "消息超过4 KB;请改用文件传输",
"errors.noReceiver": "未选择接收方",
"errors.devTokenMissing":
"Dev模式:未在.env.local设置VITE_CDROP_DEV_TOKEN",
"errors.noAccessToken": "无访问令牌:用户必须先登录",
"errors.clipboardUnavailable": "浏览器不支持剪贴板API(需HTTPS与权限)",
"errors.clipboardOverflow": "内容过大,超过 {{max}} 字节上限",
"errors.clipboardWriteFailed": "写入本机剪贴板失败:{{message}}",
} as const;
+244
View File
@@ -0,0 +1,244 @@
import type { TranslationDict } from "../index";
// 繁體中文(臺灣用語)。在地化詞彙:檔案 / 訊息 / 剪貼簿 / 設定 / 裝置 / 線上 /
// 連線 / 重新整理 / 伺服器 / 使用者 / 位元組 等。引號使用「」/『』直角形式
// (與簡體 "" 不同),切勿在此檔案執行 MatchedQuotesToFullwidth。
// 缺失的 key 會自動 fallback 到 zh-CN(基準詞典)。
export const zhTW: Partial<TranslationDict> = {
// ---- 應用 / 全域 -----------------------------------------------------
"app.brand": "Commilitia Drop",
"deviceType.browser": "瀏覽器",
"deviceType.macos": "macOS 用戶端",
"deviceType.windows": "Windows 用戶端",
"deviceType.linux": "Linux 用戶端",
"deviceType.ios": "iOS 用戶端",
"deviceType.shortcut": "捷徑",
"app.connected": "已連線",
"app.connecting": "正在連線…",
"app.disconnectedTitle": "正在重新連線",
"app.disconnectedHint": "與伺服器的連線已中斷,正在自動重新連線,無需重新整理頁面。",
// ---- 頂部列 / 使用者選單 ---------------------------------------------
"nav.accountMenu": "帳號選單",
"nav.thisDeviceLabel": "本機:",
"nav.settings": "設定",
"nav.signOut": "登出",
"nav.backToHome": "← 首頁",
"nav.language": "語言 / Language",
"nav.theme": "主題 / Theme",
"nav.theme.light": "淺色",
"nav.theme.dark": "深色",
"nav.theme.system": "跟隨系統",
"nav.themeToggle": "切換主題",
// ---- 首頁 ------------------------------------------------------------
"home.greeting": "你好,{{name}}",
"home.deviceList.title": "裝置",
"home.deviceList.onlineSuffix": "{{count}} 部線上)",
"home.deviceList.showOffline": "顯示離線裝置({{count}}",
"home.deviceList.empty":
"尚無其他裝置。在另一個分頁或裝置上以同一帳號登入,並設定一個不同的裝置名稱(設定 → 裝置名稱),即可在此處看到它。",
"home.tabs.file": "檔案",
"home.tabs.message": "訊息",
"home.tabs.clipboard": "剪貼簿",
"home.file.dropzone": "拖曳檔案到此處或點選選擇",
"home.file.selectedSize": "{{name}}{{size}}",
"home.file.rejected": "檔案已拒絕",
"home.cta.selectDeviceAndFile": "請選擇裝置與檔案",
"home.cta.selectFile": "請選擇檔案",
"home.cta.selectDevice": "請選擇裝置",
"home.cta.sendTo": "傳送到 {{name}}",
"home.message.placeholder": "傳送訊息給 {{name}}…",
"home.message.placeholderEmpty": "請先選擇裝置",
"home.message.hint": "Enter傳送,Shift + Enter換行,最長4 KB。",
"home.message.title": "訊息",
"home.message.countSuffix": "{{count}}",
"home.message.clearAll": "全部清除",
"home.transfer.active": "進行中",
"home.transfer.history": "最近的傳輸",
"home.clipboard.cloudTitle": "雲端最新",
"home.clipboard.cloudFrom": "來自 {{name}} · {{time}}",
"home.clipboard.cloudFromSelf": "來自本機 · {{time}}",
"home.clipboard.cloudEmpty": "尚未同步任何內容。",
"home.clipboard.copyToLocal": "複製到本機剪貼簿",
"home.clipboard.copyToLocalSuccess": "已複製到本機剪貼簿",
"home.clipboard.uploadTitle": "同步本機剪貼簿",
"home.clipboard.uploadHint": "僅純文字,最大64 KB;樣式(粗體 / 顏色等)會被剝除。",
"home.clipboard.uploadButton": "上傳本機剪貼簿",
"home.clipboard.uploading": "上傳中…",
"home.clipboard.uploadEmpty": "本機剪貼簿為空",
"home.clipboard.refresh": "重新整理",
"home.clipboard.clear": "清空雲端",
"home.clipboard.clearConfirm": "確認清空雲端剪貼簿?所有裝置將看到空內容。",
"home.clipboard.clearSuccess": "已清空雲端剪貼簿",
"home.clipboard.hidden": "內容已隱藏 · 點擊「顯示」查看",
"home.clipboard.reveal": "顯示",
"home.clipboard.hide": "隱藏",
// ---- 首次設定 --------------------------------------------------------
"setup.title": "為本機命名",
"setup.help": "此名稱會顯示給你的其他裝置。建議避免空格,長度不超過 32 個字元。",
"setup.field": "裝置名稱",
"setup.save": "儲存並繼續",
// ---- 登入 ------------------------------------------------------------
"login.title": "登入",
"login.dev.help":
"Dev模式:隨意填寫一個使用者識別碼。同識別碼的兩個分頁將模擬同一使用者的兩部裝置。",
"login.dev.field": "Dev使用者識別碼",
"login.dev.continue": "繼續",
"login.prod.help": "透過Casdoor登入。",
"login.prod.button": "透過Casdoor登入",
// ---- OAuth 回呼 -------------------------------------------------------
"oauth.failed": "登入失敗",
"oauth.signingIn": "正在登入…",
"oauth.missingParams": "缺少code或state查詢參數",
// ---- 設定頁 ----------------------------------------------------------
"settings.title": "設定",
"settings.currentDevice.title": "目前裝置",
"settings.deviceName.field": "裝置名稱",
"settings.deviceName.save": "儲存",
"settings.deviceName.unchanged": "新名稱與目前一致",
"settings.deviceName.empty": "名稱不能為空",
"settings.deviceName.asciiOnly": "裝置名稱只能使用 ASCII 字元(英文字母、數字、符號)。",
"settings.deviceName.success": "裝置名稱已更新",
"settings.unregister.current": "解除註冊本裝置",
"settings.unregister.currentHint": "將登出並移除本裝置的註冊。",
"settings.unregister.currentConfirm":
"解除註冊本裝置會移除其註冊並登出,確認繼續?",
"settings.unregister.peerConfirm": "確認要移除裝置「{{name}}」?",
"settings.peers.title": "其他裝置",
"settings.peers.empty": "尚無其他裝置。",
"settings.peers.remove": "移除",
"settings.peers.removing": "正在移除…",
"settings.error.delete": "操作失敗:{{message}}",
"settings.online": "線上",
"settings.offline": "離線",
"settings.lastSeen": "上次活躍 {{time}}",
"settings.lastSeenNever": "尚未上線",
"settings.account.title": "帳號",
"settings.account.signOut": "登出",
"settings.desktop.title": "桌面",
"settings.desktop.clipboardSync": "剪貼簿自動同步",
"settings.desktop.clipboardSyncHint": "複製後即上傳雲端,並接收其他裝置的剪貼簿更新。",
"settings.desktop.launchAtLogin": "開機自動啟動",
"settings.desktop.launchAtLoginHint": "登入系統後自動在背景啟動 cdrop(常駐選單列)。",
"settings.desktop.downloadDir": "下載資料夾",
"settings.desktop.downloadDirHint": "接收到的檔案會儲存至此資料夾;留空則使用系統下載資料夾。",
"settings.desktop.downloadDirChoose": "變更…",
"settings.desktop.downloadDirReset": "還原預設",
"settings.desktop.downloadDirPicker": "選擇下載資料夾",
"settings.desktop.ttlNote": "基於安全考量,雲端剪貼簿內容會在 5 分鐘後自動清除。",
// ---- iOS 捷徑權杖 ----------------------------------------------------
"settings.shortcut.title": "iOS 捷徑",
"settings.shortcut.intro": "為 iOS「捷徑」簽發長效、僅限剪貼簿、可隨時撤銷的專用權杖。即使權杖外洩也只能讀寫剪貼簿、無法觸及其他資料。",
"settings.shortcut.labelField": "備註名稱",
"settings.shortcut.labelPlaceholder": "例如:我的 iPhone",
"settings.shortcut.issue": "簽發",
"settings.shortcut.empty": "尚未簽發任何權杖。",
"settings.shortcut.loadError": "權杖清單載入失敗。",
"settings.shortcut.retry": "重試",
"settings.shortcut.revoke": "撤銷",
"settings.shortcut.revoking": "正在撤銷…",
"settings.shortcut.revokeConfirm": "撤銷後使用此權杖的捷徑將立即失效,確認繼續?",
"settings.shortcut.revoked": "已撤銷",
"settings.shortcut.revokedToast": "權杖已撤銷",
"settings.shortcut.expired": "已過期",
"settings.shortcut.expiresAt": "到期 {{date}}",
"settings.shortcut.lastUsed": "上次使用 {{time}}",
"settings.shortcut.neverUsed": "尚未使用",
"settings.shortcut.unavailable": "伺服器未啟用捷徑權杖。",
"settings.shortcut.tooMany": "使用中的權杖已達上限,請先撤銷舊權杖。",
"settings.shortcut.issueError": "操作失敗:{{message}}",
"settings.shortcut.issuedTitle": "權杖已簽發",
"settings.shortcut.onceWarning": "權杖僅顯示這一次,請立即複製保存;關閉後無法再次檢視。",
"settings.shortcut.tokenLabel": "權杖(Bearer",
"settings.shortcut.copy": "複製",
"settings.shortcut.copyFailed": "複製失敗,請手動選取複製",
"settings.shortcut.copied": "已複製",
"settings.shortcut.baseLabel": "伺服器位址",
"settings.shortcut.guideTitle": "在 iOS 上手動建立(手動同步)",
"settings.shortcut.guideIntro": "在 iPhone 的「捷徑」App 中新增兩個捷徑,首次執行時填入上方的伺服器位址與權杖:",
"settings.shortcut.guideDeviceName": "兩個捷徑都加標頭 X-Device-Name: 你的裝置名稱(僅 ASCII),讓它在裝置清單中統一顯示。",
"settings.shortcut.guidePullTitle": "拉取(雲端 → 本機剪貼簿)",
"settings.shortcut.guidePull1": "「取得 URL 內容」:網址設為 你的伺服器位址 加 /api/clipboard,方法 GET,標頭加 Authorization: Bearer 你的權杖。",
"settings.shortcut.guidePull2": "「取得字典值」:取鍵 content。",
"settings.shortcut.guidePull3": "內容非空時用「複製到剪貼簿」寫入,可再加「顯示通知」提示已拉取。",
"settings.shortcut.guidePushTitle": "上傳(本機剪貼簿 → 雲端)",
"settings.shortcut.guidePush1": "「取得剪貼簿」;若為空則停止。",
"settings.shortcut.guidePush2": "「取得 URL 內容」:網址同上,方法 PUT,標頭加 Authorization: Bearer 你的權杖 與 Content-Type: application/json,請求內容選 JSON{\"content\": 剪貼簿, \"content_type\": \"text/plain\"}。",
"settings.shortcut.guidePush3": "可加「顯示通知」提示已上傳。",
"settings.shortcut.guideToleranceNote": "網路不穩時單次執行可能失敗,重試即可;自動輪詢版會在背景靜默重試(下一階段提供)。",
"settings.shortcut.done": "完成",
// ---- 傳輸狀態 / 模式 -------------------------------------------------
"transfer.savedTo": "已儲存至 {{path}}",
"transfer.saveFailed": "儲存檔案失敗:{{error}}",
"transfer.action.cancel": "取消",
"transfer.action.delete": "刪除",
"transfer.action.relayNow": "立即中繼",
"transfer.action.details": "詳情",
"transfer.state.PENDING": "等待中",
"transfer.state.ACCEPTED": "已接受",
"transfer.state.P2P_ACTIVE": "傳輸中",
"transfer.state.RELAY_ACTIVE": "中繼中",
"transfer.state.DONE": "已完成",
"transfer.state.FAILED": "失敗",
"transfer.state.CANCELLED": "已取消",
"transfer.mode.p2p": "P2P",
"transfer.mode.relay": "中繼",
// ---- 客戶端 phase(即時遙測,區分等待中和卡住)---------------------
"transfer.phase.initializing": "正在初始化…",
"transfer.phase.waiting_accept": "等待對端接受…",
"transfer.phase.ice_gathering": "收集網路候選…",
"transfer.phase.ice_checking": "建立直連通道…",
"transfer.phase.ice_connected": "通道已連線",
"transfer.phase.dc_open": "通道已開啟",
"transfer.phase.transferring": "傳輸中",
"transfer.phase.completing": "收尾中…",
"transfer.phase.fallback_pending": "切換到中繼路徑…",
"transfer.phase.relay_uploading": "中繼上傳中",
"transfer.phase.relay_downloading": "中繼下載中",
"transfer.stalled": "似乎卡住了({{seconds}} 秒無進度)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/秒",
// ---- 偵錯展開區 -----------------------------------------------------
"transfer.debug.toggle": "偵錯資訊",
"transfer.debug.iceGathering": "候選收集",
"transfer.debug.iceConnection": "連線狀態",
"transfer.debug.candidatesLocal": "本端候選",
"transfer.debug.candidatesRemote": "對端候選",
"transfer.debug.selectedPair": "選中候選對",
"transfer.debug.noPair": "尚未選定候選對",
"transfer.debug.candidatesLine":
"host {{host}} · mDNS {{mdns}} · srflx {{srflx}} · prflx {{prflx}} · relay {{relay}}",
"transfer.debug.dataChannel": "資料通道",
"transfer.debug.dcLine": "{{state}} · 待傳送 {{buffered}}",
// ---- 時間 ------------------------------------------------------------
"time.justNow": "剛剛",
"time.secondsAgo": "{{n}} 秒前",
"time.minutesAgo": "{{n}} 分鐘前",
"time.hoursAgo": "{{n}} 小時前",
// ---- 通用 ------------------------------------------------------------
"common.dismiss": "移除",
"common.cancel": "取消",
"common.confirm": "確認",
// ---- 錯誤(lib/* 中拋出,呈現到 Alert-----------------------------
"errors.messageEmpty": "訊息為空",
"errors.messageOverflow": "訊息超過4 KB;請改用檔案傳輸",
"errors.noReceiver": "未選擇接收方",
"errors.devTokenMissing":
"Dev模式:未在.env.local設定VITE_CDROP_DEV_TOKEN",
"errors.noAccessToken": "無存取權杖:使用者必須先登入",
"errors.clipboardUnavailable": "瀏覽器不支援剪貼簿API(需HTTPS與權限)",
"errors.clipboardOverflow": "內容過大,超過 {{max}} 位元組上限",
"errors.clipboardWriteFailed": "寫入本機剪貼簿失敗:{{message}}",
};
+127
View File
@@ -0,0 +1,127 @@
import "@mantine/core/styles.css";
import "@mantine/dropzone/styles.css";
import "./styles/index.css";
import React, { useEffect } from "react";
import ReactDOM from "react-dom/client";
import {
MantineProvider,
createTheme,
type MantineColorsTuple,
} from "@mantine/core";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { ToastViewport } from "./ui/feedback";
import { routeTree } from "./routeTree.gen";
import { useAppStore, type ThemeMode } from "./store";
import { startCjkAutospace } from "./utils/cjkAutospace";
import { initDesktopMenuBridge, loadDesktopSettings } from "./net/desktop";
// Mantine 需要 10 阶静态色,硬编码值与 tokens.semantic.css 的 --accent-* 阶
// 严格对齐(Dracula Purple 谱)。组件颜色仍读 var(--accent),这里仅满足
// Mantine 内部 primaryColor 索引。
//
// primaryShade = { light: 7, dark: 4 }
// - light 取 [7] = #644AC9Alucard Purple
// - dark 取 [4] = #AA99FFDracula Base BrightBluePurple 的官方亮版,
// 在深色背景上对比度 6.7:1,作链接/选中文字可读性达 AAA Large)
const accentTuple: MantineColorsTuple = [
"#F3F0FF",
"#E4DEFF",
"#C9BEFF",
"#AC9BFE",
"#AA99FF",
"#9580FF",
"#7458EE",
"#644AC9",
"#523CA8",
"#3F2D85",
];
const theme = createTheme({
primaryColor: "accent",
primaryShade: { light: 7, dark: 4 },
defaultRadius: "md",
radius: { sm: "6px", md: "10px", lg: "14px" },
fontFamily: "var(--font-sans)",
fontFamilyMonospace: "var(--font-mono)",
headings: { fontFamily: "var(--font-sans)" },
colors: { accent: accentTuple },
});
const router = createRouter({ routeTree, defaultPreload: "intent" });
// 桌面端:菜单栏「设置」→ 路由到 /settings(浏览器里为 no-op)。
initDesktopMenuBridge(() => { void router.navigate({ to: "/settings" }); });
// 桌面端:启动即加载本地配置,把剪贴板同步开关应用到门控(浏览器里为 no-op)。
void loadDesktopSettings();
// Cmd+,macOS/ Ctrl+,Windows·Linux)→ 打开设置,桌面与浏览器通用。桌面端
// 原生菜单未占用 ⌘,,故按键会落到 WebView 由此处理;未登录时 /settings 守卫会
// 自行重定向,无需额外判断。
window.addEventListener("keydown", (e) =>
{
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key === ",")
{
e.preventDefault();
void router.navigate({ to: "/settings" });
}
});
declare module "@tanstack/react-router"
{
interface Register
{
router: typeof router;
}
}
// system 模式:移除 data-theme attribute,让 tokens.semantic.css 中的
// @media (prefers-color-scheme: dark) 接管;light / dark 时写死 attribute。
function applyTheme(mode: ThemeMode): void
{
const root = document.documentElement;
if (mode === "system") { root.removeAttribute("data-theme"); }
else { root.setAttribute("data-theme", mode); }
}
// 切语言时给 RouterProvider 换 key 强制全树重挂载,让所有 t() 调用以新 locale
// 重新求值;t() 仍是纯函数,避免改造每个调用点。这样比 location.reload() 安全
// (内存里的 refresh_token 不丢)。
function App()
{
const locale = useAppStore((s) => s.locale);
const theme$ = useAppStore((s) => s.theme);
useEffect(() => { applyTheme(theme$); }, [ theme$ ]);
// Mantine 自身也维护 colorScheme(决定 [data-mantine-color-scheme])。
// light / dark 用 forceColorScheme 锁死;system 时不传 force,让其
// 内部跟随 prefers-color-scheme。store.theme 作为单一来源同步双方。
const force = theme$ === "system" ? undefined : theme$;
return (
<MantineProvider
theme={theme}
defaultColorScheme="auto"
forceColorScheme={force}
>
<ToastViewport />
<RouterProvider key={locale} router={router} />
</MantineProvider>
);
}
// 首屏在 React 挂载前先应用一次主题,避免 FOUC。桌面壳的登录态已在 store 初始化
// 时从注入的 window.__CDROP_BOOT__ 同步水合(store/helpers.ts),无需异步等待。
applyTheme(useAppStore.getState().theme);
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
// CJK 自动间距 shim:在 React 完成首次挂载后启动。MutationObserver 后续
// 增量处理 React 重渲染产生的新文本节点。放在 createRoot 之后,首帧绘制
// 完会触发一次 runShim() 处理已挂载的子树。
requestAnimationFrame(() => { startCjkAutospace(); });
+95
View File
@@ -0,0 +1,95 @@
import { useAppStore } from "../store";
import { refreshTokens } from "../features/auth/auth";
import { readInjectedApiBase, readInjectedDeviceType } from "../store/helpers";
import { toAsciiDeviceName } from "../utils/format";
import { t } from "../i18n";
// /api 请求的来源前缀:桌面 Windows 注入 loopback 代理地址(绕开会缓冲 SSE 的
// custom scheme),macOS / 浏览器为空(同源相对 URL)。读一次注入值即定。
const API_BASE = readInjectedApiBase();
// 客户端类型(browser / macos / windows / linux),随每个请求发给后端登记设备。
const DEVICE_TYPE = readInjectedDeviceType();
function withBase(input: RequestInfo): RequestInfo
{
if (typeof input === "string" && API_BASE && input.startsWith("/"))
{
return API_BASE + input;
}
return input;
}
// Centralised fetch wrapper:
// - in dev, attaches Authorization: Bearer <VITE_CDROP_DEV_TOKEN> + X-Dev-User
// - in prod, attaches Authorization: Bearer <accessToken from store>
// - always sets X-Device-Name from the store
// - in prod, on a 401 response transparently refreshes the access token via
// /api/auth/refresh and retries once
//
// Returns the fetch Response untouched so callers can drive streaming bodies
// (SSE, relay GET stream) themselves.
export async function apiFetch(input: RequestInfo, init?: RequestInit): Promise<Response>
{
let r = await doFetch(input, init);
if (r.status === 401 && useAppStore.getState().authMode === "prod")
{
const ok = await refreshTokens();
if (ok)
{
r = await doFetch(input, init);
}
}
return r;
}
async function doFetch(input: RequestInfo, init?: RequestInit): Promise<Response>
{
const state = useAppStore.getState();
const headers = new Headers(init?.headers ?? {});
if (state.authMode === "dev")
{
const devToken = import.meta.env.VITE_CDROP_DEV_TOKEN;
if (!devToken)
{
throw new Error(t("errors.devTokenMissing"));
}
headers.set("Authorization", `Bearer ${devToken}`);
if (state.user?.id)
{
headers.set("X-Dev-User", state.user.id);
}
}
else
{
if (!state.accessToken)
{
throw new Error(t("errors.noAccessToken"));
}
headers.set("Authorization", `Bearer ${state.accessToken}`);
}
if (state.selfDeviceName)
{
// Device names are ASCII-only; coerce here so a legacy non-ASCII name
// can't make Headers.set throw (the server sanitises identically).
const safeName = toAsciiDeviceName(state.selfDeviceName);
if (safeName) { headers.set("X-Device-Name", safeName); }
}
headers.set("X-Device-Type", DEVICE_TYPE);
return fetch(withBase(input), { ...init, headers });
}
export async function apiJSON<T>(input: RequestInfo, init?: RequestInit): Promise<T>
{
const r = await apiFetch(input, init);
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`${r.status}: ${text}`);
}
if (r.status === 204) { return undefined as T; }
return ( await r.json() ) as T;
}
+289
View File
@@ -0,0 +1,289 @@
// Wails 桌面壳的桥接层:仅在桌面 WebView 中可用。浏览器里 isDesktop() 返回
// false,所有调用点都回退到既有的 Web 行为,因此本模块对纯 Web 构建零影响
// ——同一份代码同时部署到浏览器与桌面。
import { useAppStore, type User } from "../store";
// 桌面会话视图:不含 refresh_token——长寿命密钥只在 Go 进程,绝不进 JS(凭据策略
// 见 desktop/platform/session.go)。登录 emit、刷新返回、启动注入都用这个形状。
interface DesktopSession
{
access_token: string;
expires_in: number;
user: { id: string; name: string; avatar?: string };
}
// Wails v2 注入的全局运行时:事件订阅在此。
interface WailsRuntime
{
EventsOn: (event: string, cb: (...data: unknown[]) => void) => () => void;
}
// 桌面本地配置(~/Library/Application Support/cdrop/config.json)。字段与 Go
// platform.DesktopConfig 的 json tag 一致。
export interface DesktopConfig
{
clipboard_sync_enabled: boolean;
launch_at_login: boolean;
download_dir: string; // 空=系统下载目录(Go 侧 ResolveDownloadDir 解析)
}
// Go 侧 App 结构体导出的方法(window.go.main.App.*)。
interface DesktopBridge
{
StartLogin: () => void;
Refresh: () => Promise<DesktopSession>;
DeviceName: () => Promise<string>;
ApplyRemoteClipboard: (content: string, sourceDevice: string) => Promise<void>;
GetSettings: () => Promise<DesktopConfig>;
SaveSettings: (cfg: DesktopConfig) => Promise<void>;
ClearSession: () => Promise<void>;
SetDeviceName: (name: string) => Promise<void>;
// data 为 base64Wails 把 JS 字符串作为 JSON 字符串编码,Go 端 []byte 参数按
// base64 解码。返回实际写入的绝对路径。
SaveDownload: (name: string, data: string) => Promise<string>;
ChooseDownloadDir: (title: string) => Promise<string>;
EffectiveDownloadDir: () => Promise<string>;
}
// 桌面剪贴板自动同步开关,默认开;由设置页通过 setClipboardSyncEnabled 调整。
// 同时门控上行(原生复制 → 上传)与下行(云端更新 → 写回原生),关闭即两端静默。
let clipboardSyncEnabled = true;
export function setClipboardSyncEnabled(enabled: boolean): void
{
clipboardSyncEnabled = enabled;
}
export function isClipboardSyncEnabled(): boolean
{
return clipboardSyncEnabled;
}
function runtime(): WailsRuntime | null
{
const rt = (window as unknown as { runtime?: WailsRuntime }).runtime;
return rt ?? null;
}
function bridge(): DesktopBridge | null
{
const go = (window as unknown as { go?: { main?: { App?: DesktopBridge } } }).go;
return go?.main?.App ?? null;
}
// isDesktop 同时要求运行时与绑定都在场,避免半初始化状态下误判。
export function isDesktop(): boolean
{
return typeof window !== "undefined" && runtime() !== null && bridge() !== null;
}
function toUser(u: DesktopSession["user"]): User
{
return { id: u.id, name: u.name, avatar: u.avatar || undefined };
}
// startDesktopLogin 触发 Go 侧 loopback PKCE 流程,等待 oauth:success / oauth:error
// 事件;成功后把 token + user 写进 store,并以主机名补齐设备名(跳过 /setup)。
export async function startDesktopLogin(): Promise<void>
{
const rt = runtime();
const app = bridge();
if (!rt || !app) { throw new Error("desktop bridge unavailable"); }
await new Promise<void>((resolve, reject) =>
{
let offOk: (() => void) | null = null;
let offErr: (() => void) | null = null;
const cleanup = () =>
{
if (offOk) { offOk(); }
if (offErr) { offErr(); }
};
offOk = rt.EventsOn("oauth:success", (...data) =>
{
cleanup();
const res = data[0] as DesktopSession;
// 不带 refreshToken:桌面的 refresh_token 只在 GoJS 永不持有。
useAppStore.getState().setAuth({
accessToken: res.access_token,
user: toUser(res.user),
});
resolve();
});
offErr = rt.EventsOn("oauth:error", (...data) =>
{
cleanup();
const detail = (data[0] as { detail?: string })?.detail ?? "登录失败";
reject(new Error(detail));
});
app.StartLogin();
});
await ensureDeviceName();
}
// ensureDeviceName 在桌面端用主机名补齐 selfDeviceName(仅当尚未命名),让路由
// 守卫不再跳转 /setup。失败静默——用户仍可在设置里手动命名。
export async function ensureDeviceName(): Promise<void>
{
const app = bridge();
if (!app) { return; }
if (useAppStore.getState().selfDeviceName) { return; }
try
{
const name = await app.DeviceName();
if (name) { useAppStore.getState().setSelfDeviceName(name); }
}
catch { /* 保留为空,回退到 /setup 手动命名 */ }
}
// initDesktopMenuBridge 把 Go 菜单栏发来的事件接到前端(如「设置」→ 路由跳转)。
// 仅桌面端生效;浏览器里 isDesktop()=false 直接返回,对 Web 零影响。
export function initDesktopMenuBridge(onSettings: () => void): void
{
const rt = runtime();
if (!isDesktop() || !rt) { return; }
rt.EventsOn("menu:settings", () => onSettings());
}
// onNativeClipboard 订阅 Go 的本机剪贴板复制事件(上行)。回调里上层决定是否
// 上传(既有 uploadClipboard 链路:鉴权 + 去重 + store)。同步关闭时不回调。
// 返回取消订阅函数;浏览器里为 no-op。
export function onNativeClipboard(handler: (text: string) => void): () => void
{
const rt = runtime();
if (!isDesktop() || !rt) { return () => { /* no-op */ }; }
return rt.EventsOn("clipboard:native", (...data) =>
{
if (!clipboardSyncEnabled) { return; }
const text = typeof data[0] === "string" ? data[0] : "";
if (text) { handler(text); }
});
}
// writeNativeClipboard 把来自其他设备的云端更新写回本机原生剪贴板(下行)。
// 仅桌面 + 同步开启 + 非空时触发;自身来源由上层与 Go 侧 Monitor 双重过滤。
export function writeNativeClipboard(content: string, sourceDevice: string): void
{
const app = bridge();
if (!isDesktop() || !app || !clipboardSyncEnabled || !content) { return; }
void app.ApplyRemoteClipboard(content, sourceDevice);
}
// loadDesktopSettings 读取本地配置并把剪贴板同步开关应用到本模块的门控。
// 返回配置供设置页初始化;浏览器 / 失败时返回 null。应在应用启动时调用一次。
export async function loadDesktopSettings(): Promise<DesktopConfig | null>
{
const app = bridge();
if (!app) { return null; }
try
{
const cfg = await app.GetSettings();
setClipboardSyncEnabled(cfg.clipboard_sync_enabled);
return cfg;
}
catch
{
return null;
}
}
// saveDesktopSettings 持久化配置并立即应用同步开关(launch-at-login 的副作用在
// Go 侧落地)。抛错交由设置页提示。
export async function saveDesktopSettings(cfg: DesktopConfig): Promise<void>
{
const app = bridge();
if (!app) { return; }
setClipboardSyncEnabled(cfg.clipboard_sync_enabled);
await app.SaveSettings(cfg);
}
// clearDesktopSession 登出时让 Go 删除持久化的 session 文件,使下次启动不再注入
// 已登录态(fire-and-forget)。浏览器里 bridge() 为 null,直接返回。
export function clearDesktopSession(): void
{
const app = bridge();
if (!app) { return; }
void app.ClearSession();
}
// persistDesktopDeviceName 让 Go 持久化重命名后的设备名(写入本地配置),使其跨
// 重启存活并随启动注入;浏览器为 no-op。fire-and-forget。
export function persistDesktopDeviceName(name: string): void
{
const app = bridge();
if (!app) { return; }
void app.SetDeviceName(name);
}
// desktopRefresh 触发 Go 内部刷新(无参——refresh_token 只在 GoJS 不传也不持有)。
// Go 用自己的 refresh_token 换新 access_tokenpublic-client,不带 secret),返回
// 不含 refresh_token 的会话视图;失败返回 null 让上层照旧清登录态。
export async function desktopRefresh(): Promise<DesktopSession | null>
{
const app = bridge();
if (!app) { return null; }
try
{
return await app.Refresh();
}
catch
{
return null;
}
}
// bytesToBase64 分块走 btoa,避免一次 String.fromCharCode(...大数组) 触发调用栈溢出。
function bytesToBase64(bytes: Uint8Array): string
{
let binary = "";
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK)
{
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
}
// saveIncomingFileDesktop 把接收到的文件交给 Go 写入配置的下载目录(绕开 WebView
// 默认下载,使路径可配置)。返回实际写入的绝对路径;桥接缺失或写盘失败时抛错,
// 由调用方回退到浏览器下载。整文件经 base64 过桥——大小折中见落盘机制选型。
export async function saveIncomingFileDesktop(name: string, blob: Blob): Promise<string>
{
const app = bridge();
if (!app) { throw new Error("desktop bridge unavailable"); }
const buf = await blob.arrayBuffer();
const b64 = bytesToBase64(new Uint8Array(buf));
return app.SaveDownload(name, b64);
}
// chooseDownloadDir 打开原生目录选择框(标题本地化由调用方传入),返回所选目录;
// 用户取消返回空串。浏览器里 bridge() 为 null,返回空串。
export async function chooseDownloadDir(title: string): Promise<string>
{
const app = bridge();
if (!app) { return ""; }
try
{
return await app.ChooseDownloadDir(title);
}
catch
{
return "";
}
}
// effectiveDownloadDir 返回当前实际落盘目录(配置覆盖或系统默认),供设置页显示。
export async function effectiveDownloadDir(): Promise<string>
{
const app = bridge();
if (!app) { return ""; }
try
{
return await app.EffectiveDownloadDir();
}
catch
{
return "";
}
}
+113
View File
@@ -0,0 +1,113 @@
import { apiFetch } from "./api";
export interface SseEvent
{
type: string;
data: unknown;
}
export type SseHandler = (event: SseEvent) => void;
// connectSSE establishes a long-lived /api/hub/events stream and pumps every
// parsed frame to onEvent. Resolves only when the body ends or signal aborts.
//
// Why not EventSource: native EventSource can't attach Authorization or
// X-Device-Name headers, both of which are mandatory (PROJECT_BRIEF.md §2 / §5).
// We use fetch + ReadableStream + TextDecoder({stream:true}) + '\n\n' framing,
// matching FRONTEND_DESIGN.md §8.
export async function connectSSE(
url: string,
onEvent: SseHandler,
signal?: AbortSignal,
onOpen?: () => void,
): Promise<void>
{
const resp = await apiFetch(url, {
method: "GET",
cache: "no-store",
signal,
});
if (!resp.ok)
{
throw new Error(`SSE ${resp.status}: ${resp.statusText}`);
}
if (!resp.body)
{
throw new Error("SSE: response has no body");
}
// The stream is established (status + body ready). Signal "connected" now,
// before any frame: a freshly-connected client may not receive its first
// event for a while (it only gets `presence` when the device set changes),
// and on Windows the first frame can sit in a downstream buffer. The
// connection itself is what the indicator should reflect.
onOpen?.();
const reader = resp.body.getReader();
const decoder = new TextDecoder("utf-8");
let buf = "";
try
{
while (true)
{
const { value, done } = await reader.read();
if (done) { break; }
buf += decoder.decode(value, { stream: true });
let sep = buf.indexOf("\n\n");
while (sep !== -1)
{
const raw = buf.substring(0, sep);
buf = buf.substring(sep + 2);
const ev = parseFrame(raw);
if (ev) { onEvent(ev); }
sep = buf.indexOf("\n\n");
}
}
}
finally
{
try { reader.cancel(); } catch { /* fine */ }
}
}
// parseFrame interprets one '\n\n'-terminated SSE block.
// Lines starting with ':' are comments (server's `: hi` probe lands here).
// Multiple `data:` lines are concatenated with '\n' per the SSE spec.
// JSON `data` is auto-decoded; anything else is passed through as a string.
function parseFrame(raw: string): SseEvent | null
{
let event = "message";
let data = "";
for (const line of raw.split("\n"))
{
if (line.length === 0 || line.startsWith(":")) { continue; }
const colon = line.indexOf(":");
if (colon === -1) { continue; }
const field = line.substring(0, colon);
const value = line.substring(colon + 1).trimStart();
if (field === "event") { event = value; }
else if (field === "data")
{
data = data.length === 0 ? value : `${data}\n${value}`;
}
}
if (event === "message" && data.length === 0)
{
return null;
}
let parsed: unknown = data;
if (data.length > 0 && (data[0] === "{" || data[0] === "["))
{
try { parsed = JSON.parse(data); }
catch { /* leave as string */ }
}
return { type: event, data: parsed };
}
+368
View File
@@ -0,0 +1,368 @@
import {
AppShell,
Group,
Menu,
Stack,
Text,
Title,
UnstyledButton,
} from "@mantine/core";
import { Outlet, Link, createRootRoute, useLocation, useNavigate } from "@tanstack/react-router";
import { Check, ChevronDown, LogOut, Monitor, Moon, Settings, Sun, Wifi, WifiOff } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { useAppStore, type ThemeMode } from "../store";
import { logout } from "../features/auth/auth";
import { uploadClipboard } from "../features/clipboard/clipboard";
import { onNativeClipboard } from "../net/desktop";
import { startHub } from "../features/transfer/hub";
import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers";
import { t, type Locale } from "../i18n";
import { Callout, IconButton, Tooltip } from "../ui/primitives";
import { Wordmark } from "../ui/brand";
export const Route = createRootRoute({ component: RootLayout });
function RootLayout()
{
const user = useAppStore((s) => s.user);
const selfDeviceName = useAppStore((s) => s.selfDeviceName);
const sseConnected = useAppStore((s) => s.sseConnected);
const sseReconnecting = useAppStore((s) => s.sseReconnecting);
const locale = useAppStore((s) => s.locale);
const setLocale = useAppStore((s) => s.setLocale);
const theme = useAppStore((s) => s.theme);
const setTheme = useAppStore((s) => s.setTheme);
const navigate = useNavigate();
const location = useLocation();
// 登录 / 首次设置 / OAuth 回调使用全屏 AuthShell(由各路由自渲染),
// 这里只渲染 <Outlet/>,不包 AppShell,从而隐去顶栏。
const fullscreen = isAuthRoute(location.pathname);
// SSE 在登录后立即开启;user.id 或 selfDeviceName 任一变化即重连
// (改名后会自动以新设备名重新注册)。
useEffect(() =>
{
if (!user || !selfDeviceName) { return; }
const ctrl = new AbortController();
startHub(ctrl.signal);
// 预取 Cloudflare TURN 凭据(或 STUN 回退),首次 WebRTC 同步可用。
void refreshICEServers();
// 桌面:本机复制 → 上传云端(复用 uploadClipboard 链路;浏览器里为 no-op)。
// 上传失败静默——不打断用户,下次复制再试。
const offNativeClipboard = onNativeClipboard((text) =>
{
void uploadClipboard(text).catch(() => { /* ignore */ });
});
return () =>
{
ctrl.abort();
stopICEServerRefresh();
offNativeClipboard();
};
}, [ user?.id, selfDeviceName ]);
const handleLogout = () =>
{
logout();
navigate({ to: "/login", search: { dev_user: undefined } });
};
if (fullscreen) { return <Outlet />; }
return (
<AppShell header={{ height: 56 }} padding="md">
<AppShell.Header>
<Group h="100%" px="md" justify="space-between" wrap="nowrap" gap="xs">
<Link
to="/"
style={{
textDecoration: "none",
color: "inherit",
minWidth: 0,
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
}}
>
<Title
order={4}
fw={600}
style={{ whiteSpace: "nowrap", fontSize: "var(--fs-22)" }}
>
<Wordmark />
</Title>
</Link>
{user && (
<Group gap={8} wrap="nowrap">
<ConnectionIcon connected={sseConnected} />
<ThemeQuickToggle theme={theme} onChange={setTheme} />
<Menu position="bottom-end" width={240} withArrow shadow="md">
<Menu.Target>
<UnstyledButton
aria-label={t("nav.accountMenu")}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "4px 6px",
borderRadius: 6,
}}
>
<UserAvatar
name={user.name}
avatarUrl={user.avatar}
size={28}
/>
<Text size="sm" visibleFrom="sm" truncate maw={140}>
{user.name}
</Text>
<ChevronDown size={14} />
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Stack gap={2} px="sm" pt="xs" pb={6}>
<Text size="sm" fw={500} truncate>{user.name}</Text>
<Text size="xs" c="dimmed">
{t("nav.thisDeviceLabel")}
<Text span fw={500}>{selfDeviceName}</Text>
</Text>
<Text
size="xs"
style={{ color: sseConnected ? "var(--state-online)" : "var(--text-muted)" }}
>
{sseConnected
? `${t("app.connected")}`
: `${t("app.connecting")}`}
</Text>
</Stack>
<Menu.Divider />
<Menu.Label>{t("nav.theme")}</Menu.Label>
{/* System 列于最上 — 默认跟随系统,最常用。 */}
<ThemeItem
mode="system"
label={t("nav.theme.system")}
icon={<Monitor size={14} />}
current={theme}
onSelect={setTheme}
/>
<ThemeItem
mode="light"
label={t("nav.theme.light")}
icon={<Sun size={14} />}
current={theme}
onSelect={setTheme}
/>
<ThemeItem
mode="dark"
label={t("nav.theme.dark")}
icon={<Moon size={14} />}
current={theme}
onSelect={setTheme}
/>
<Menu.Divider />
<Menu.Item
leftSection={<Settings size={14} />}
onClick={() => navigate({ to: "/settings" })}
>
{t("nav.settings")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>{t("nav.language")}</Menu.Label>
<LocaleItem
locale="zh-CN"
label="简体中文"
current={locale}
onSelect={setLocale}
/>
<LocaleItem
locale="zh-TW"
label="繁體中文"
current={locale}
onSelect={setLocale}
/>
<LocaleItem
locale="en-US"
label="English"
current={locale}
onSelect={setLocale}
/>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<LogOut size={14} />}
onClick={handleLogout}
>
{t("nav.signOut")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
)}
</Group>
</AppShell.Header>
<AppShell.Main>
{sseReconnecting && (
<div style={{ marginBottom: "var(--space-4)" }}>
<Callout
tone="error"
icon={<WifiOff size={16} />}
title={t("app.disconnectedTitle")}
>
{t("app.disconnectedHint")}
</Callout>
</div>
)}
<Outlet />
</AppShell.Main>
</AppShell>
);
}
// Header 上的快捷按钮:单击在三态间循环 system → light → dark → system。
// 图标随当前模式切换;hover 显示 tooltip 提示下一步会切到什么。
function ThemeQuickToggle(props: { theme: ThemeMode; onChange: (m: ThemeMode) => void })
{
const { theme, onChange } = props;
const next: ThemeMode = theme === "system" ? "light" : theme === "light" ? "dark" : "system";
const icon = theme === "light" ? <Sun size={16} />
: theme === "dark" ? <Moon size={16} />
: <Monitor size={16} />;
const label = `${t("nav.themeToggle")}${t(`nav.theme.${theme}` as const)}${t(`nav.theme.${next}` as const)}`;
return (
<Tooltip label={label}>
<IconButton
aria-label={label}
size="sm"
onClick={() => onChange(next)}
>
{icon}
</IconButton>
</Tooltip>
);
}
function ThemeItem(props: {
mode: ThemeMode;
label: string;
icon: ReactNode;
current: ThemeMode;
onSelect: (m: ThemeMode) => void;
})
{
const { mode, label, icon, current, onSelect } = props;
const active = mode === current;
return (
<Menu.Item
leftSection={active
? <Check size={14} />
: <span style={{ display: "inline-block", width: 14 }} />}
rightSection={icon}
onClick={() => { if (!active) { onSelect(mode); } }}
>
{label}
</Menu.Item>
);
}
function LocaleItem(props: {
locale: Locale;
label: string;
current: Locale;
onSelect: (l: Locale) => void;
})
{
const { locale, label, current, onSelect } = props;
const active = locale === current;
return (
<Menu.Item
leftSection={active
? <Check size={14} />
: <span style={{ display: "inline-block", width: 14 }} />}
onClick={() => { if (!active) { onSelect(locale); } }}
>
{label}
</Menu.Item>
);
}
function ConnectionIcon(props: { connected: boolean })
{
const { connected } = props;
const color = connected ? "var(--state-online)" : "var(--text-muted)";
const label = connected ? t("app.connected") : t("app.connecting");
return (
<span
aria-label={label}
title={label}
style={{ display: "inline-flex", alignItems: "center" }}
>
{connected
? <Wifi size={16} color={color} />
: <WifiOff size={16} color={color} />}
</span>
);
}
// 这些路由自己撑满 viewportroot layout 不再套 AppShell。
function isAuthRoute(pathname: string): boolean
{
return pathname === "/login"
|| pathname === "/setup"
|| pathname.startsWith("/oauth/");
}
function initial(name: string): string
{
const trimmed = name.trim();
if (!trimmed) { return "?"; }
// 取首个码点(兼容 emoji / 多字节中文姓名)。
const first = Array.from(trimmed)[0];
return first.toUpperCase();
}
// 头像:优先 Casdoor 提供的图(user.avatar)。URL 缺失或加载失败时回退到
// 字母方块 —— 浅色主题白底黑字、深色主题黑底白字(与 Dracula 调色对比最强)。
function UserAvatar(props: { name: string; avatarUrl?: string; size?: number })
{
const { name, avatarUrl, size = 28 } = props;
const [ broken, setBroken ] = useState(false);
const fontSize = Math.round(size * 0.42);
const baseStyle: React.CSSProperties = {
width: size,
height: size,
borderRadius: "50%",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
flexShrink: 0,
userSelect: "none",
};
if (avatarUrl && !broken)
{
return (
<img
src={avatarUrl}
alt=""
referrerPolicy="no-referrer"
onError={() => setBroken(true)}
style={{ ...baseStyle, objectFit: "cover" }}
/>
);
}
// 字母方块:颜色由语义令牌驱动,主题切换时自然反转。
const letterStyle: React.CSSProperties = {
...baseStyle,
background: "var(--avatar-bg)",
color: "var(--avatar-fg)",
fontFamily: "var(--font-sans)",
fontWeight: 600,
fontSize,
lineHeight: 1,
letterSpacing: 0,
};
return <span style={letterStyle} aria-label={name}>{initial(name)}</span>;
}
+44
View File
@@ -0,0 +1,44 @@
.page
{
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.greeting
{
margin: 0;
font-size: var(--fs-22);
font-weight: 600;
color: var(--text);
line-height: var(--lh-title);
letter-spacing: -0.01em;
}
.dualColumn
{
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: var(--space-4);
align-items: start; /* 两栏各自高度,避免短卡片被拉伸导致内部底部按钮"飘"出可视边界 */
}
.composeCol,
.clipboardCol
{
display: flex;
min-width: 0; /* 子卡片 panel 长内容不撑破 grid 列宽 */
}
.composeCol > *,
.clipboardCol > *
{
flex: 1;
width: 100%;
min-width: 0;
}
@media (max-width: 768px)
{
.dualColumn { grid-template-columns: 1fr; }
}
+122
View File
@@ -0,0 +1,122 @@
import { useEffect, useMemo } from "react";
import { Container } from "@mantine/core";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useAppStore } from "../store";
import { DeviceStrip } from "../features/devices/DeviceStrip";
import { Composer } from "../features/transfer/Composer";
import { ActivityFeed } from "../features/transfer/ActivityFeed";
import { ClipboardPanel } from "../features/clipboard/ClipboardPanel";
import { MessageList } from "../features/messaging/MessageList";
import { startOutgoingTransfer } from "../features/transfer/transfer";
import { sendMessage } from "../features/messaging/messaging";
import { fetchClipboard, CLIPBOARD_MAX_BYTES } from "../features/clipboard/clipboard";
import { t } from "../i18n";
import { toast } from "../ui/feedback";
import s from "./index.module.css";
export const Route = createFileRoute("/")({
component: HomePage,
beforeLoad: () =>
{
const { user, selfDeviceName } = useAppStore.getState();
if (!user) { throw redirect({ to: "/login", search: { dev_user: undefined } }); }
if (!selfDeviceName) { throw redirect({ to: "/setup" }); }
},
});
// 在 hint 文案里直接展示;保留 import 以让 lib/clipboard 字节上限保持唯一来源。
void CLIPBOARD_MAX_BYTES;
function HomePage()
{
const user = useAppStore((s) => s.user);
const devices = useAppStore((s) => s.devices);
const selfDeviceName = useAppStore((s) => s.selfDeviceName);
const activeTransfers = useAppStore((s) => s.activeTransfers);
const history = useAppStore((s) => s.history);
const messages = useAppStore((s) => s.messages);
const clipboard = useAppStore((s) => s.clipboard);
const selectedDevice = useAppStore((s) => s.selectedDevice);
const selectedFile = useAppStore((s) => s.selectedFile);
const setSelectedDevice = useAppStore((s) => s.setSelectedDevice);
const setSelectedFile = useAppStore((s) => s.setSelectedFile);
const removeMessage = useAppStore((s) => s.removeMessage);
const clearMessages = useAppStore((s) => s.clearMessages);
// 进页面拉一次最新(带 If-None-Match)。SSE 实时推送在 hub.ts 里处理。
useEffect(() => { void fetchClipboard().catch(() => {}); }, []);
const activeList = useMemo(() => Object.values(activeTransfers), [ activeTransfers ]);
const handleSendFile = async () =>
{
if (!selectedDevice || !selectedFile) { return; }
try
{
await startOutgoingTransfer(selectedDevice, selectedFile);
setSelectedFile(null);
}
catch (e)
{
toast.error("文件发送失败", e instanceof Error ? e.message : String(e));
}
};
const handleSendMessage = async (text: string) =>
{
if (!selectedDevice) { throw new Error(t("home.cta.selectDevice")); }
await sendMessage(selectedDevice, text);
};
// 拖文件直接到设备卡:跳过 composer,临时切换 target 后立刻发起。
const handleDropFile = async (deviceName: string, file: File) =>
{
setSelectedDevice(deviceName);
try { await startOutgoingTransfer(deviceName, file); }
catch (e)
{
toast.error("文件发送失败", e instanceof Error ? e.message : String(e));
}
};
return (
<Container size="xl" py="lg">
<div className={s.page}>
<h1 className={s.greeting}>
{t("home.greeting", { name: user?.name ?? "" })}
</h1>
<DeviceStrip
devices={devices}
selfDeviceName={selfDeviceName}
selectedDevice={selectedDevice}
onSelect={setSelectedDevice}
onDropFile={handleDropFile}
/>
<div className={s.dualColumn}>
<div className={s.composeCol}>
<Composer
targetDevice={selectedDevice}
selectedFile={selectedFile}
onFileChange={setSelectedFile}
onSendFile={handleSendFile}
onSendMessage={handleSendMessage}
/>
</div>
<div className={s.clipboardCol}>
<ClipboardPanel state={clipboard} selfDevice={selfDeviceName} />
</div>
</div>
<MessageList
messages={messages}
onDismiss={removeMessage}
onClearAll={clearMessages}
/>
<ActivityFeed active={activeList} history={history} />
</div>
</Container>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertTriangle, LogIn } from "lucide-react";
import { useEffect, useState } from "react";
import { fetchAuthConfig, loginDev, loginProd } from "../features/auth/auth";
import { isDesktop, startDesktopLogin } from "../net/desktop";
import { AuthShell } from "../features/auth/AuthShell";
import { useAppStore } from "../store";
import { t } from "../i18n";
import { Button, Callout, TextField } from "../ui/primitives";
export const Route = createFileRoute("/login")({
component: LoginPage,
validateSearch: (search: Record<string, unknown>) =>
({
dev_user: typeof search.dev_user === "string" ? search.dev_user : undefined,
}),
});
function LoginPage()
{
const navigate = useNavigate();
const search = Route.useSearch();
const localAuthMode = useAppStore((s) => s.authMode);
const [ devUser, setDevUser ] = useState(search.dev_user ?? "");
const [ error, setError ] = useState<string | null>(null);
// 前端编译期推断的 mode(DEV/PROD)可能与后端实际部署不一致
// (例如把 prod 构建打到 dev 后端测试),因此以服务器返回的为准。
const [ serverMode, setServerMode ] = useState<"dev" | "prod" | null>(null);
useEffect(() =>
{
let cancelled = false;
fetchAuthConfig().then(
(cfg) => { if (!cancelled) { setServerMode(cfg.auth_mode); } },
() => { /* 回退到编译期 mode */ },
);
return () => { cancelled = true; };
}, []);
const effectiveMode = serverMode ?? localAuthMode;
const handleDevLogin = () =>
{
try
{
const params = new URLSearchParams();
if (devUser) { params.set("dev_user", devUser); }
loginDev(params);
navigate({ to: "/" });
}
catch (e)
{
setError(e instanceof Error ? e.message : String(e));
}
};
const handleProdLogin = async () =>
{
try
{
// 桌面端走 Go loopback PKCE 桥(浏览器重定向式登录不适用于 WebView);
// 成功后由 startDesktopLogin 写好 token + 设备名,这里只负责跳首页。
if (isDesktop())
{
await startDesktopLogin();
navigate({ to: "/" });
}
else
{
await loginProd();
}
}
catch (e) { setError(e instanceof Error ? e.message : String(e)); }
};
return (
<AuthShell
title={t("login.title")}
subtitle="在你的多台设备之间快速传输文件、消息与剪贴板。"
footer={`© ${new Date().getFullYear()} Commilitia Drop`}
>
{error && (
<Callout tone="error" icon={<AlertTriangle size={16} />}>
{error}
</Callout>
)}
{effectiveMode === "dev"
? (
<>
<Callout tone="warn">
{t("login.dev.help")}
</Callout>
<TextField
label={t("login.dev.field")}
placeholder="alice"
value={devUser}
onChange={(e) => setDevUser(e.currentTarget.value)}
onKeyDown={(e) =>
{
if (e.key === "Enter") { e.preventDefault(); handleDevLogin(); }
}}
/>
<Button
variant="primary"
size="lg"
block
leftIcon={<LogIn size={16} />}
onClick={handleDevLogin}
>
{t("login.dev.continue")}
</Button>
</>
)
: (
<>
<p data-jz-level="paragraph" style={{
margin: 0,
fontSize: "var(--fs-13)",
lineHeight: "var(--lh-body)",
color: "var(--text-muted)",
}}>
{t("login.prod.help")}
</p>
<Button
variant="primary"
size="lg"
block
leftIcon={<LogIn size={16} />}
onClick={handleProdLogin}
>
{t("login.prod.button")}
</Button>
</>
)}
</AuthShell>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { Loader } from "@mantine/core";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertTriangle } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { completeOAuthLogin } from "../features/auth/auth";
import { AuthShell } from "../features/auth/AuthShell";
import { t } from "../i18n";
import { Callout } from "../ui/primitives";
export const Route = createFileRoute("/oauth/callback")({
component: CallbackPage,
validateSearch: (search: Record<string, unknown>) =>
({
code: typeof search.code === "string" ? search.code : undefined,
state: typeof search.state === "string" ? search.state : undefined,
error: typeof search.error === "string" ? search.error : undefined,
error_description: typeof search.error_description === "string"
? search.error_description
: undefined,
}),
});
function CallbackPage()
{
const search = Route.useSearch();
const navigate = useNavigate();
const [ err, setErr ] = useState<string | null>(null);
// ref guardcompleteOAuthLogin 内部会消费 sessionStorage 中的 PKCE_VERIFIER /
// OAUTH_STATE,二次进入必然 throw "state mismatch / verifier missing",在
// navigate(/) 真正完成之前的那一帧引发"登录失败"闪现。
//
// navigate 是异步的:promise resolve → setErr/navigatenavigate 实际生效之间
// 仍可能发生一次 re-renderuseNavigate 返回值在某些版本里非稳定引用,会让
// useEffect deps 抖动重跑)。ref guard 杜绝逻辑重入,cancelled flag 兜底防止
// unmount 后 setErr。
const startedRef = useRef(false);
useEffect(() =>
{
if (startedRef.current) { return; }
startedRef.current = true;
let cancelled = false;
// 把 setErr 推迟一拍:navigate({to:"/"}) 是异步的,promise resolve
// 后 callback 真正 unmount 前可能渲染一帧 err 状态(即使 success path
// 不该走这里,但 setAuth → store 更新 → 父级 re-render → useNavigate
// 引用波动 → effect 二度入场是过去的真实路径,残留闪现)。
//
// 失败时延迟 800ms 显示,给 success 路径足够时间触发卸载;真正失败
// 800ms 内 cleanup 不会被调用,setErr 仍能渲染失败态。
const showErrDeferred = (msg: string) =>
{
console.error("[oauth/callback]", msg);
window.setTimeout(() =>
{
if (!cancelled) { setErr(msg); }
}, 800);
};
if (search.error)
{
showErrDeferred(`${search.error}${search.error_description ?? ""}`);
return;
}
if (!search.code || !search.state)
{
showErrDeferred(t("oauth.missingParams"));
return;
}
completeOAuthLogin(search.code, search.state).then(
() => { if (!cancelled) { navigate({ to: "/" }); } },
(e) => { showErrDeferred(e instanceof Error ? e.message : String(e)); },
);
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅在 mount 时跑一次;search 由 URL 决定,mount 时即定。
if (err)
{
return (
<AuthShell title={t("oauth.failed")} hideBrand>
<Callout tone="error" icon={<AlertTriangle size={16} />}>{err}</Callout>
</AuthShell>
);
}
return (
<AuthShell title={t("oauth.signingIn")} subtitle="正在向 Casdoor 交换访问令牌…" hideBrand>
<div style={{ display: "flex", justifyContent: "center", padding: "var(--space-4) 0" }}>
<Loader color="var(--accent)" />
</div>
</AuthShell>
);
}
+314
View File
@@ -0,0 +1,314 @@
import { useMemo, useState } from "react";
import {
Anchor,
Container,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
import { LogOut, Trash2 } from "lucide-react";
import { logout } from "../features/auth/auth";
import { DesktopSettings } from "../features/desktop/DesktopSettings";
import { isDesktop, persistDesktopDeviceName } from "../net/desktop";
import { apiFetch } from "../net/api";
import { useAppStore, type DeviceInfo } from "../store";
import { t } from "../i18n";
import { formatRelative, isAsciiDeviceName } from "../utils/format";
import { Badge, Button, Panel, TextField } from "../ui/primitives";
import { StateDot } from "../ui/glyphs";
// deviceTypeLabel 把后端存的设备类型本地化展示(浏览器 / macOS 客户端 / …)。
// 容错匹配(与 DeviceStrip 的 mapKind 一致),未知类型回退原值。
function deviceTypeLabel(type: string): string
{
const k = type.toLowerCase();
if (k.includes("mac") || k === "darwin") { return t("deviceType.macos"); }
if (k.includes("win")) { return t("deviceType.windows"); }
if (k.includes("linux")) { return t("deviceType.linux"); }
if (k === "shortcut") { return t("deviceType.shortcut"); }
if (k.includes("ios") || k === "iphone" || k === "ipad") { return t("deviceType.ios"); }
if (k === "browser") { return t("deviceType.browser"); }
return type;
}
import { toast } from "../ui/feedback";
export const Route = createFileRoute("/settings")({
component: SettingsPage,
beforeLoad: () =>
{
const { user, selfDeviceName } = useAppStore.getState();
if (!user) { throw redirect({ to: "/login", search: { dev_user: undefined } }); }
if (!selfDeviceName) { throw redirect({ to: "/setup" }); }
},
});
function SettingsPage()
{
const navigate = useNavigate();
const user = useAppStore((s) => s.user);
const selfDeviceName = useAppStore((s) => s.selfDeviceName);
const setSelfDeviceName = useAppStore((s) => s.setSelfDeviceName);
const storeDevices = useAppStore((s) => s.devices);
const [ pendingName, setPendingName ] = useState(selfDeviceName ?? "");
const [ removing, setRemoving ] = useState<Set<string>>(new Set());
const [ renaming, setRenaming ] = useState(false);
const peerDevices = useMemo(
() => storeDevices.filter((d) => d.name !== selfDeviceName),
[ storeDevices, selfDeviceName ],
);
const trimmedName = pendingName.trim();
const canSaveName = !!trimmedName && trimmedName !== selfDeviceName;
const handleRename = async () =>
{
if (!selfDeviceName) { return; }
if (!trimmedName) { toast.warn(t("settings.deviceName.empty")); return; }
if (!isAsciiDeviceName(trimmedName))
{
toast.warn(t("settings.deviceName.asciiOnly"));
return;
}
if (trimmedName === selfDeviceName)
{
toast.warn(t("settings.deviceName.unchanged"));
return;
}
setRenaming(true);
try
{
// 先在后端把旧设备记录删掉。Hub.Kick 会强制关闭旧的 SSE
// 紧接着 root effect 检测到 selfDeviceName 变化会以新名重新注册。
// 404(旧记录已被 sweeper 清理)也按成功处理。
const r = await apiFetch(
`/api/devices/${encodeURIComponent(selfDeviceName)}`,
{ method: "DELETE" },
);
if (!r.ok && r.status !== 404)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`${r.status}: ${text}`);
}
setSelfDeviceName(trimmedName);
persistDesktopDeviceName(trimmedName); // 桌面:持久化到 Go 配置,跨重启存活
toast.ok(t("settings.deviceName.success"));
}
catch (e)
{
toast.error(t("settings.error.delete", {
message: e instanceof Error ? e.message : String(e),
}));
}
finally
{
setRenaming(false);
}
};
const handleRemovePeer = async (name: string) =>
{
if (!window.confirm(t("settings.unregister.peerConfirm", { name }))) { return; }
setRemoving((prev) =>
{
const next = new Set(prev);
next.add(name);
return next;
});
try
{
const r = await apiFetch(
`/api/devices/${encodeURIComponent(name)}`,
{ method: "DELETE" },
);
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`${r.status}: ${text}`);
}
// 服务端的 publishPresence 会通过 SSE 推送新的设备列表,store
// 会随之更新,这里不需要手动 setDevices。
toast.ok("设备已移除", name);
}
catch (e)
{
toast.error(t("settings.error.delete", {
message: e instanceof Error ? e.message : String(e),
}));
}
finally
{
setRemoving((prev) =>
{
const next = new Set(prev);
next.delete(name);
return next;
});
}
};
const handleUnregisterSelf = async () =>
{
if (!selfDeviceName) { return; }
if (!window.confirm(t("settings.unregister.currentConfirm"))) { return; }
try
{
await apiFetch(
`/api/devices/${encodeURIComponent(selfDeviceName)}`,
{ method: "DELETE" },
);
}
catch
{
// 即便 DELETE 失败也按用户意图清理本地并跳转登录。
}
finally
{
setSelfDeviceName(null);
logout();
navigate({ to: "/login", search: { dev_user: undefined } });
}
};
const handleSignOut = () =>
{
logout();
navigate({ to: "/login", search: { dev_user: undefined } });
};
return (
<Container size="sm" py="lg">
<Stack gap="lg">
<Group justify="space-between" align="center" wrap="nowrap">
<Title order={2}>{t("settings.title")}</Title>
<Anchor component={Link} to="/" size="sm">
{t("nav.backToHome")}
</Anchor>
</Group>
<Panel title={t("settings.currentDevice.title")}>
<Stack gap="sm">
<TextField
label={t("settings.deviceName.field")}
value={pendingName}
onChange={(e) => setPendingName(e.currentTarget.value)}
onKeyDown={(e) =>
{
if (e.key === "Enter" && canSaveName)
{
e.preventDefault();
void handleRename();
}
}}
/>
<Group>
<Button
variant="primary"
onClick={handleRename}
disabled={!canSaveName}
loading={renaming}
>
{t("settings.deviceName.save")}
</Button>
</Group>
<div style={{ height: 1, background: "var(--divider)" }} />
<Text size="sm" c="dimmed">
{t("settings.unregister.currentHint")}
</Text>
<Group>
<Button
variant="danger"
leftIcon={<Trash2 size={14} />}
onClick={handleUnregisterSelf}
>
{t("settings.unregister.current")}
</Button>
</Group>
</Stack>
</Panel>
{isDesktop() && <DesktopSettings />}
<Panel title={t("settings.peers.title")}>
{peerDevices.length === 0 ? (
<Text size="sm" c="dimmed">{t("settings.peers.empty")}</Text>
) : (
<Stack gap="xs">
{peerDevices.map((d) => (
<PeerRow
key={d.name}
device={d}
removing={removing.has(d.name)}
onRemove={() => handleRemovePeer(d.name)}
/>
))}
</Stack>
)}
</Panel>
<Panel title={t("settings.account.title")}>
<Stack gap="sm">
{user && (
<Stack gap={2}>
<Text size="sm" fw={500} truncate>{user.name}</Text>
{user.id !== user.name && (
<Text size="xs" c="dimmed" truncate>{user.id}</Text>
)}
</Stack>
)}
<Group>
<Button
variant="ghost"
leftIcon={<LogOut size={14} />}
onClick={handleSignOut}
>
{t("settings.account.signOut")}
</Button>
</Group>
</Stack>
</Panel>
</Stack>
</Container>
);
}
function PeerRow(props: { device: DeviceInfo; removing: boolean; onRemove: () => void })
{
const { device, removing, onRemove } = props;
const lastSeenText = device.lastSeen
? t("settings.lastSeen", { time: formatRelative(device.lastSeen) })
: t("settings.lastSeenNever");
return (
<Panel variant="sunken" padding="tight">
<Group justify="space-between" wrap="nowrap" align="center">
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
<Group gap={6} wrap="nowrap">
<StateDot tone={device.online ? "online" : "offline"} />
<Text fw={500} size="sm" truncate>{device.name}</Text>
<Badge tone={device.online ? "online" : "neutral"}>
{t(device.online ? "settings.online" : "settings.offline")}
</Badge>
</Group>
<Text size="xs" c="dimmed" truncate>
{deviceTypeLabel(device.type)} · {lastSeenText}
</Text>
</Stack>
<Button
size="sm"
variant="ghost"
loading={removing}
onClick={onRemove}
style={{ color: "var(--state-error)" }}
>
{removing ? t("settings.peers.removing") : t("settings.peers.remove")}
</Button>
</Group>
</Panel>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { Check, Monitor } from "lucide-react";
import { useState } from "react";
import { AuthShell } from "../features/auth/AuthShell";
import { persistDesktopDeviceName } from "../net/desktop";
import { useAppStore } from "../store";
import { t } from "../i18n";
import { isAsciiDeviceName } from "../utils/format";
import { toast } from "../ui/feedback";
import { Button, TextField } from "../ui/primitives";
export const Route = createFileRoute("/setup")({
component: SetupPage,
beforeLoad: () =>
{
const { user } = useAppStore.getState();
if (!user) { throw redirect({ to: "/login", search: { dev_user: undefined } }); }
},
});
// 首次为本机命名。后端 middleware 在首次认证请求时 UPSERT 进 devices 表,
// 因此这里只负责把名字写进 localStorage,让路由守卫不再跳转到此页。
function SetupPage()
{
const navigate = useNavigate();
const setSelfDeviceName = useAppStore((s) => s.setSelfDeviceName);
const [ name, setName ] = useState(suggestDefault());
const handleSave = () =>
{
const trimmed = name.trim();
if (!trimmed) { return; }
if (!isAsciiDeviceName(trimmed))
{
toast.warn(t("settings.deviceName.asciiOnly"));
return;
}
setSelfDeviceName(trimmed);
persistDesktopDeviceName(trimmed); // 桌面:持久化到 Go 配置,跨重启存活
navigate({ to: "/" });
};
return (
<AuthShell
title={t("setup.title")}
subtitle={t("setup.help")}
hideBrand
footer={
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<Monitor size={12} />
</span>
}
>
<TextField
label={t("setup.field")}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
onKeyDown={(e) =>
{
if (e.key === "Enter" && name.trim())
{
e.preventDefault();
handleSave();
}
}}
autoFocus
/>
<Button
variant="primary"
size="lg"
block
leftIcon={<Check size={16} />}
onClick={handleSave}
disabled={!name.trim()}
>
{t("setup.save")}
</Button>
</AuthShell>
);
}
function suggestDefault(): string
{
const ua = navigator.userAgent;
let browser = "Browser";
if (ua.includes("Firefox/")) { browser = "Firefox"; }
else if (ua.includes("Edg/")) { browser = "Edge"; }
else if (ua.includes("Chrome/")) { browser = "Chrome"; }
else if (ua.includes("Safari/")) { browser = "Safari"; }
let os = "Desktop";
if (ua.includes("Mac")) { os = "macOS"; }
else if (ua.includes("Windows")) { os = "Windows"; }
else if (ua.includes("Linux")) { os = "Linux"; }
return `${browser}-${os}-${Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, "0")}`;
}
+242
View File
@@ -0,0 +1,242 @@
import { createFileRoute } from "@tanstack/react-router";
import { Container, Group, Stack, Text, Title } from "@mantine/core";
import { Check, Cloud, Download, Send, Settings, Trash2, Upload } from "lucide-react";
import { useState } from "react";
import {
Badge,
Button,
IconButton,
Panel,
Tabs,
TextArea,
TextField,
Tooltip,
} from "../ui/primitives";
import { DeviceTypeIcon, StateDot } from "../ui/glyphs";
import type { DeviceKind } from "../ui/glyphs";
import type { ThemeMode } from "../store";
import { useAppStore } from "../store";
import { toast } from "../ui/feedback";
export const Route = createFileRoute("/styleguide")({ component: StyleGuide });
// 临时路由:M2 验收页。展示所有原子组件在 light / dark 主题下的表现。
function StyleGuide()
{
const theme = useAppStore((s) => s.theme);
const setTheme = useAppStore((s) => s.setTheme);
const [ tab, setTab ] = useState<"file" | "message">("file");
return (
<Container size="lg" py="lg">
<Stack gap="lg">
<Group justify="space-between" align="baseline">
<Title order={3} fw={600}>cdrop · UI </Title>
<Group gap="xs">
<Text size="xs" c="dimmed"></Text>
<Tabs<ThemeMode>
items={[
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" },
]}
value={theme}
onChange={setTheme}
aria-label="theme switch"
/>
</Group>
</Group>
{/* ---- Button variants × sizes ---- */}
<Panel title="Button">
<Stack gap="sm">
<Group gap="sm" wrap="wrap">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="danger">Danger</Button>
<Button variant="primary" disabled>Disabled</Button>
<Button variant="primary" loading>Loading</Button>
</Group>
<Group gap="sm" wrap="wrap">
<Button variant="primary" size="sm">Small</Button>
<Button variant="primary" size="md">Medium</Button>
<Button variant="primary" size="lg">Large</Button>
</Group>
<Group gap="sm" wrap="wrap">
<Button variant="primary" leftIcon={<Send size={14} />}>
</Button>
<Button variant="secondary" leftIcon={<Download size={14} />}>
</Button>
<Button variant="ghost" leftIcon={<Trash2 size={14} />}>
</Button>
</Group>
</Stack>
</Panel>
{/* ---- IconButton ---- */}
<Panel title="IconButton">
<Group gap="sm">
<IconButton aria-label="settings" size="sm"><Settings size={14} /></IconButton>
<IconButton aria-label="settings" size="md"><Settings size={16} /></IconButton>
<IconButton aria-label="settings" size="lg"><Settings size={18} /></IconButton>
<IconButton aria-label="upload" selected><Upload size={16} /></IconButton>
<IconButton aria-label="disabled" disabled><Cloud size={16} /></IconButton>
</Group>
</Panel>
{/* ---- Field ---- */}
<Panel title="FieldTextField · TextArea">
<Stack gap="sm">
<TextField
label="设备名称"
placeholder="macbook-pro"
hint="此名称展示给你的其他设备。"
required
/>
<TextField
label="只读"
value="alice@dev"
readOnly
/>
<TextField
label="错误态"
defaultValue="x"
error="名称不能少于 2 个字符"
/>
<TextArea
label="消息"
placeholder="键入消息(≤ 4 KB"
hint="回车发送,Shift + 回车换行。"
rows={3}
/>
</Stack>
</Panel>
{/* ---- Panel variants ---- */}
<Panel title="Panel 变体">
<Group gap="sm" align="stretch" wrap="wrap">
<Panel variant="default" padding="tight" style={{ flex: 1, minWidth: 160 }}>
<Text size="sm">default</Text>
</Panel>
<Panel variant="raised" padding="tight" style={{ flex: 1, minWidth: 160 }}>
<Text size="sm">raisedhover/</Text>
</Panel>
<Panel variant="sunken" padding="tight" style={{ flex: 1, minWidth: 160 }}>
<Text size="sm">sunken</Text>
</Panel>
<Panel variant="flat" padding="tight" style={{ flex: 1, minWidth: 160 }}>
<Text size="sm">flat</Text>
</Panel>
</Group>
</Panel>
{/* ---- Tabs ---- */}
<Panel title="Tabs">
<Stack gap="sm">
<Tabs
items={[
{ value: "file", label: "文件" },
{ value: "message", label: "消息" },
]}
value={tab}
onChange={setTab}
aria-label="compose mode"
/>
<Tabs
fullWidth
items={[
{ value: "file", label: "文件" },
{ value: "message", label: "消息" },
]}
value={tab}
onChange={setTab}
aria-label="compose mode wide"
/>
</Stack>
</Panel>
{/* ---- Badge ---- */}
<Panel title="Badge">
<Group gap="sm" wrap="wrap">
<Badge tone="neutral">neutral</Badge>
<Badge tone="accent">accent</Badge>
<Badge tone="online" dot>线</Badge>
<Badge tone="warn"></Badge>
<Badge tone="error"></Badge>
</Group>
</Panel>
{/* ---- Tooltip ---- */}
<Panel title="Tooltip(自 Mantine 重导出)">
<Group gap="sm">
<Tooltip label="发送文件到选中设备">
<Button variant="primary" leftIcon={<Send size={14} />}></Button>
</Tooltip>
<Tooltip label="复制云端最新内容到本机剪贴板">
<IconButton aria-label="copy"><Download size={16} /></IconButton>
</Tooltip>
</Group>
</Panel>
{/* ---- StateDot ---- */}
<Panel title="StateDot">
<Group gap="md" wrap="wrap">
{(["online","active","warn","error","offline"] as const).map((t) => (
<Group key={t} gap={6}>
<StateDot tone={t} />
<Text size="xs">{t}</Text>
</Group>
))}
</Group>
</Panel>
{/* ---- Toast ---- */}
<Panel title="Toast(右上角 · 进度条耗尽自动关闭 · hover 暂停)">
<Group gap="sm" wrap="wrap">
<Button
variant="secondary" size="sm"
onClick={() => toast.info("传输已开始", "screenshot.png → iphone-15")}
>Info</Button>
<Button
variant="secondary" size="sm"
onClick={() => toast.ok("已复制到本机剪贴板", "可以粘贴到其他应用")}
>Success</Button>
<Button
variant="secondary" size="sm"
onClick={() => toast.warn("中继容量即将耗尽", "全局缓冲 480 / 512 MiB")}
>Warning</Button>
<Button
variant="secondary" size="sm"
onClick={() => toast.error("连接失败", "ICE timeout 30s — 已切换中继路径重试")}
>Error</Button>
<Button
variant="ghost" size="sm"
onClick={() => toast.info("仅标题", undefined, 3000)}
>Title only · 3s</Button>
<Button
variant="ghost" size="sm"
onClick={() => toast.info("常驻不自动关闭", "duration=0,需手动 X 关闭", 0)}
>No auto-close</Button>
</Group>
</Panel>
{/* ---- DeviceTypeIcon ---- */}
<Panel title="DeviceTypeIcon">
<Group gap="md" wrap="wrap">
{(["macos","windows","linux","ios","android","tablet","unknown"] as DeviceKind[]).map((k) => (
<Group key={k} gap={6}>
<DeviceTypeIcon kind={k} size={20} />
<Text size="xs">{k}</Text>
<Check size={12} style={{ opacity: 0 }} />
</Group>
))}
</Group>
</Panel>
</Stack>
</Container>
);
}
+140
View File
@@ -0,0 +1,140 @@
import type { TransferRecord, ThemeMode, User } from "./types";
// DONE 是"全部数据已交付"的语义终态,把进行时遥测残留归一到这一事实,避免
// history 行还显示"传输中字节 / 待发送 X MB"等错觉。FAILED / CANCELLED 不归
// 一:保留断点位置便于诊断,phase 字段告诉用户"卡在哪个阶段失败"。
export function normalizeTerminal(cur: TransferRecord, finalState: string): TransferRecord
{
const result: TransferRecord = { ...cur, state: finalState };
if (finalState !== "DONE") { return result; }
result.phase = undefined;
result.bytesTransferred = cur.fileSize;
if (cur.iceStats?.dataChannel)
{
result.iceStats = {
...cur.iceStats,
dataChannel: { ...cur.iceStats.dataChannel, bufferedAmount: 0 },
};
}
return result;
}
// ---- storage keys & readers ----
export const ACCESS_TOKEN_KEY = "cdrop.access_token";
export const USER_KEY = "cdrop.user";
export const SELF_DEVICE_KEY = "cdrop.self_device";
export const THEME_KEY = "cdrop.theme";
// 桌面壳的跨重启会话恢复:Go 侧在启动时从持久化文件读出 session,注入到
// index.html 的 window.__CDROP_SESSION__,因为 wails:// 自定义 scheme 下 WKWebView
// 的 localStorage / sessionStorage 都不跨重启存活(custom-scheme origin 无持久化
// 存储)。注入的全局在 bundle 执行前就绪,store 初始化即可同步水合。浏览器无此
// 注入,回落到 sessionStorage(刷新存活、关标签即清;refresh_token 仅在内存)。
// 注入的 session 不含 refresh_token——refresh_token 是长寿命密钥,只在 Go 进程里,
// 绝不进入 WebView(凭据策略见 desktop/platform/session.go)。
interface InjectedSession
{
access_token: string;
user: { id: string; name: string; avatar?: string };
}
interface InjectedBoot
{
session?: InjectedSession | null;
device_name?: string;
api_base?: string;
device_type?: string;
}
function injectedBoot(): InjectedBoot | null
{
if (typeof window === "undefined") { return null; }
return (window as unknown as { __CDROP_BOOT__?: InjectedBoot }).__CDROP_BOOT__ ?? null;
}
function injectedSession(): InjectedSession | null
{
const s = injectedBoot()?.session;
return s && typeof s.access_token === "string" && s.access_token ? s : null;
}
// readInjectedDeviceName 在桌面壳里返回启动注入的设备名,让重启后路由守卫不再跳
// /setup(设备名同样存在 Go 侧、随 session 一并注入);浏览器无注入返回 null。
export function readInjectedDeviceName(): string | null
{
const name = injectedBoot()?.device_name;
return name && name.length > 0 ? name : null;
}
// readInjectedApiBase 返回 /api 请求要前缀的来源。Windows 桌面壳注入 loopback
// HTTP 代理地址(http://127.0.0.1:PORT),让 SSE 走 WebView2 正常网络栈而非会缓冲
// 的 custom schememacOS 与浏览器无此注入、返回 ""(相对 URL、同源)。
export function readInjectedApiBase(): string
{
const base = injectedBoot()?.api_base;
return base && base.length > 0 ? base : "";
}
// readInjectedDeviceType 返回桌面壳注入的客户端类型(macos / windows / linux),
// 前端经 X-Device-Type 透传给后端,使设备列表不再把桌面端都标成 browser;浏览器
// 无注入返回 "browser"。
export function readInjectedDeviceType(): string
{
const t = injectedBoot()?.device_type;
return t && t.length > 0 ? t : "browser";
}
export function readSessionAccess(): string | null
{
const inj = injectedSession();
if (inj) { return inj.access_token; }
if (typeof window === "undefined") { return null; }
return window.sessionStorage.getItem(ACCESS_TOKEN_KEY);
}
// user.id / user.name 不是机密(公开显示在 UI、JWT 里可解码),随 access_token
// 一并持久化,才能让刷新 / 重启后路由守卫不再误跳 /login。
export function readSessionUser(): User | null
{
const inj = injectedSession();
if (inj?.user && typeof inj.user.id === "string" && typeof inj.user.name === "string")
{
return { id: inj.user.id, name: inj.user.name, avatar: inj.user.avatar };
}
if (typeof window === "undefined") { return null; }
const raw = window.sessionStorage.getItem(USER_KEY);
if (!raw) { return null; }
try
{
const parsed = JSON.parse(raw) as unknown;
if (
parsed
&& typeof parsed === "object"
&& typeof (parsed as User).id === "string"
&& typeof (parsed as User).name === "string"
)
{
return parsed as User;
}
return null;
}
catch
{
return null;
}
}
export function readSelfDevice(): string | null
{
if (typeof window === "undefined") { return null; }
return window.localStorage.getItem(SELF_DEVICE_KEY);
}
export function readTheme(): ThemeMode
{
if (typeof window === "undefined") { return "system"; }
const raw = window.localStorage.getItem(THEME_KEY);
if (raw === "light" || raw === "dark" || raw === "system") { return raw; }
return "system";
}
+37
View File
@@ -0,0 +1,37 @@
import { create } from "zustand";
import type { AppState } from "./types";
import { createAuthSlice } from "./slices/auth";
import { createDevicesSlice } from "./slices/devices";
import { createTransferSlice } from "./slices/transfer";
import { createMessagesSlice } from "./slices/messages";
import { createClipboardSlice } from "./slices/clipboard";
import { createUiSlice } from "./slices/ui";
import { createThemeSlice } from "./slices/theme";
import { createLocaleSlice } from "./slices/locale";
export const useAppStore = create<AppState>()((...a) =>
({
...createAuthSlice(...a),
...createDevicesSlice(...a),
...createTransferSlice(...a),
...createMessagesSlice(...a),
...createClipboardSlice(...a),
...createUiSlice(...a),
...createThemeSlice(...a),
...createLocaleSlice(...a),
}));
// 重新导出所有 type 与 helper,保证现有 `from "./store"` import 仍可解析。
export type {
AppState,
AuthMode,
ThemeMode,
User,
DeviceInfo,
TransferPhase,
CandidateBreakdown,
IceStats,
TransferRecord,
MessageRecord,
ClipboardState,
} from "./types";
+52
View File
@@ -0,0 +1,52 @@
import type { StateCreator } from "zustand";
import type { AppState, AuthMode } from "../types";
import {
ACCESS_TOKEN_KEY,
USER_KEY,
readSessionAccess,
readSessionUser,
} from "../helpers";
export type AuthSlice = Pick<
AppState,
"authMode" | "accessToken" | "refreshToken" | "user" | "setAuth" | "clearAuth"
>;
const initialAuthMode: AuthMode = import.meta.env.DEV ? "dev" : "prod";
// 跨重启的持久化由 Go 侧(session 文件 + 启动注入 window.__CDROP_BOOT__)负责,
// 因为 wails:// scheme 下 WebView 存储不存活。sessionStorage 仅用于浏览器同 tab
// 刷新;桌面在单次运行内的连续性由内存中的 store 保证。
// refreshToken 在 JS 里恒为 null:浏览器只在内存持有(不落盘),桌面则完全不持有
// refresh 走 Go 内部,refresh_token 只在 Go 进程)——见凭据策略。
export const createAuthSlice: StateCreator<AppState, [], [], AuthSlice> = (set) =>
({
authMode: initialAuthMode,
accessToken: readSessionAccess(),
refreshToken: null,
user: readSessionUser(),
setAuth: (a) =>
{
if (typeof window !== "undefined")
{
window.sessionStorage.setItem(ACCESS_TOKEN_KEY, a.accessToken);
window.sessionStorage.setItem(USER_KEY, JSON.stringify(a.user));
}
set({
accessToken: a.accessToken,
refreshToken: a.refreshToken ?? null,
user: a.user,
});
},
clearAuth: () =>
{
if (typeof window !== "undefined")
{
window.sessionStorage.removeItem(ACCESS_TOKEN_KEY);
window.sessionStorage.removeItem(USER_KEY);
}
set({ accessToken: null, refreshToken: null, user: null });
},
});
+10
View File
@@ -0,0 +1,10 @@
import type { StateCreator } from "zustand";
import type { AppState } from "../types";
export type ClipboardSlice = Pick<AppState, "clipboard" | "setClipboard">;
export const createClipboardSlice: StateCreator<AppState, [], [], ClipboardSlice> = (set) =>
({
clipboard: null,
setClipboard: (c) => set({ clipboard: c }),
});
+35
View File
@@ -0,0 +1,35 @@
import type { StateCreator } from "zustand";
import type { AppState } from "../types";
import { SELF_DEVICE_KEY, readSelfDevice, readInjectedDeviceName } from "../helpers";
// 这一片把"设备"语义全部合到一起:本机设备名 + Hub(SSE 连接 / 在线列表)。
// 它们都围绕"我有哪些设备、谁在线"这一问题。
export type DevicesSlice = Pick<
AppState,
"selfDeviceName" | "setSelfDeviceName"
| "sseConnected" | "sseReconnecting" | "devices"
| "setSseConnected" | "setSseReconnecting" | "setDevices"
>;
export const createDevicesSlice: StateCreator<AppState, [], [], DevicesSlice> = (set) =>
({
// 桌面壳优先用启动注入的设备名(localStorage 在 wails:// 下不跨重启存活);
// 浏览器回落到 localStorage。
selfDeviceName: readInjectedDeviceName() ?? readSelfDevice(),
setSelfDeviceName: (name) =>
{
if (typeof window !== "undefined")
{
if (name) { window.localStorage.setItem(SELF_DEVICE_KEY, name); }
else { window.localStorage.removeItem(SELF_DEVICE_KEY); }
}
set({ selfDeviceName: name });
},
sseConnected: false,
sseReconnecting: false,
devices: [],
setSseConnected: (v) => set({ sseConnected: v }),
setSseReconnecting: (v) => set({ sseReconnecting: v }),
setDevices: (d) => set({ devices: d }),
});
+17
View File
@@ -0,0 +1,17 @@
import type { StateCreator } from "zustand";
import type { AppState } from "../types";
import { getLocale, setLocale as i18nSetLocale } from "../../i18n";
export type LocaleSlice = Pick<AppState, "locale" | "setLocale">;
// 初始值取自 i18n 模块(已从 localStorage 还原);setLocale 同步到 i18n
// 模块(更新 currentLocale + 写 localStorage)。
export const createLocaleSlice: StateCreator<AppState, [], [], LocaleSlice> = (set) =>
({
locale: getLocale(),
setLocale: (l) =>
{
i18nSetLocale(l);
set({ locale: l });
},
});
+27
View File
@@ -0,0 +1,27 @@
import type { StateCreator } from "zustand";
import type { AppState } from "../types";
export type MessagesSlice = Pick<
AppState,
"messages" | "addMessage" | "removeMessage" | "clearMessages"
>;
export const createMessagesSlice: StateCreator<AppState, [], [], MessagesSlice> = (set) =>
({
messages: [],
addMessage: (m) =>
{
set((s) => ({ messages: [ m, ...s.messages ].slice(0, 200) }));
},
removeMessage: (id) =>
{
set((s) => ({ messages: s.messages.filter((mm) => mm.id !== id) }));
},
clearMessages: () =>
{
set({ messages: [] });
},
});
+18
View File
@@ -0,0 +1,18 @@
import type { StateCreator } from "zustand";
import type { AppState } from "../types";
import { THEME_KEY, readTheme } from "../helpers";
export type ThemeSlice = Pick<AppState, "theme" | "setTheme">;
export const createThemeSlice: StateCreator<AppState, [], [], ThemeSlice> = (set) =>
({
theme: readTheme(),
setTheme: (t) =>
{
if (typeof window !== "undefined")
{
window.localStorage.setItem(THEME_KEY, t);
}
set({ theme: t });
},
});
+49
View File
@@ -0,0 +1,49 @@
import type { StateCreator } from "zustand";
import type { AppState } from "../types";
import { normalizeTerminal } from "../helpers";
export type TransferSlice = Pick<
AppState,
"activeTransfers" | "history" | "upsertTransfer" | "completeTransfer" | "removeTransfer"
>;
export const createTransferSlice: StateCreator<AppState, [], [], TransferSlice> = (set) =>
({
activeTransfers: {},
history: [],
upsertTransfer: (t) =>
{
set((s) =>
({
activeTransfers: { ...s.activeTransfers, [t.sessionId]: t },
}));
},
completeTransfer: (sessionId, finalState) =>
{
set((s) =>
{
const cur = s.activeTransfers[sessionId];
if (!cur) { return s; }
const finalRec = normalizeTerminal(cur, finalState);
const { [sessionId]: _, ...rest } = s.activeTransfers;
return ({
activeTransfers: rest,
history: [ finalRec, ...s.history ].slice(0, 20),
});
});
},
removeTransfer: (sessionId) =>
{
set((s) =>
{
const { [sessionId]: _, ...rest } = s.activeTransfers;
return ({
activeTransfers: rest,
history: s.history.filter((h) => h.sessionId !== sessionId),
});
});
},
});
+17
View File
@@ -0,0 +1,17 @@
import type { StateCreator } from "zustand";
import type { AppState } from "../types";
export type UiSlice = Pick<
AppState,
"selectedDevice" | "selectedFile" | "setSelectedDevice" | "setSelectedFile"
>;
// 当前 UI slice 仅承载首页 composer 所需的"选中设备"与"选中文件"。后续若
// 需要更多 UI 临时态(开关、临时表单、拖拽态),都收拢在此处。
export const createUiSlice: StateCreator<AppState, [], [], UiSlice> = (set) =>
({
selectedDevice: null,
selectedFile: null,
setSelectedDevice: (n) => set({ selectedDevice: n }),
setSelectedFile: (f) => set({ selectedFile: f }),
});
+169
View File
@@ -0,0 +1,169 @@
import type { Locale } from "../i18n";
// Auth shape — see FRONTEND_DESIGN.md §6.
// access_token lives in sessionStorage so a tab refresh keeps the user logged in
// (PROJECT_BRIEF.md §2). refresh_token stays in the store only — never in storage —
// to keep XSS exposure as small as possible.
export interface User
{
id: string;
name: string;
/** Avatar URL from OIDC `picture` / Casdoor `avatar` claim. 空 / 加载失败时
UI 回退到字母方块。 */
avatar?: string;
}
export interface DeviceInfo
{
name: string;
type: string;
online: boolean;
lastSeen: number;
}
export type AuthMode = "dev" | "prod";
export type ThemeMode = "light" | "dark" | "system";
// 客户端侧细颗粒度阶段,与服务端的 statePENDING/ACCEPTED/...)正交;
// 用来把"等待中/传输中"展开成具体正在做什么,配合 lastProgressAt 区分卡住。
export type TransferPhase =
| "initializing" // 刚 /initiate / 收到 transfer:incoming,未起 WebRTC
| "waiting_accept" // sender 等 /acceptreceiver 等自己 /accept 回执
| "ice_gathering" // RTCPeerConnection 正在收集候选
| "ice_checking" // 候选已交换,连通性检查中
| "ice_connected" // ICE 通过,DC 即将打开
| "dc_open" // DataChannel 打开但还没开始传字节
| "transferring" // 字节正在流动
| "completing" // sender 已 send "done" / receiver 收到 "done",等终态
| "fallback_pending" // 30s ICE 超时,已 POST /fallback,等 RELAY_ACTIVE 信号
| "relay_uploading" // 走 Relay 路径,sender 上传 chunk
| "relay_downloading"; // 走 Relay 路径,receiver 流式 GET
// ICE 候选分类,用来回答"为什么 P2P 没建好"
// host = 本机真实地址;mdns = 浏览器隐私混淆的 .local 主机名(同内网必须能解析);
// srflx = STUN 反射的公网地址;prflx = peer-reflexiverelay = 通过 TURN 中转。
export interface CandidateBreakdown
{
host: number;
mdns: number;
srflx: number;
prflx: number;
relay: number;
}
export interface IceStats
{
gathering: RTCIceGatheringState;
connection: RTCIceConnectionState;
candidates: { local: CandidateBreakdown; remote: CandidateBreakdown };
selectedPair?: { local: string; remote: string }; // 形如 "host/udp" "relay/tcp"
/** DataChannel 即时状态:诊断 sender 卡在 bufferedamountlow 用 */
dataChannel?: {
readyState: RTCDataChannelState;
bufferedAmount: number;
};
}
export interface TransferRecord
{
sessionId: string;
direction: "outgoing" | "incoming";
fileName: string;
fileSize: number;
state: string;
/**
* "p2p" | "relay" | undefined.
* Server emits this on the P2P_ACTIVE / RELAY_ACTIVE transition; the
* client carries it forward into terminal states for history display.
*/
mode?: string;
peerName: string;
startedAt: number;
// ---- 客户端实时遥测 ------------------------------------------------------
phase?: TransferPhase;
/** 本端已传字节:outgoing 是 sender 已 sendincoming 是 receiver 已收 */
bytesTransferred?: number;
/** 上一次 bytesTransferred 增长的时刻(ms epoch),UI 据此判 stalled */
lastProgressAt?: number;
iceStats?: IceStats;
}
// In-memory only — cleared on tab close, or via UI clear.
export interface MessageRecord
{
id: string;
direction: "incoming" | "outgoing";
peerName: string;
text: string;
sentAt: number;
}
export interface ClipboardState
{
content: string;
contentType: string;
sourceDevice: string;
updatedAt: number;
// version 是服务端每用户单调递增的版本号;条件 GET 的 ETag 用它(而非墙钟
// updated_at),故同秒两写也能正确判变更。
version: number;
}
export interface AppState
{
// ---- auth slice ----
authMode: AuthMode;
accessToken: string | null;
refreshToken: string | null;
user: User | null;
setAuth: (a: { accessToken: string; refreshToken?: string; user: User }) => void;
clearAuth: () => void;
// ---- device slice ----
selfDeviceName: string | null;
setSelfDeviceName: (name: string | null) => void;
// ---- hub slice ----
sseConnected: boolean;
sseReconnecting: boolean; // several straight SSE failures; UI shows a "reconnecting" banner — the loop keeps retrying, never gives up
devices: DeviceInfo[];
setSseConnected: (v: boolean) => void;
setSseReconnecting: (v: boolean) => void;
setDevices: (d: DeviceInfo[]) => void;
// ---- transfer slice ----
activeTransfers: Record<string, TransferRecord>;
history: TransferRecord[];
upsertTransfer: (t: TransferRecord) => void;
completeTransfer: (sessionId: string, finalState: string) => void;
/** 从列表移除一条记录(删除已完成的历史项 / 兜底清掉残留 active 项)。 */
removeTransfer: (sessionId: string) => void;
// ---- messages slice ----
messages: MessageRecord[];
addMessage: (m: MessageRecord) => void;
removeMessage: (id: string) => void;
clearMessages: () => void;
// ---- clipboard slice ----
clipboard: ClipboardState | null;
setClipboard: (c: ClipboardState | null) => void;
// ---- ui slice ----
selectedDevice: string | null;
selectedFile: File | null;
setSelectedDevice: (n: string | null) => void;
setSelectedFile: (f: File | null) => void;
// ---- theme slice ----
// 'system' 时移除 data-theme 让 @media (prefers-color-scheme) 接管;
// 'light' / 'dark' 时写死 attribute。持久化到 localStorage。
theme: ThemeMode;
setTheme: (t: ThemeMode) => void;
// ---- locale slice ----
locale: Locale;
setLocale: (l: Locale) => void;
}
+90
View File
@@ -0,0 +1,90 @@
/*
* Base 层 —— html / body / 通用排印 / 滚动条基线。所有具体颜色与字体
* 通过语义令牌读取,不写硬编码值。
*/
*,
*::before,
*::after
{
box-sizing: border-box;
}
html,
body
{
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
font-size: 14px;
line-height: var(--lh-body);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "kern", "liga";
transition:
background-color var(--dur-slow) var(--ease-in-out),
color var(--dur-slow) var(--ease-in-out);
}
#root
{
min-height: 100vh;
background: var(--bg);
}
a
{
color: var(--accent);
text-decoration: none;
transition: color var(--dur-fast) var(--ease-out);
}
a:hover
{
color: color-mix(in srgb, var(--accent) 80%, var(--text));
text-decoration: underline;
text-underline-offset: 2px;
}
::selection
{
background: var(--accent-soft);
color: var(--text);
}
code,
kbd,
samp,
pre
{
font-family: var(--font-mono);
font-size: 0.95em;
}
/* 简单的滚动条美化(仅 webkit)—— 使用 surface-sunken 与 divider,跟随主题。 */
*::-webkit-scrollbar
{
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-track
{
background: transparent;
}
*::-webkit-scrollbar-thumb
{
background: var(--divider);
border-radius: var(--radius-pill);
border: 2px solid var(--bg);
}
*::-webkit-scrollbar-thumb:hover
{
background: color-mix(in srgb, var(--divider) 70%, var(--text));
}
+91
View File
@@ -0,0 +1,91 @@
/*
* CJK 排版精修 —— 参照 ~/Library/CloudStorage/Dropbox/工具设置/Claude/Base/
* templates/html/CJK-TYPOGRAPHY.md
*
* 仅在 <html lang="zh-*" | "ja" | "ko"> 时激活。:where(:lang(...)) 保持零
* 特异性,组件层可直接覆盖,无需 !important。i18n.ts 已经把 locale 切到
* documentElement.lang,这套规则自动跟随。
*
* 字重策略(项目约定,偏离上游 spec):
* - Body Sans(Orbit Gothic CJK)保持 font-weight 400 —— Sans 在 400 不存在
* 过细问题。上游 spec 把 body 提到 500 是针对 Noto Serif CJK 屏幕显示偏细
* 的特定问题,本项目 body 走 Sans,不适用。
* - .serif opt-in 类显式拉到 500 —— Noto Serif CJK 在 400 屏幕上确实偏细,
* 这是 Serif 路径独有的修正,留给将来 hero / marketing 长段落使用。
* - mono / code 锁 400,且 opt-out 所有 CJK 处理(autospace / trim / justify
* 都关掉),保 byte 对齐与 URL / token 可读性。
*/
/* ===== CJK lang root:挂行 / 换行 =====
中西间隙、标点挤压、原生 text-autospace 关闭均由聚珍(juzhen.css 的 .juzhen
normalize + JS pass)接管,此处不再设 text-spacing-trim / text-autospace
以免与聚珍托管挤压双重作用。仅保留两条段落级、与聚珍原子透明兼容的规则。 */
:where(:lang(zh), :lang(ja), :lang(ko))
{
/* 闭合标点在行尾溢出挂起,首字符开口标点挂在左边界,改善光学齐边 */
hanging-punctuation: first allow-end;
/* JIS X 4051 / clreq §3.1.2 禁则浏览器原生兜底(聚珍 jinze 为主) */
line-break: strict;
}
/* ===== 长英文段断词 ===== 聚珍(JuzhenlongWord pass 把 [A-Za-z]{6,} 包进
<span lang="en">,这里给浏览器一个原生英文断词字典 + 渲染连字符。
连字符仅由 CSS 渲染、不在 DOM,选取 / 复制返回原文无污染。原先 CJK
lang root 的 overflow-wrap: anywhere 已被本规则替代:不再硬斩单词,
也保留连字符提示。hyphenate-character 显式 ASCII "-" 避免 serif 字
型把 U+2010 画成全角(本项目 body 走 Sans 暂不踩此坑,但与上游模板
规范一致)。 */
[lang|="en"]
{
-webkit-hyphens: auto;
hyphens: auto;
hyphenate-character: "-";
hyphenate-limit-chars: 6 3 3; /* 词长 / 断点前 / 断点后最少字符 */
}
/* ===== 默认两端对齐:多行块容器(段落 / 列表项 / 引用 / 单元格 / 说明)
===== text-align-last: left 让段末与单行不被拉伸,只有真正的中间行受益。 */
:where(:lang(zh), :lang(ja), :lang(ko))
:is(p, li, blockquote, td, dd, summary, figcaption, caption,
.justify, .justify *)
{
text-align: justify;
text-align-last: left;
}
/* ===== 显式 opt-out:容器及其子树都不走 justify ===== */
:where(:lang(zh), :lang(ja), :lang(ko)) :is(.no-justify, .no-justify *)
{
text-align: start;
text-justify: auto;
text-align-last: auto;
}
/* ===== 显式对齐工具类 =====
.text-left / .text-center / .text-right 覆盖默认 justify + 传播到后代;
主要用例:<th> / <td> / <caption> 的数值列右对齐 / 居中。
规格 (0,1,0) 自身 + (0,1,0) 后代,压过默认规则 (0,0,1);
text-justify / text-align-last 显式 auto,清掉残留 inter-character 拉伸。 */
.text-left, .text-left * { text-align: left; text-justify: auto; text-align-last: auto; }
.text-center, .text-center * { text-align: center; text-justify: auto; text-align-last: auto; }
.text-right, .text-right * { text-align: right; text-justify: auto; text-align-last: auto; }
/* ===== Mono opt-out:byte 对齐,关掉所有 CJK 处理 ===== */
code, kbd, pre, samp,
.mono, .mono *
{
font-weight: 400;
text-autospace: no-autospace;
text-spacing-trim: space-all;
text-align: start;
text-justify: auto;
text-align-last: auto;
}
/* ===== Serif opt-in:Noto Serif CJK 在 400 屏幕偏细,锁定 500(Medium) ===== */
.serif
{
font-family: var(--font-serif);
font-weight: 500;
}
+169
View File
@@ -0,0 +1,169 @@
/*
* 本地优先字体层 —— 用 local() 覆盖 CDN 的纯 url() @font-face。
*
* 问题:三个字体 CDN 的 @font-face 都只有 src: url(...)、无 local()。浏览器解析到
* 这些 family 名(如 "Orbit Gothic CJK SC")时会被该 @font-face 捕获、直接走网络
* url,即便本机已安装同名字体也不会用 → 首屏闪烁、无谓流量。
*
* 修法:在 CDN <link>(见 index.html)之后,对项目实际用到的 family × 字重重新声明
* @font-facesrc 把 local() 放最前、CDN url() 兜底。同 family 同 weight/style 的
* @font-face 以「后定义者胜」,cdrop 打包 CSS 在 index.html 静态 <link> 之后加载,故
* 本层覆盖 CDN:本机命中即零网络瞬时渲染,未装则回退网络(swap)。
*
* 覆盖范围:仅项目用到的字重(400/500/600/700)× 实际激活的 region
* - Orbit Gothic CJK SC / TCSans 正文 Mono 的 CJK 落字)
* - Maple MonoMono 拉丁 / 符号)
* Serif 的 "Noto Serif CJK SC" 是本地专属名(gfonts 只定义 "Noto Serif SC"、不含该
* 名),字体栈里已排最前 → 本就本地优先,无需在此覆盖。日韩(JP/KR)region 暂未激活,
* 启用时按同样 4 字重补充即可。
*
* local() 同时给 family 名与 PostScript 名两种写法以提高命中率;font-display: swap
* 与 CDN 保持一致。
*/
/* ===== Orbit Gothic CJK SC(默认 / 简体 Sans + Mono CJK===== */
@font-face
{
font-family: "Orbit Gothic CJK SC";
font-style: normal;
font-weight: 400;
font-display: swap;
src:
local("Orbit Gothic CJK SC"),
local("OrbitGothicCJKsc-Regular"),
url("https://orbit-gothic.cdn.commilitia.net/WOFF2/CJKsc/OrbitGothicCJKsc-Regular.woff2") format("woff2");
}
@font-face
{
font-family: "Orbit Gothic CJK SC";
font-style: normal;
font-weight: 500;
font-display: swap;
src:
local("Orbit Gothic CJK SC"),
local("OrbitGothicCJKsc-Medium"),
url("https://orbit-gothic.cdn.commilitia.net/WOFF2/CJKsc/OrbitGothicCJKsc-Medium.woff2") format("woff2");
}
@font-face
{
font-family: "Orbit Gothic CJK SC";
font-style: normal;
font-weight: 600;
font-display: swap;
src:
local("Orbit Gothic CJK SC"),
local("OrbitGothicCJKsc-SemiBold"),
url("https://orbit-gothic.cdn.commilitia.net/WOFF2/CJKsc/OrbitGothicCJKsc-SemiBold.woff2") format("woff2");
}
@font-face
{
font-family: "Orbit Gothic CJK SC";
font-style: normal;
font-weight: 700;
font-display: swap;
src:
local("Orbit Gothic CJK SC"),
local("OrbitGothicCJKsc-Bold"),
url("https://orbit-gothic.cdn.commilitia.net/WOFF2/CJKsc/OrbitGothicCJKsc-Bold.woff2") format("woff2");
}
/* ===== Orbit Gothic CJK TC(繁体 Sans + Mono CJK===== */
@font-face
{
font-family: "Orbit Gothic CJK TC";
font-style: normal;
font-weight: 400;
font-display: swap;
src:
local("Orbit Gothic CJK TC"),
local("OrbitGothicCJKtc-Regular"),
url("https://orbit-gothic.cdn.commilitia.net/WOFF2/CJKtc/OrbitGothicCJKtc-Regular.woff2") format("woff2");
}
@font-face
{
font-family: "Orbit Gothic CJK TC";
font-style: normal;
font-weight: 500;
font-display: swap;
src:
local("Orbit Gothic CJK TC"),
local("OrbitGothicCJKtc-Medium"),
url("https://orbit-gothic.cdn.commilitia.net/WOFF2/CJKtc/OrbitGothicCJKtc-Medium.woff2") format("woff2");
}
@font-face
{
font-family: "Orbit Gothic CJK TC";
font-style: normal;
font-weight: 600;
font-display: swap;
src:
local("Orbit Gothic CJK TC"),
local("OrbitGothicCJKtc-SemiBold"),
url("https://orbit-gothic.cdn.commilitia.net/WOFF2/CJKtc/OrbitGothicCJKtc-SemiBold.woff2") format("woff2");
}
@font-face
{
font-family: "Orbit Gothic CJK TC";
font-style: normal;
font-weight: 700;
font-display: swap;
src:
local("Orbit Gothic CJK TC"),
local("OrbitGothicCJKtc-Bold"),
url("https://orbit-gothic.cdn.commilitia.net/WOFF2/CJKtc/OrbitGothicCJKtc-Bold.woff2") format("woff2");
}
/* ===== Maple MonoMono 拉丁 / 符号)===== */
@font-face
{
font-family: "Maple Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src:
local("Maple Mono"),
local("MapleMono-Regular"),
url("https://maple-mono.cdn.commilitia.net/MapleMono-Regular.ttf.woff2") format("woff2");
}
@font-face
{
font-family: "Maple Mono";
font-style: normal;
font-weight: 500;
font-display: swap;
src:
local("Maple Mono"),
local("MapleMono-Medium"),
url("https://maple-mono.cdn.commilitia.net/MapleMono-Medium.ttf.woff2") format("woff2");
}
@font-face
{
font-family: "Maple Mono";
font-style: normal;
font-weight: 600;
font-display: swap;
src:
local("Maple Mono"),
local("MapleMono-SemiBold"),
url("https://maple-mono.cdn.commilitia.net/MapleMono-SemiBold.ttf.woff2") format("woff2");
}
@font-face
{
font-family: "Maple Mono";
font-style: normal;
font-weight: 700;
font-display: swap;
src:
local("Maple Mono"),
local("MapleMono-Bold"),
url("https://maple-mono.cdn.commilitia.net/MapleMono-Bold.ttf.woff2") format("woff2");
}
+186
View File
@@ -0,0 +1,186 @@
/*
* 字体栈 —— 逐 region 明确化。
*
* 聚珍(Juzhen)2.0 不再内建逐语言字体级联,字体完全交消费端 CSS 决定。命名分两类,
* 回退策略不同:
*
* - 含「CJK」的合一字体("Orbit Gothic CJK <R>"、"Noto Serif CJK <R>" /
* Source Han):泛 CJK 全字覆盖,各区变体字符集相同、仅默认 glyph(locl)不同。
* 故每族只列「当前区一个」即可——它已覆盖全部 CJK 字,且 lang + 该区字体触发
* 正确 locl 地域字形,多列其他区无意义。这类多为用户机器上的本地字体,优先尝试。
* - 不含「CJK」的区域子集("Noto Serif <R>"Google Fonts 命名,适合 Web 分发):
* 每区只覆盖该区子集、字符集不全,故须跨区列全(当前区在前取 locl,余区补字),
* 以并集逼近全字覆盖。次于本地合一字体尝试。
*
* 三族(<R> SCTCJPKR:root 默认 SC,兼英文 / 无 lang 回退):
* - Sans "Orbit Gothic CJK <R>" → 系统 sans
* - Serif"Noto Serif CJK <R>"(本地合一)→ "Noto Serif <R>" 跨区(Web 子集)→ 系统 serif
* - Mono "Maple Mono" → "Orbit Gothic CJK <R>" → 系统 mono
*
* "Noto Serif CJK <R>"Adobe / fontconfig,同 Source Han Serif)与 "Noto Serif <R>"
* Google Fonts)命名不同:前者合一全覆盖、后者区域子集;拉丁正文回退 "Noto Serif"。
*
* 区域变体由 :lang() 切,<html lang="zh-CN|zh-TW|en-US"> 由 uiSlice.locale 同步
* 写入;项目 lang 取自 i18n.Locale,故繁体走 :lang(zh-TW) 而非 :lang(zh-Hant)
* 后续新增日韩沿 BCP47 区域 tag 添加即可。
*
* 字体文件 woff2 由远程 CDN 拉取(index.html 里 <link rel="stylesheet"> 引入
* @font-face 声明):
* - Orbit Gothic CJK → orbit-gothic.cdn.commilitia.net
* - Maple Mono → maple-mono.cdn.commilitia.net
* - Fredoka(字标) → gfonts.commilitia.net(同代理;--font-display 用)
* - Noto Serif(子集) → gfonts.commilitia.netGoogle Fonts 的 CF Worker 代理)
*
* 远程加载失败时按 fallback 链回退到系统字体,整体外观仍 OK。
*/
:root
{
--font-sans:
"Orbit Gothic CJK SC",
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"PingFang SC",
"Microsoft YaHei",
"Hiragino Sans GB",
sans-serif;
--font-serif:
"Noto Serif CJK SC",
"Source Han Serif SC",
"Noto Serif SC",
"Noto Serif TC",
"Noto Serif JP",
"Noto Serif KR",
"Noto Serif",
"Songti SC",
Georgia,
Cambria,
"Times New Roman",
serif;
--font-mono:
"Maple Mono",
"Orbit Gothic CJK SC",
"JetBrains Mono",
ui-monospace,
SFMono-Regular,
Menlo,
Consolas,
monospace;
/* display 仅用于品牌字标("Commilitia Drop")与极少数大字号陈列。Fredoka 是
品牌资产 README 指定的字标字体(圆体 geometric sans),经 gfonts.commilitia.net
镜像加载;无 CJK 字形,CJK 字符按 var(--font-sans) 逐字回退(字标为纯拉丁,
不触发)。远程失败时整链回退到 sans,外观仍 OK。 */
--font-display:
"Fredoka",
var(--font-sans);
}
/* ===== 繁体(TC===== */
:lang(zh-TW),
:lang(zh-HK),
:lang(zh-MO)
{
--font-sans:
"Orbit Gothic CJK TC",
system-ui,
-apple-system,
"PingFang TC",
"Microsoft JhengHei",
"Hiragino Sans CNS",
sans-serif;
--font-serif:
"Noto Serif CJK TC",
"Source Han Serif TC",
"Noto Serif TC",
"Noto Serif SC",
"Noto Serif JP",
"Noto Serif KR",
"Noto Serif",
"Songti TC",
Georgia,
serif;
--font-mono:
"Maple Mono",
"Orbit Gothic CJK TC",
"JetBrains Mono",
ui-monospace,
SFMono-Regular,
Menlo,
Consolas,
monospace;
}
/* ===== 日文(JP===== */
:lang(ja)
{
--font-sans:
"Orbit Gothic CJK JP",
system-ui,
-apple-system,
"Hiragino Sans",
"Yu Gothic",
"Meiryo",
sans-serif;
--font-serif:
"Noto Serif CJK JP",
"Source Han Serif",
"Noto Serif JP",
"Noto Serif SC",
"Noto Serif TC",
"Noto Serif KR",
"Noto Serif",
"Yu Mincho",
Georgia,
serif;
--font-mono:
"Maple Mono",
"Orbit Gothic CJK JP",
"JetBrains Mono",
ui-monospace,
SFMono-Regular,
Menlo,
Consolas,
monospace;
}
/* ===== 韩文(KR===== */
:lang(ko)
{
--font-sans:
"Orbit Gothic CJK KR",
system-ui,
-apple-system,
"Apple SD Gothic Neo",
"Malgun Gothic",
sans-serif;
--font-serif:
"Noto Serif CJK KR",
"Source Han Serif KR",
"Noto Serif KR",
"Noto Serif SC",
"Noto Serif TC",
"Noto Serif JP",
"Noto Serif",
Georgia,
serif;
--font-mono:
"Maple Mono",
"Orbit Gothic CJK KR",
"JetBrains Mono",
ui-monospace,
SFMono-Regular,
Menlo,
Consolas,
monospace;
}
+27
View File
@@ -0,0 +1,27 @@
/*
* 全局样式入口。被 main.tsx 单点 import。
*
* 顺序约定:
* 1. tokens.primitives 原始常量(颜色 / 字号 / 间距 / 时长)
* 2. tokens.semantic 主题相关语义变量(--bg / --accent / ...
* 3. fonts 字体族变量与 :lang() 切换
* 4. fontface 本地优先 @font-face 覆盖层(local() shadow CDN 的纯 url
* 5. base html/body 与通用排印基线
* 6. juzhen 聚珍套件视觉尺寸(中西间隙 / 标点挤压 / justify 原子,必配)
* 7. cjk CJK 排版精修(项目层覆盖:justify 选择器 / mono opt-out
* 8. motion 动效 utility 与 reduced-motion
* 9. tailwind 最后,让 utilities 可覆盖 base
*/
@import "./tokens.primitives.css";
@import "./tokens.semantic.css";
@import "./fonts.css";
@import "./fontface.css";
@import "./base.css";
@import "../lib/cjk-autospace/dist/juzhen.css";
@import "./cjk.css";
@import "./motion.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
+35
View File
@@ -0,0 +1,35 @@
/*
* Motion 层 —— 通用过渡 utility 与 reduced-motion 兜底。
*/
.transition-base
{
transition:
background-color var(--dur-base) var(--ease-out),
border-color var(--dur-base) var(--ease-out),
color var(--dur-base) var(--ease-out),
box-shadow var(--dur-base) var(--ease-out);
}
.transition-fast
{
transition:
background-color var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out),
transform var(--dur-fast) var(--ease-out);
}
@media (prefers-reduced-motion: reduce)
{
*,
*::before,
*::after
{
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}
+121
View File
@@ -0,0 +1,121 @@
/*
* Primitives 层 —— 原始令牌。来自 Dracula Pro 官方调色板与设计稿基础尺度。
*
* 组件不直接消费这一层。主题切换不动这一层。新增主题 = 在 semantic 层把
* 这里的原始值映射给语义变量。
*/
:root
{
/* ---- Dracula Pro · Base 色板 ---- */
--c-purple-500: #9580FF; /* Base Purple — 大块面积/按钮背景适用 */
--c-purple-400: #AA99FF; /* Base BrightBlue — Purple 的官方亮版,适合
深色背景上的文字 / 链接,对比度 6.7:1 */
--c-purple-700: #644AC9; /* Alucard Purple — 亮色主题 accent */
--c-pink: #FF80BF; /* 扩展色 */
--c-pink-deep: #A3144D;
--c-red: #FF9580; /* dark · state-error */
--c-red-deep: #CB3A2A; /* light · state-error */
--c-orange: #FFCA80; /* dark · state-warn */
--c-orange-deep: #A34D14; /* light · state-warn */
--c-yellow: #FFFF80;
--c-yellow-deep: #846E15;
--c-green: #8AFF80; /* dark · state-online */
--c-green-deep: #14710A; /* light · state-online */
--c-cyan: #80FFEA;
--c-cyan-deep: #036A96;
/* ---- Dracula Pro · 文字 / Comment(色 token,非表面) ---- */
/* Dracula 官方深色 bg / surface 偏 250° 紫调,在紫色 accent 上对比度
塌缩到 ~4.2:1。本项目改走「中性灰表面 + Dracula 色作 accent / 角色色」,
表面色单独抽到 c-bg-dark-* 系列,这里只保留 foreground 与 comment。 */
--c-comment-dracula: #7970A9; /* official Comment —— 仅用于"非常 muted"
的场景(例如 hover-only 提示);作为
UI 次要文字色对比度只有 4.3:1,临界。 */
--c-comment-bright: #A89DD3; /* Comment 的官方派生亮版(同色相 250°,
饱和度 35% 明度 72%),用于 UI muted 文字,
对比度 ~7:1 (AAA Large + AA Normal)。 */
--c-foreground-dracula: #F8F8F2; /* official Foreground */
--c-foreground-alucard: #1F1F1F; /* official Foreground */
--c-comment-alucard: #635D97; /* official Comment */
/* ---- 中性灰深色 5 阶 surface(去紫调) ----
目标:让紫色 accent 在背景上对比度达 7:1+,避免 Dracula 官方紫底紫前景
塌缩。色相 H≈260°(略冷,与紫色 accent 同冷调系)、饱和度 8-12% 极低、
明度 9-22% 阶梯递进。 */
--c-bg-dark-neutral: #17171C; /* 页面底,最深 */
--c-sunken-dark-neutral: #1E1E24; /* 下沉(剪贴板 sunken / dropzone 底) */
--c-surface-dark-neutral: #26262E; /* 默认 panel */
--c-raised-dark-neutral: #2E2E37; /* hover / 主 panel */
--c-elev2-dark-neutral: #37373F; /* 双层 elev(对话框 / 弹层) */
--c-divider-dark-neutral: #3C3C45;
--c-divider-strong-dark: #54545F;
--c-text-muted-dark-neutral: #9C9CA9; /* 比 Comment 紫调更冷的中性 muted */
/* ---- 中性灰浅色 5 阶 surface(去紫调) ----
原 Alucard #F5F5F5/#FFFFFF/#EAEAEA 已基本中性,这里微调成更冷一档,
并显式拉出 5 阶,让 hover 的 surface→raised 有视觉差。
2026-05-16 调整:bg 与 sunken 各自再深一档,把卡片"浮"起来,
让默认白色 panel 从页面背景里明显分层(原 #F2F2F4 太接近白)。 */
--c-bg-light-neutral: #E9E8EE; /* 页面底,更冷更深一档,与白卡形成清晰 ~9% 亮度差 */
--c-sunken-light-neutral: #DDDCE3; /* 下沉表面,比 bg 再深一档 */
--c-surface-light-neutral: #FAFAFB; /* 默认 panel:几乎纯白 */
--c-raised-light-neutral: #FFFFFF; /* hover / 主 panel */
--c-elev2-light-neutral: #FFFFFF; /* 双层(primary panel) */
--c-divider-light-neutral: #D6D5DC;
--c-divider-strong-light: #B5B4BD;
--c-text-muted-light-neutral: #5B5A6E; /* 比 #635D97 略冷,饱和度更低 */
/* ---- 原 Dracula / Alucard 表面变量保留作 fallback,目前不消费 ---- */
--c-bg-dracula: var(--c-bg-dark-neutral);
--c-surface-dracula: var(--c-surface-dark-neutral);
--c-raised-dracula: var(--c-raised-dark-neutral);
--c-sunken-dracula: var(--c-sunken-dark-neutral);
--c-selection-dracula: #454158; /* official Selection,仅 ::selection 还要 */
--c-bg-alucard: var(--c-bg-light-neutral);
--c-surface-alucard: var(--c-surface-light-neutral);
--c-raised-alucard: var(--c-raised-light-neutral);
--c-sunken-alucard: var(--c-sunken-light-neutral);
--c-selection-alucard: #CFCFDE;
/* ---- 字号阶梯 ---- */
--fs-11: 11px;
--fs-12: 12px;
--fs-13: 13px;
--fs-15: 15px;
--fs-18: 18px;
--fs-22: 22px;
--fs-28: 28px;
/* ---- 行高 ---- */
--lh-body: 1.6; /* 正文 / 多行块 */
--lh-ui: 1.4; /* 按钮 / 单行 UI */
--lh-title: 1.2; /* 标题 */
/* ---- 字距 ---- */
--ls-brand: 0.06em;
--ls-label: 0.08em;
/* ---- 间距阶梯(基 4---- */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-7: 48px;
--space-8: 64px;
/* ---- 圆角 ---- */
--radius-card: 10px;
--radius-control: 6px;
--radius-pill: 999px;
/* ---- 动效 ---- */
--dur-fast: 120ms;
--dur-base: 220ms;
--dur-slow: 320ms;
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
}
+191
View File
@@ -0,0 +1,191 @@
/*
* Semantic 层 —— 语义令牌。组件唯一应该消费的颜色 / 阴影变量。
*
* 主题切换 = 改一个 attribute:document.documentElement.dataset.theme = "dark"。
* 自定义主题 = 在 :root[data-theme="custom-x"] 下覆盖同一组变量。
*
* 系统主题:未设 data-theme 时,跟随 @media (prefers-color-scheme: dark)。
*
* 2026-05 重塑:
* - 表面切到中性灰 5 阶(去紫调),accent 仍是 Dracula Purple。
* - 引入 5 色角色:info / success / warn / error / emphasis + 各自 soft 派生。
* - 状态编码统一走角色色(Activity 左色条、Badge tone、Callout 等)。
*/
/* ===== Light · 中性灰 + Alucard accent / 角色色(默认) ===== */
:root,
:root[data-theme="light"]
{
--bg: var(--c-bg-light-neutral);
--surface: var(--c-surface-light-neutral);
--surface-raised: var(--c-raised-light-neutral);
--surface-sunken: var(--c-sunken-light-neutral);
--surface-elev2: var(--c-elev2-light-neutral);
--divider: var(--c-divider-light-neutral);
--divider-strong: var(--c-divider-strong-light);
--text: var(--c-foreground-alucard);
--text-muted: var(--c-text-muted-light-neutral);
--accent: var(--c-purple-700);
--accent-soft: color-mix(in srgb, var(--accent) 10%, transparent);
--accent-softer: color-mix(in srgb, var(--accent) 5%, transparent);
--accent-ring: color-mix(in srgb, var(--accent) 26%, transparent);
--accent-on: #FFFFFF;
/* 5 色角色 —— Alucard 调色板深版,作 status 编码主力 */
--info: var(--c-cyan-deep); /* #036A96 */
--success: var(--c-green-deep); /* #14710A */
--warn: var(--c-yellow-deep); /* #846E15 */
--error: var(--c-red-deep); /* #CB3A2A */
--emphasis: var(--c-pink-deep); /* #A3144D */
--orange: var(--c-orange-deep); /* #A34D14,备用 */
--info-soft: color-mix(in srgb, var(--info) 9%, transparent);
--success-soft: color-mix(in srgb, var(--success) 9%, transparent);
--warn-soft: color-mix(in srgb, var(--warn) 11%, transparent);
--error-soft: color-mix(in srgb, var(--error) 9%, transparent);
--emphasis-soft: color-mix(in srgb, var(--emphasis) 9%, transparent);
/* state-* 是历史 alias,保留兼容现有组件(StateDot 等) */
--state-online: var(--success);
--state-error: var(--error);
--state-warn: var(--warn);
--shadow-card: 0 1px 2px rgba(17, 17, 20, 0.05),
0 2px 6px rgba(17, 17, 20, 0.07);
--shadow-raised: 0 8px 20px rgba(17, 17, 20, 0.10),
0 2px 6px rgba(17, 17, 20, 0.06);
--shadow-overlay: 0 12px 32px rgba(17, 17, 20, 0.18);
--shadow-accent: 0 8px 22px rgba(100, 74, 201, 0.24);
/* 字母头像:浅色主题 白底黑字 */
--avatar-bg: #FFFFFF;
--avatar-fg: #1F1F1F;
/* 颜色阶 —— Mantine accent 色板用,10 阶手工对齐 Dracula Purple */
--accent-0: #F3F0FF;
--accent-1: #E4DEFF;
--accent-2: #C9BEFF;
--accent-3: #AC9BFE;
--accent-4: #9580FF;
--accent-5: #8369FB;
--accent-6: #7458EE;
--accent-7: #644AC9;
--accent-8: #523CA8;
--accent-9: #3F2D85;
color-scheme: light;
}
/* ===== Dark · 中性灰 + Dracula accent / 角色色 =====
*
* 对比度准则:
* - 表面用 c-*-dark-neutral 5 阶,色相 260° 但饱和度 8%,接近中性灰。
* 紫色 accent (#AA99FF) 在 #26262E 上对比度 ~8.5:1,胜过原 Dracula 紫底紫前景的 4.2:1。
* - --text-muted 用更冷的中性 #9C9CA9,而非 Comment 紫调,避免在中性灰上仍透紫感。
* - --accent 用 BrightBlue #AA99FF (Dracula PRO Base 官方"亮紫"),
* 按钮背景仍可用,accent-on 是 #1B1A23 强反差。
*/
:root[data-theme="dark"]
{
--bg: var(--c-bg-dark-neutral);
--surface: var(--c-surface-dark-neutral);
--surface-raised: var(--c-raised-dark-neutral);
--surface-sunken: var(--c-sunken-dark-neutral);
--surface-elev2: var(--c-elev2-dark-neutral);
--divider: var(--c-divider-dark-neutral);
--divider-strong: var(--c-divider-strong-dark);
--text: var(--c-foreground-dracula);
--text-muted: var(--c-text-muted-dark-neutral);
--accent: var(--c-purple-400);
--accent-soft: color-mix(in srgb, var(--accent) 20%, transparent);
--accent-softer: color-mix(in srgb, var(--accent) 10%, transparent);
--accent-ring: color-mix(in srgb, var(--accent) 36%, transparent);
--accent-on: #1B1A23;
/* 5 色角色 —— Dracula Pro neon,中性背景下饱和度足够 */
--info: var(--c-cyan); /* #80FFEA */
--success: var(--c-green); /* #8AFF80 */
--warn: var(--c-yellow); /* #FFFF80 */
--error: var(--c-red); /* #FF9580 */
--emphasis: var(--c-pink); /* #FF80BF */
--orange: var(--c-orange); /* #FFCA80 */
--info-soft: color-mix(in srgb, var(--info) 14%, transparent);
--success-soft: color-mix(in srgb, var(--success) 14%, transparent);
--warn-soft: color-mix(in srgb, var(--warn) 14%, transparent);
--error-soft: color-mix(in srgb, var(--error) 14%, transparent);
--emphasis-soft: color-mix(in srgb, var(--emphasis) 14%, transparent);
--state-online: var(--success);
--state-error: var(--error);
--state-warn: var(--warn);
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.45),
0 4px 12px rgba(0, 0, 0, 0.5);
--shadow-raised: 0 12px 28px rgba(0, 0, 0, 0.55),
0 2px 6px rgba(0, 0, 0, 0.4);
--shadow-overlay: 0 16px 40px rgba(0, 0, 0, 0.65);
--shadow-accent: 0 10px 28px rgba(170, 153, 255, 0.34);
--avatar-bg: #000000;
--avatar-fg: #FFFFFF;
color-scheme: dark;
}
/* ===== System:未指定 data-theme 时跟随系统 ===== */
@media (prefers-color-scheme: dark)
{
:root:not([data-theme])
{
--bg: var(--c-bg-dark-neutral);
--surface: var(--c-surface-dark-neutral);
--surface-raised: var(--c-raised-dark-neutral);
--surface-sunken: var(--c-sunken-dark-neutral);
--surface-elev2: var(--c-elev2-dark-neutral);
--divider: var(--c-divider-dark-neutral);
--divider-strong: var(--c-divider-strong-dark);
--text: var(--c-foreground-dracula);
--text-muted: var(--c-text-muted-dark-neutral);
--accent: var(--c-purple-400);
--accent-soft: color-mix(in srgb, var(--accent) 20%, transparent);
--accent-softer: color-mix(in srgb, var(--accent) 10%, transparent);
--accent-ring: color-mix(in srgb, var(--accent) 36%, transparent);
--accent-on: #1B1A23;
--info: var(--c-cyan);
--success: var(--c-green);
--warn: var(--c-yellow);
--error: var(--c-red);
--emphasis: var(--c-pink);
--orange: var(--c-orange);
--info-soft: color-mix(in srgb, var(--info) 14%, transparent);
--success-soft: color-mix(in srgb, var(--success) 14%, transparent);
--warn-soft: color-mix(in srgb, var(--warn) 14%, transparent);
--error-soft: color-mix(in srgb, var(--error) 14%, transparent);
--emphasis-soft: color-mix(in srgb, var(--emphasis) 14%, transparent);
--state-online: var(--success);
--state-error: var(--error);
--state-warn: var(--warn);
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.45),
0 4px 12px rgba(0, 0, 0, 0.5);
--shadow-raised: 0 12px 28px rgba(0, 0, 0, 0.55),
0 2px 6px rgba(0, 0, 0, 0.4);
--shadow-overlay: 0 16px 40px rgba(0, 0, 0, 0.65);
--shadow-accent: 0 10px 28px rgba(170, 153, 255, 0.34);
--avatar-bg: #000000;
--avatar-fg: #FFFFFF;
color-scheme: dark;
}
}
+46
View File
@@ -0,0 +1,46 @@
/* 品牌字标:吉祥物 logo mark + Fredoka 600 混排双色文字,对齐品牌资产 README。
字号由容器控制(font-size 继承),此处只定字体族 / 字重 / 双色 / 关大写 / logo。
双色按主题三态切换,沿用 tokens.semantic.css 范式:显式 [data-theme] + 系统
(无 data-theme 跟随 prefers-color-scheme)。 */
.wordmark
{
display: inline-flex;
align-items: center;
/* 行内居中而非 baseline:作为 inline 元素嵌在 Title/文本行里时,按 baseline
对齐会让高过文字的 logo 顶出行框上沿(顶栏上端溢出);middle 居中消除。 */
vertical-align: middle;
gap: 0.4em;
font-family: var(--font-display);
font-weight: 600;
letter-spacing: 0;
text-transform: none;
white-space: nowrap;
}
/* logo mark:高度随字号(em)缩放,方形吉祥物头像(透明 PNG,明暗背景皆可)。
取 2em——只放大图像、不动文字(容器 font-size 不变),让头像在固定高度的顶栏里
占得较满又不溢出:header 高 56px、font-size fs-22 时 =39.6px(约 71%)。头像
四周本有透明留白,故需较大倍数视觉才填满。 */
.mark
{
height: 1.8em;
width: auto;
flex-shrink: 0;
}
.text { white-space: nowrap; }
/* 浅色默认:Ink / AmberREADME 指定)。 */
.name { color: #2C2118; }
.drop { color: #DB9410; }
/* 显式深色:白 / 亮金。 */
:global(:root[data-theme="dark"]) .name { color: #FFFFFF; }
:global(:root[data-theme="dark"]) .drop { color: #F8C637; }
/* 系统深色(未显式指定主题时跟随系统)。 */
@media (prefers-color-scheme: dark)
{
:global(:root:not([data-theme])) .name { color: #FFFFFF; }
:global(:root:not([data-theme])) .drop { color: #F8C637; }
}
+26
View File
@@ -0,0 +1,26 @@
import s from "./Wordmark.module.css";
export interface WordmarkProps
{
/** 透传到外层 span 的额外类名(容器经 font-size 控制字号 / 定位)。 */
className?: string;
}
// 品牌字标:吉祥物 logo mark + “Commilitia Drop”(Fredoka 600 混排双色),严格对齐
// 品牌资产 README。规范要求页面不得以纯文字呈现品牌,故 logo 图与文字成对出现。
// logo 高度随 font-size(em)缩放;图为装饰(相邻文字已含品牌名),故 aria-hidden。
// 浅色 Commilitia=Ink(#2C2118) / Drop=Amber(#DB9410),深色背景转 白 / #F8C637。品牌名
// 是专有名词、不本地化,故两段写死,不走 i18n。
export function Wordmark(props: WordmarkProps)
{
const cls = props.className ? `${s.wordmark} ${props.className}` : s.wordmark;
return (
<span className={cls}>
<img className={s.mark} src="/logo-mark.png" alt="" aria-hidden="true" />
<span className={s.text}>
<span className={s.name}>Commilitia</span>{" "}
<span className={s.drop}>Drop</span>
</span>
</span>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { Wordmark } from "./Wordmark";
export type { WordmarkProps } from "./Wordmark";
+127
View File
@@ -0,0 +1,127 @@
/*
* 右上角 Toast。flat 卡片(shadow-raised),宽度 320,堆叠间距 10。
* 进度条从左侧消耗:起始全宽,hover 暂停,耗尽 = 全空。
* 颜色等级:info / ok / warn / error,左侧 3px 条按 tone 上色。
*/
.viewport
{
position: fixed;
top: var(--space-4);
right: var(--space-4);
z-index: 9999;
width: 320px;
max-width: calc(100vw - var(--space-4) * 2);
display: flex;
flex-direction: column;
gap: var(--space-2);
pointer-events: none; /* 让 viewport 不挡点击; 各 toast 自己 enable */
}
.toast
{
pointer-events: auto;
position: relative;
background: var(--surface);
border-radius: var(--radius-card);
box-shadow: var(--shadow-raised);
padding: var(--space-3) var(--space-4) calc(var(--space-3) + 4px);
display: flex;
gap: var(--space-3);
align-items: flex-start;
min-width: 0;
overflow: hidden;
border-left: 3px solid var(--toast-accent, var(--accent));
animation: toast-enter var(--dur-base) var(--ease-out);
transition: opacity var(--dur-base) var(--ease-out),
transform var(--dur-base) var(--ease-out);
}
@keyframes toast-enter
{
from { opacity: 0; transform: translateX(8px); }
to { opacity: 1; transform: translateX(0); }
}
.leaving
{
opacity: 0;
transform: translateX(8px);
}
.toneInfo { --toast-accent: var(--accent); }
.toneOk { --toast-accent: var(--state-online); }
.toneWarn { --toast-accent: var(--state-warn); }
.toneError { --toast-accent: var(--state-error); }
.icon
{
flex-shrink: 0;
margin-top: 2px;
color: var(--toast-accent);
}
.body
{
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.title
{
font-family: var(--font-sans);
font-size: var(--fs-13);
font-weight: 600;
color: var(--text);
line-height: var(--lh-ui);
word-break: break-word;
}
.content
{
font-family: var(--font-sans);
font-size: var(--fs-12);
color: var(--text-muted);
line-height: var(--lh-body);
word-break: break-word;
}
.close
{
flex-shrink: 0;
background: transparent;
border: 0;
color: var(--text-muted);
cursor: pointer;
padding: 2px;
border-radius: var(--radius-control);
display: inline-flex;
transition: color var(--dur-fast) var(--ease-out),
background-color var(--dur-fast) var(--ease-out);
}
.close:hover { color: var(--text); background: var(--surface-sunken); }
/* 进度条:起始 100% 宽 (right anchored),timer 推进时 width 缩短,
颜色块"耗尽"在右侧。 */
.progressTrack
{
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
background: var(--surface-sunken);
}
.progressFill
{
position: absolute;
top: 0;
right: 0; /* 锚定右侧 - 进度 -> 0 时从右往左只剩右边一点 */
bottom: 0;
background: var(--toast-accent);
transition: width 100ms linear;
}
+189
View File
@@ -0,0 +1,189 @@
import {
useEffect,
useRef,
useState,
useSyncExternalStore,
type ReactNode,
} from "react";
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
import s from "./Toast.module.css";
export type ToastTone = "info" | "ok" | "warn" | "error";
export interface ToastInput
{
title: ReactNode;
/** 正文内容(可选,只有标题时省略)。 */
content?: ReactNode;
tone?: ToastTone;
/** 持续时长(ms)。默认 5000;0 = 永不自动关闭。 */
duration?: number;
}
interface ToastRecord extends Required<Omit<ToastInput, "content">>
{
id: number;
content?: ReactNode;
}
// ---- 模块内 manager:外部通过 toast.xxx() 调用即可,不需要 React context ----
type Listener = (list: ToastRecord[]) => void;
const listeners = new Set<Listener>();
let list: ToastRecord[] = [];
let nextId = 1;
function emit() { for (const l of listeners) { l(list); } }
function show(input: ToastInput): number
{
const id = nextId++;
const rec: ToastRecord = {
id,
title: input.title,
content: input.content,
tone: input.tone ?? "info",
duration: input.duration ?? 5000,
};
list = [ ...list, rec ];
emit();
return id;
}
function dismiss(id: number)
{
list = list.filter((t) => t.id !== id);
emit();
}
function subscribe(l: Listener): () => void
{
listeners.add(l);
return () => { listeners.delete(l); };
}
function snapshot() { return list; }
export const toast = {
show,
dismiss,
info: (title: ReactNode, content?: ReactNode, duration?: number) =>
show({ title, content, tone: "info", duration }),
ok: (title: ReactNode, content?: ReactNode, duration?: number) =>
show({ title, content, tone: "ok", duration }),
warn: (title: ReactNode, content?: ReactNode, duration?: number) =>
show({ title, content, tone: "warn", duration }),
error: (title: ReactNode, content?: ReactNode, duration?: number) =>
show({ title, content, tone: "error", duration: duration ?? 7000 }),
};
// ---- React 视图层 ----
const TONE_CLASS: Record<ToastTone, string> = {
info: s.toneInfo,
ok: s.toneOk,
warn: s.toneWarn,
error: s.toneError,
};
const TONE_ICON: Record<ToastTone, ReactNode> = {
info: <Info size={16} />,
ok: <CheckCircle2 size={16} />,
warn: <AlertTriangle size={16} />,
error: <XCircle size={16} />,
};
export function ToastViewport()
{
const items = useSyncExternalStore(subscribe, snapshot, snapshot);
return (
<div className={s.viewport} aria-live="polite" aria-atomic="false">
{items.map((it) => <ToastCard key={it.id} record={it} />)}
</div>
);
}
function ToastCard(props: { record: ToastRecord })
{
const { record } = props;
const [ paused, setPaused ] = useState(false);
const [ leaving, setLeaving ] = useState(false);
// 用 elapsed 实际逝去时间(ms),非匀速 tick — 暂停期间不累加。
const [ elapsed, setElapsed ] = useState(0);
const lastTickRef = useRef<number>(performance.now());
const total = record.duration;
const autoClose = total > 0;
useEffect(() =>
{
if (!autoClose) { return; }
let raf = 0;
const tick = () =>
{
const now = performance.now();
const dt = now - lastTickRef.current;
lastTickRef.current = now;
if (!paused)
{
setElapsed((e) =>
{
const next = e + dt;
if (next >= total)
{
// 在下一帧触发退出动画 + 真正移除;避免在 setState 内部
// dispatch 引起其它 toast 重渲染问题。
setLeaving(true);
window.setTimeout(() => toast.dismiss(record.id), 220);
return total;
}
return next;
});
}
raf = window.requestAnimationFrame(tick);
};
raf = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(raf);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ paused, autoClose, total, record.id ]);
const remaining = Math.max(0, total - elapsed);
const widthPct = autoClose ? (remaining / total) * 100 : 100;
return (
<div
className={[
s.toast,
TONE_CLASS[record.tone],
leaving ? s.leaving : "",
].filter(Boolean).join(" ")}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
role={record.tone === "error" ? "alert" : "status"}
>
<span className={s.icon}>{TONE_ICON[record.tone]}</span>
<div className={s.body}>
<div className={s.title}>{record.title}</div>
{record.content && <div className={`${s.content} justify`} data-jz-level="paragraph">{record.content}</div>}
</div>
<button
type="button"
className={s.close}
aria-label="关闭"
onClick={() =>
{
setLeaving(true);
window.setTimeout(() => toast.dismiss(record.id), 220);
}}
>
<X size={14} />
</button>
{autoClose && (
<div className={s.progressTrack} aria-hidden="true">
<div className={s.progressFill} style={{ width: `${widthPct}%` }} />
</div>
)}
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { toast, ToastViewport } from "./Toast";
export type { ToastInput, ToastTone } from "./Toast";
+87
View File
@@ -0,0 +1,87 @@
import type { SVGProps } from "react";
import { Apple, Laptop, Smartphone, Tablet, Zap } from "lucide-react";
export type DeviceKind =
| "macos" | "windows" | "linux" | "ios" | "shortcut" | "android" | "tablet" | "unknown";
const STROKE = 1.5;
export interface DeviceTypeIconProps extends SVGProps<SVGSVGElement>
{
kind: DeviceKind;
size?: number;
}
// 简笔线稿,1.5px stroke。默认走 currentColor,外层用 var(--text-muted)
// 或 var(--accent) 控制即可。Apple/Tablet 复用 lucide 的现成几何;
// Windows/Linux/Android 自绘以保持区分度。
export function DeviceTypeIcon(props: DeviceTypeIconProps)
{
const { kind, size = 18, ...rest } = props;
if (kind === "macos" || kind === "ios")
{
const Cmp = kind === "macos" ? Laptop : Apple;
return <Cmp size={size} strokeWidth={STROKE} {...(rest as any)} />;
}
if (kind === "shortcut")
{
// iOS 快捷指令——非真实设备,用闪电(自动化/快捷动作的通用记号)区分。
return <Zap size={size} strokeWidth={STROKE} {...(rest as any)} />;
}
if (kind === "android")
{
return (
<svg
width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth={STROKE}
strokeLinecap="round" strokeLinejoin="round" {...rest}
>
<path d="M5 11a7 7 0 0 1 14 0v6a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1z" />
<path d="M7 7l-2-3" />
<path d="M17 7l2-3" />
<circle cx="9" cy="11" r="0.6" fill="currentColor" />
<circle cx="15" cy="11" r="0.6" fill="currentColor" />
</svg>
);
}
if (kind === "windows")
{
return (
<svg
width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth={STROKE}
strokeLinecap="round" strokeLinejoin="round" {...rest}
>
<path d="M3 5l9-1v8H3z" />
<path d="M12 4l9-1v10h-9z" />
<path d="M3 13h9v8l-9-1z" />
<path d="M12 13h9v9l-9-1z" />
</svg>
);
}
if (kind === "linux")
{
return (
<svg
width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth={STROKE}
strokeLinecap="round" strokeLinejoin="round" {...rest}
>
<path d="M9 4c-1 2-1 4 0 6" />
<path d="M15 4c1 2 1 4 0 6" />
<path d="M7 12c0 4 2 8 5 8s5-4 5-8" />
<path d="M7 12c-2 1-3 3-3 5" />
<path d="M17 12c2 1 3 3 3 5" />
<circle cx="10" cy="9" r="0.6" fill="currentColor" />
<circle cx="14" cy="9" r="0.6" fill="currentColor" />
</svg>
);
}
if (kind === "tablet")
{
return <Tablet size={size} strokeWidth={STROKE} {...(rest as any)} />;
}
// unknown → 默认手机轮廓
return <Smartphone size={size} strokeWidth={STROKE} {...(rest as any)} />;
}
+35
View File
@@ -0,0 +1,35 @@
import type { CSSProperties } from "react";
export type StateDotTone = "online" | "offline" | "active" | "error" | "warn";
export interface StateDotProps
{
tone: StateDotTone;
/** 直径,默认 6px。 */
size?: number;
title?: string;
}
// 6px 圆点。color 随状态过渡,无光晕装饰。
const TONE_COLOR: Record<StateDotTone, string> = {
online: "var(--state-online)",
offline: "var(--text-muted)",
active: "var(--accent)",
error: "var(--state-error)",
warn: "var(--state-warn)",
};
export function StateDot(props: StateDotProps)
{
const { tone, size = 6, title } = props;
const style: CSSProperties = {
display: "inline-block",
width: size,
height: size,
borderRadius: "50%",
background: TONE_COLOR[tone],
transition: "background-color var(--dur-slow) var(--ease-in-out)",
flexShrink: 0,
};
return <span style={style} role={title ? "img" : undefined} aria-label={title} title={title} />;
}
+5
View File
@@ -0,0 +1,5 @@
export { StateDot } from "./StateDot";
export type { StateDotProps, StateDotTone } from "./StateDot";
export { DeviceTypeIcon } from "./DeviceTypeIcon";
export type { DeviceTypeIconProps, DeviceKind } from "./DeviceTypeIcon";
+71
View File
@@ -0,0 +1,71 @@
/* Badge:内联状态标签。tone 决定颜色族。 */
.badge
{
display: inline-flex;
align-items: center;
gap: 4px;
font-family: var(--font-sans);
font-size: var(--fs-11);
font-weight: 500;
line-height: 1;
letter-spacing: var(--ls-label);
text-transform: uppercase;
border-radius: var(--radius-pill);
padding: 3px 8px;
white-space: nowrap;
}
.neutral
{
background: var(--surface-sunken);
color: var(--text-muted);
}
.accent
{
background: var(--accent-soft);
color: var(--accent);
}
.online
{
background: color-mix(in srgb, var(--state-online) 16%, transparent);
color: var(--state-online);
}
.error
{
background: color-mix(in srgb, var(--state-error) 14%, transparent);
color: var(--state-error);
}
.warn
{
background: color-mix(in srgb, var(--state-warn) 16%, transparent);
color: var(--state-warn);
}
.info
{
background: var(--info-soft);
color: var(--info);
}
.emphasis
{
background: var(--emphasis-soft);
color: var(--emphasis);
}
.dot
{
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
/* labelchildren 统一包在 inline span(见 Badge.tsx 注释),避免聚珍在混排文本里
注入的 jz-* 行内元素被 .badge 的 inline-flex 当成独立 flex item、让 gap:4px 在
文本中间叠加。span 保持 inline,内部按内联流排版,不触发 flex item 化。 */
.label { display: inline; }
+42
View File
@@ -0,0 +1,42 @@
import type { HTMLAttributes, ReactNode } from "react";
import s from "./Badge.module.css";
export type BadgeTone = "neutral" | "accent" | "online" | "error" | "warn" | "info" | "emphasis";
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement>
{
tone?: BadgeTone;
/** 在文字左侧加一个状态点。 */
dot?: boolean;
children: ReactNode;
}
const TONE_CLASS: Record<BadgeTone, string> = {
neutral: s.neutral,
accent: s.accent,
online: s.online,
error: s.error,
warn: s.warn,
info: s.info,
emphasis: s.emphasis,
};
export function Badge(props: BadgeProps)
{
const { tone = "neutral", dot, className, children, ...rest } = props;
// cjk-isolate 全局类:Badge 是「视觉文本块」——自带背景的 chip,聚珍归为隔离类
// (而非整体 pill):两端与相邻 CJK/Latin 文本补 0.25em 边界间距,内部文字仍照常排版。
const cls = [ s.badge, TONE_CLASS[tone], "cjk-isolate", className ?? "" ]
.filter(Boolean).join(" ");
return (
<span className={cls} {...rest}>
{dot && <span className={s.dot} aria-hidden="true" />}
{/* children 包在单个 inline span.badge 是 inline-flex + gap,若聚珍
在混排 / 带标点的 children 内注入 jz-hws / jz-char,这些行内元素会
各自成为独立 flex item、让 gap 在文本中间叠加(同 Button.tsx 的 label
span)。包成 span 后整段算 1 个 flex item,聚珍仅在 span 内部按内联流处理。 */}
<span className={s.label}>{children}</span>
</span>
);
}
+134
View File
@@ -0,0 +1,134 @@
/* Buttonvariant primary/secondary/ghost/danger × size sm/md/lg。
全部消费语义令牌;hover/active/disabled 状态由 :hover/:active/:disabled 处理。 */
.btn
{
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
font-family: var(--font-sans);
font-weight: 500;
line-height: var(--lh-ui);
border: 1px solid transparent;
border-radius: var(--radius-control);
cursor: pointer;
user-select: none;
white-space: nowrap;
text-decoration: none;
transition:
background-color var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
}
.btn:disabled
{
cursor: not-allowed;
opacity: 0.55;
}
.btn:focus-visible
{
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* sizes */
.sm { font-size: var(--fs-12); padding: 4px 10px; min-height: 28px; }
.md { font-size: var(--fs-13); padding: 6px 14px; min-height: 34px; }
.lg { font-size: var(--fs-15); padding: 9px 18px; min-height: 40px; }
/* variants */
.primary
{
background: var(--accent);
color: var(--accent-on);
border-color: var(--accent);
}
.primary:hover:not(:disabled)
{
background: color-mix(in srgb, var(--accent) 88%, black);
border-color: color-mix(in srgb, var(--accent) 88%, black);
}
.primary:active:not(:disabled)
{
background: color-mix(in srgb, var(--accent) 78%, black);
}
.secondary
{
background: var(--surface);
color: var(--text);
border-color: var(--divider);
}
.secondary:hover:not(:disabled)
{
background: var(--surface-raised);
border-color: color-mix(in srgb, var(--divider) 60%, var(--text));
}
.secondary:active:not(:disabled)
{
background: var(--surface-sunken);
}
.ghost
{
background: transparent;
color: var(--text);
border-color: transparent;
}
.ghost:hover:not(:disabled)
{
background: var(--accent-soft);
color: var(--accent);
}
.ghost:active:not(:disabled)
{
background: color-mix(in srgb, var(--accent) 20%, transparent);
}
.danger
{
background: var(--state-error);
color: #FFFFFF;
border-color: var(--state-error);
}
.danger:hover:not(:disabled)
{
background: color-mix(in srgb, var(--state-error) 86%, black);
border-color: color-mix(in srgb, var(--state-error) 86%, black);
}
/* label:文本统一包在 span 里(见 Button.tsx 注释),避免聚珍(Juzhenshim 拆段后
每个片段成独立 anonymous flex item 让 .btn 的 gap 在文本中间被叠加。
span 保持 inline,内部 shim span + 文本节点都按内联流排版,既不再触发
flex item 化,也不引入新的 gap。 */
.label { display: inline; }
/* block (full width) */
.block { width: 100%; }
/* loading 状态:内容透明,旋转图标覆盖 */
.loading { color: transparent !important; pointer-events: none; position: relative; }
.spinner
{
position: absolute;
width: 14px;
height: 14px;
border: 1.5px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
opacity: 0.7;
animation: btn-spin 700ms linear infinite;
}
.primary.loading .spinner { color: var(--accent-on); }
.secondary.loading .spinner { color: var(--text); }
.ghost.loading .spinner { color: var(--text); }
.danger.loading .spinner { color: #FFFFFF; }
@keyframes btn-spin
{
to { transform: rotate(360deg); }
}
+78
View File
@@ -0,0 +1,78 @@
import {
forwardRef,
type ButtonHTMLAttributes,
type ReactNode,
} from "react";
import s from "./Button.module.css";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
export type ButtonSize = "sm" | "md" | "lg";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
{
variant?: ButtonVariant;
size?: ButtonSize;
/** 左侧图标(lucide-react 元素或任意 ReactNode)。 */
leftIcon?: ReactNode;
/** 右侧图标。 */
rightIcon?: ReactNode;
/** 加载态:内容隐藏,覆盖一个旋转圈,按钮自动禁用。 */
loading?: boolean;
/** width: 100%。 */
block?: boolean;
}
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: s.primary,
secondary: s.secondary,
ghost: s.ghost,
danger: s.danger,
};
const SIZE_CLASS: Record<ButtonSize, string> = {
sm: s.sm,
md: s.md,
lg: s.lg,
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) =>
{
const {
variant = "primary",
size = "md",
leftIcon, rightIcon, loading, block,
disabled, className, children, type = "button",
...rest
} = props;
const cls = [
s.btn,
VARIANT_CLASS[variant],
SIZE_CLASS[size],
block ? s.block : "",
loading ? s.loading : "",
className ?? "",
].filter(Boolean).join(" ");
return (
<button
ref={ref}
type={type}
className={cls}
disabled={disabled || loading}
{...rest}
>
{leftIcon}
{/* label 必须包在单个 span 里:button 是 inline-flex + gap,聚珍(Juzhen
shim 把文本拆成多段后,每段会成为独立 anonymous flex item,
flex gap 会被层层叠加,出现「使用 Casdoor 登录」里近一个 CJK
宽度的异常空隙。包成 span 后整段算 1 个 flex item,shim 仅在
span 内部插值,gap 只作用于 leftIcon / label / rightIcon 之间。 */}
<span className={s.label}>{children}</span>
{rightIcon}
{loading && <span className={s.spinner} aria-hidden="true" />}
</button>
);
});
Button.displayName = "Button";
+56
View File
@@ -0,0 +1,56 @@
/* Callout:薄阴影 + soft 状态背景。替代 Mantine Alert 的强色填充,
与 Flat Cards 基调一致。 */
.callout
{
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
border: 1px solid var(--divider);
border-left: 3px solid var(--divider);
font-size: var(--fs-13);
line-height: var(--lh-ui);
color: var(--text);
}
.icon { flex-shrink: 0; margin-top: 1px; }
.body { flex: 1; min-width: 0; }
.title { font-weight: 600; margin-bottom: 2px; }
/* 角色色编码:左色条 + soft 背景 + 同色标题 / 图标。 */
.toneError
{
background: var(--error-soft);
border-left-color: var(--error);
}
.toneError .body { color: var(--text); }
.toneError .icon, .toneError .title { color: var(--error); }
.toneWarn
{
background: var(--warn-soft);
border-left-color: var(--warn);
}
.toneWarn .body { color: var(--text); }
.toneWarn .icon, .toneWarn .title { color: var(--warn); }
.toneOk
{
background: var(--success-soft);
border-left-color: var(--success);
}
.toneOk .body { color: var(--text); }
.toneOk .icon, .toneOk .title { color: var(--success); }
.toneInfo
{
background: var(--info-soft);
border-left-color: var(--info);
}
.toneInfo .body { color: var(--text); }
.toneInfo .icon, .toneInfo .title { color: var(--info); }
+35
View File
@@ -0,0 +1,35 @@
import type { ReactNode } from "react";
import s from "./Callout.module.css";
export type CalloutTone = "error" | "warn" | "ok" | "info";
export interface CalloutProps
{
tone?: CalloutTone;
icon?: ReactNode;
title?: ReactNode;
children: ReactNode;
}
const TONE_CLASS: Record<CalloutTone, string> = {
error: s.toneError,
warn: s.toneWarn,
ok: s.toneOk,
info: s.toneInfo,
};
export function Callout(props: CalloutProps)
{
const { tone = "info", icon, title, children } = props;
return (
<div className={`${s.callout} ${TONE_CLASS[tone]}`} role={tone === "error" ? "alert" : undefined}>
{icon && <span className={s.icon}>{icon}</span>}
{/* .justify 由 cjk.css 提供:在 CJK lang 下让 body 段落走两端对齐;
title 不需要(短句),用单独 div 隔开避免被 .justify * 子树覆盖。 */}
<div className={s.body}>
{title && <div className={s.title}>{title}</div>}
<div className="justify" data-jz-level="paragraph">{children}</div>
</div>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
/* Fieldlabel + control + hint/error 一体。control 是 input / textarea / select。
提供 inputClass / textareaClass 以便外部直接套用。 */
.field
{
display: flex;
flex-direction: column;
gap: var(--space-1);
font-family: var(--font-sans);
}
.label
{
font-size: var(--fs-12);
font-weight: 500;
color: var(--text-muted);
letter-spacing: var(--ls-label);
text-transform: uppercase;
}
.required::after
{
content: " *";
color: var(--state-error);
}
.input
{
width: 100%;
font-family: inherit;
font-size: var(--fs-13);
line-height: var(--lh-ui);
color: var(--text);
background: var(--surface);
border: 1px solid var(--divider);
border-radius: var(--radius-control);
padding: 7px 10px;
transition:
border-color var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
}
.input:hover:not(:disabled):not(:focus)
{
border-color: color-mix(in srgb, var(--divider) 60%, var(--text));
}
.input:focus
{
border-color: var(--accent);
outline: none;
box-shadow: 0 0 0 3px var(--accent-soft);
}
.input:disabled
{
background: var(--surface-sunken);
cursor: not-allowed;
opacity: 0.7;
}
.input::placeholder
{
color: var(--text-muted);
opacity: 0.6;
}
.textarea
{
composes: input;
resize: vertical;
min-height: 80px;
line-height: var(--lh-body);
}
.invalid
{
border-color: var(--state-error) !important;
}
.invalid:focus
{
box-shadow: 0 0 0 3px color-mix(in srgb, var(--state-error) 22%, transparent) !important;
}
.hint
{
font-size: var(--fs-11);
color: var(--text-muted);
margin-top: 2px;
}
.error
{
font-size: var(--fs-11);
color: var(--state-error);
margin-top: 2px;
}
+100
View File
@@ -0,0 +1,100 @@
import {
forwardRef,
useId,
type InputHTMLAttributes,
type TextareaHTMLAttributes,
type ReactNode,
} from "react";
import s from "./Field.module.css";
interface BaseProps
{
label?: ReactNode;
hint?: ReactNode;
error?: ReactNode;
required?: boolean;
}
export interface TextFieldProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "size">,
BaseProps
{}
export interface TextAreaProps
extends TextareaHTMLAttributes<HTMLTextAreaElement>,
BaseProps
{}
function Wrap(props: {
id: string;
label?: ReactNode;
hint?: ReactNode;
error?: ReactNode;
required?: boolean;
children: ReactNode;
})
{
const { id, label, hint, error, required, children } = props;
return (
<div className={s.field}>
{label && (
<label
htmlFor={id}
className={[ s.label, required ? s.required : "" ].filter(Boolean).join(" ")}
>
{label}
</label>
)}
{children}
{error
? <div className={`${s.error} justify`} role="alert" data-jz-level="paragraph">{error}</div>
: hint ? <div className={`${s.hint} justify`} data-jz-level="paragraph">{hint}</div> : null}
</div>
);
}
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>((props, ref) =>
{
const { id, label, hint, error, required, className, ...rest } = props;
const auto = useId();
const inputId = id ?? auto;
const cls = [ s.input, error ? s.invalid : "", className ?? "" ]
.filter(Boolean).join(" ");
return (
<Wrap id={inputId} label={label} hint={hint} error={error} required={required}>
<input
ref={ref}
id={inputId}
className={cls}
aria-invalid={error ? true : undefined}
{...rest}
/>
</Wrap>
);
});
TextField.displayName = "TextField";
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>((props, ref) =>
{
const { id, label, hint, error, required, className, ...rest } = props;
const auto = useId();
const inputId = id ?? auto;
const cls = [ s.textarea, error ? s.invalid : "", className ?? "" ]
.filter(Boolean).join(" ");
return (
<Wrap id={inputId} label={label} hint={hint} error={error} required={required}>
<textarea
ref={ref}
id={inputId}
className={cls}
aria-invalid={error ? true : undefined}
{...rest}
/>
</Wrap>
);
});
TextArea.displayName = "TextArea";
@@ -0,0 +1,51 @@
/* IconButton:纯图标按钮,正方形。aria-label 必填,由调用方提供。 */
.btn
{
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-control);
cursor: pointer;
padding: 0;
transition:
background-color var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out);
}
.btn:hover:not(:disabled)
{
background: var(--accent-soft);
color: var(--accent);
}
.btn:active:not(:disabled)
{
background: color-mix(in srgb, var(--accent) 20%, transparent);
}
.btn:disabled
{
cursor: not-allowed;
opacity: 0.5;
}
.btn:focus-visible
{
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.sm { width: 28px; height: 28px; }
.md { width: 34px; height: 34px; }
.lg { width: 40px; height: 40px; }
/* 选中态(toggle/active */
.selected
{
background: var(--accent-soft);
color: var(--accent);
}
+39
View File
@@ -0,0 +1,39 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import s from "./IconButton.module.css";
export type IconButtonSize = "sm" | "md" | "lg";
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
{
/** Required: 图标按钮没有可见文本,必须有 aria-label。 */
"aria-label": string;
size?: IconButtonSize;
selected?: boolean;
children: ReactNode;
}
const SIZE_CLASS: Record<IconButtonSize, string> = {
sm: s.sm,
md: s.md,
lg: s.lg,
};
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>((props, ref) =>
{
const { size = "md", selected, className, children, type = "button", ...rest } = props;
const cls = [
s.btn,
SIZE_CLASS[size],
selected ? s.selected : "",
className ?? "",
].filter(Boolean).join(" ");
return (
<button ref={ref} type={type} className={cls} {...rest}>
{children}
</button>
);
});
IconButton.displayName = "IconButton";
+73
View File
@@ -0,0 +1,73 @@
/* 卡片容器:Flat Cards 基线。1px divider 描边 + 1 层阴影完成模块分割。
描边在浅色主题里让卡片轮廓清晰,深色主题里同样有视觉边界。 */
.panel
{
background: var(--surface);
border-radius: var(--radius-card);
border: 1px solid var(--divider);
box-shadow: var(--shadow-card);
padding: var(--space-4);
transition:
background-color var(--dur-base) var(--ease-out),
box-shadow var(--dur-base) var(--ease-out);
}
.padTight { padding: var(--space-3); }
.padDefault { padding: var(--space-4); }
.padLoose { padding: var(--space-5); }
.padNone { padding: 0; }
.raised
{
background: var(--surface-raised);
box-shadow: var(--shadow-raised);
}
.sunken
{
background: var(--surface-sunken);
box-shadow: none;
}
.flat
{
box-shadow: none;
}
/* primary:关键卡(如 Composer)用,顶部 3px accent 条 + 略微提升的表面。
语义上"这是用户当前关注的主体操作"。 */
.primary
{
background: var(--surface-elev2);
box-shadow: var(--shadow-raised);
position: relative;
}
.primary::before
{
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--accent);
border-radius: var(--radius-card) var(--radius-card) 0 0;
}
.header
{
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.title
{
font-size: var(--fs-13);
font-weight: 600;
letter-spacing: var(--ls-label);
text-transform: uppercase;
color: var(--text-muted);
}
+58
View File
@@ -0,0 +1,58 @@
import type { CSSProperties, HTMLAttributes, ReactNode } from "react";
import s from "./Panel.module.css";
export type PanelVariant = "default" | "raised" | "sunken" | "flat" | "primary";
export type PanelPadding = "none" | "tight" | "default" | "loose";
// 排除原生 titlestring — 我们的 title 接受 ReactNode 用于卡顶标签栏。
export interface PanelProps extends Omit<HTMLAttributes<HTMLDivElement>, "title">
{
variant?: PanelVariant;
padding?: PanelPadding;
/** 顶部标签栏(uppercase tracked label + 任意右侧操作元素)。 */
title?: ReactNode;
actions?: ReactNode;
children?: ReactNode;
style?: CSSProperties;
className?: string;
}
const PAD_CLASS: Record<PanelPadding, string> = {
none: s.padNone,
tight: s.padTight,
default: s.padDefault,
loose: s.padLoose,
};
const VARIANT_CLASS: Record<PanelVariant, string> = {
default: "",
raised: s.raised,
sunken: s.sunken,
flat: s.flat,
primary: s.primary,
};
export function Panel(props: PanelProps)
{
const {
variant = "default",
padding = "default",
title, actions, children,
className, ...rest
} = props;
const cls = [ s.panel, VARIANT_CLASS[variant], PAD_CLASS[padding], className ]
.filter(Boolean).join(" ");
return (
<div className={cls} {...rest}>
{(title || actions) && (
<div className={s.header}>
<div className={s.title}>{title}</div>
<div>{actions}</div>
</div>
)}
{children}
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
/* Tabssegmented control。一行 button 列表 + 选中态用 surface-raised + accent 文字。 */
.tabs
{
display: inline-flex;
background: var(--surface-sunken);
border-radius: var(--radius-control);
padding: 3px;
gap: 2px;
font-family: var(--font-sans);
}
.tab
{
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
font: inherit;
font-size: var(--fs-13);
font-weight: 500;
color: var(--text-muted);
background: transparent;
border: 0;
border-radius: 4px;
padding: 5px 12px;
cursor: pointer;
transition:
background-color var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out);
}
.tab:hover
{
color: var(--text);
}
.tab:focus-visible
{
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.tabActive
{
background: var(--surface);
color: var(--accent);
}
.fullWidth { display: flex; width: 100%; }
.fullWidth .tab { flex: 1; }
+44
View File
@@ -0,0 +1,44 @@
import type { ReactNode } from "react";
import s from "./Tabs.module.css";
export interface TabItem<T extends string>
{
value: T;
label: ReactNode;
}
export interface TabsProps<T extends string>
{
items: TabItem<T>[];
value: T;
onChange: (v: T) => void;
fullWidth?: boolean;
"aria-label"?: string;
}
export function Tabs<T extends string>(props: TabsProps<T>)
{
const { items, value, onChange, fullWidth, "aria-label": ariaLabel } = props;
const cls = [ s.tabs, fullWidth ? s.fullWidth : "" ].filter(Boolean).join(" ");
return (
<div role="tablist" aria-label={ariaLabel} className={cls}>
{items.map((it) =>
{
const active = it.value === value;
return (
<button
key={it.value}
role="tab"
type="button"
aria-selected={active}
className={[ s.tab, active ? s.tabActive : "" ].filter(Boolean).join(" ")}
onClick={() => onChange(it.value)}
>
{it.label}
</button>
);
})}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More