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)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
@@ -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
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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: {},
|
||||
},
|
||||
};
|
||||
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 407 KiB |
|
After Width: | Height: | Size: 850 KiB |
@@ -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"
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 只在 Go,JS 不持有),故
|
||||
// 无需 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>
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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 里,防聚珍(Juzhen)shim 拆段后
|
||||
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); }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// 接收端的字节落地槽,用来避免把整个文件累积到 JS 堆。
|
||||
//
|
||||
// 背景:iOS Safari 单 tab 内存预算紧;超过后系统会节流 / 暂停 JS 执行,
|
||||
// 导致 SCTP 不再 ack → sender bufferedAmount 撑满 → 死锁(用户实测 24.4 MB
|
||||
// 卡住的根因)。改成流式写入 Origin Private File System(OPFS)后内存只占
|
||||
// 一个 chunk,iOS 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 下永远 truthy(optional
|
||||
// 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 { /* 已经清掉或没创建过 */ }
|
||||
}
|
||||
@@ -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
|
||||
// - 接收端流式写 OPFS(lib/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 绑了 onopen;sender 这里覆盖一下,等
|
||||
// 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 超过
|
||||
// kMaxQueuedSendDataBytes(16 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 经常拿不到(连接刚 connected,nominated 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_ACTIVE,UI 永远不进入 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 流程决定终态。
|
||||
}
|
||||
@@ -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 把外层 abort(cleanup / 用户取消)与 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;
|
||||
}
|
||||
@@ -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(() => ""));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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}}",
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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}}",
|
||||
};
|
||||
@@ -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] = #644AC9(Alucard Purple)
|
||||
// - dark 取 [4] = #AA99FF(Dracula Base BrightBlue,Purple 的官方亮版,
|
||||
// 在深色背景上对比度 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(); });
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 为 base64:Wails 把 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 只在 Go,JS 永不持有。
|
||||
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 只在 Go,JS 不传也不持有)。
|
||||
// Go 用自己的 refresh_token 换新 access_token(public-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 "";
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
// 这些路由自己撑满 viewport,root 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>;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 guard:completeOAuthLogin 内部会消费 sessionStorage 中的 PKCE_VERIFIER /
|
||||
// OAUTH_STATE,二次进入必然 throw "state mismatch / verifier missing",在
|
||||
// navigate(/) 真正完成之前的那一帧引发"登录失败"闪现。
|
||||
//
|
||||
// navigate 是异步的:promise resolve → setErr/navigate;navigate 实际生效之间
|
||||
// 仍可能发生一次 re-render(useNavigate 返回值在某些版本里非稳定引用,会让
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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="Field(TextField · 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">raised(hover/拖拽目标)</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>
|
||||
);
|
||||
}
|
||||
@@ -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 scheme;macOS 与浏览器无此注入、返回 ""(相对 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";
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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 }),
|
||||
});
|
||||
@@ -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 }),
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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: [] });
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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 }),
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
// 客户端侧细颗粒度阶段,与服务端的 state(PENDING/ACCEPTED/...)正交;
|
||||
// 用来把"等待中/传输中"展开成具体正在做什么,配合 lastProgressAt 区分卡住。
|
||||
export type TransferPhase =
|
||||
| "initializing" // 刚 /initiate / 收到 transfer:incoming,未起 WebRTC
|
||||
| "waiting_accept" // sender 等 /accept,receiver 等自己 /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-reflexive;relay = 通过 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 已 send,incoming 是 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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* ===== 长英文段断词 ===== 聚珍(Juzhen)longWord 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;
|
||||
}
|
||||
@@ -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-face,src 把 local() 放最前、CDN url() 兜底。同 family + 同 weight/style 的
|
||||
* @font-face 以「后定义者胜」,cdrop 打包 CSS 在 index.html 静态 <link> 之后加载,故
|
||||
* 本层覆盖 CDN:本机命中即零网络瞬时渲染,未装则回退网络(swap)。
|
||||
*
|
||||
* 覆盖范围:仅项目用到的字重(400/500/600/700)× 实际激活的 region:
|
||||
* - Orbit Gothic CJK SC / TC(Sans 正文 + Mono 的 CJK 落字)
|
||||
* - Maple Mono(Mono 拉丁 / 符号)
|
||||
* 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 Mono(Mono 拉丁 / 符号)===== */
|
||||
@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");
|
||||
}
|
||||
@@ -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> = SC/TC/JP/KR;: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.net(Google 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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 / Amber(README 指定)。 */
|
||||
.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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Wordmark } from "./Wordmark";
|
||||
export type { WordmarkProps } from "./Wordmark";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { toast, ToastViewport } from "./Toast";
|
||||
export type { ToastInput, ToastTone } from "./Toast";
|
||||
@@ -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)} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { StateDot } from "./StateDot";
|
||||
export type { StateDotProps, StateDotTone } from "./StateDot";
|
||||
|
||||
export { DeviceTypeIcon } from "./DeviceTypeIcon";
|
||||
export type { DeviceTypeIconProps, DeviceKind } from "./DeviceTypeIcon";
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* label:children 统一包在 inline span(见 Badge.tsx 注释),避免聚珍在混排文本里
|
||||
注入的 jz-* 行内元素被 .badge 的 inline-flex 当成独立 flex item、让 gap:4px 在
|
||||
文本中间叠加。span 保持 inline,内部按内联流排版,不触发 flex item 化。 */
|
||||
.label { display: inline; }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/* Button:variant 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 注释),避免聚珍(Juzhen)shim 拆段后
|
||||
每个片段成独立 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); }
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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); }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/* Field:label + 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
// 排除原生 title(string) — 我们的 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/* Tabs:segmented 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; }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||