Files
Commilitia-Drop/web/src/routes/__root.tsx
T
admin 92a03aeafd 推送通知:Web Push(网页 / PWA)+ 桌面原生通知
页面 / PWA 关闭、或桌面窗口非前台时,收到文件、消息、传输完成 / 失败以系统通知提醒。
判定统一为「页面打开走应用内提示,否则系统通知」,三端各自落地;网页端的判定直接复用
Hub.SendTo 的投递结果,零额外状态。

- Web Push(VAPID,账号无关):服务端 internal/push.Sender 在事件未经 SSE 投递到目标
  设备时(即该设备页面已关)发推送,文案按订阅 locale 在服务端渲染(中简 / 中繁 / 英)、
  404 / 410 自动清理死订阅;新增 push_subscriptions 表(含 locale,主键为 endpoint 的
  SHA-256 以幂等 upsert)+ /api/push/{vapid-key, subscribe POST/DELETE} 端点
- 事件挂接:transfer:incoming / message(离线返 202,文本随推送送达)/ transfer:state
  DONE|FAILED,均未投递才推
- 配置 CDROP_VAPID_PUBLIC_KEY / CDROP_VAPID_PRIVATE_KEY(半对即拒启动,都空则推送惰性
  关闭)+ just vapid-keygen 生成工具(cmd/vapidgen)
- 前端:service worker 加 push / notificationclick(仍不拦截 /api 与导航);net/push.ts
  订阅管理 + push store slice + 设置页「推送通知」面板(仅浏览器 / PWA);登录后 resyncPush
  把既有订阅重新登记到当前用户,换用户 / endpoint 轮换 / 切换语言皆自愈
- 桌面原生通知:platform/notify_{darwin,windows,other}.go + notify_darwin.m,仅窗口非
  前台时弹原生通知(macOS UNUserNotificationCenter,未签名时安全空操作、待签名后显示;
  Windows go-toast 即用);app.go ShowNotification 绑定,前端 notifyNativeIfBackground
  复用 hub 事件分发 + 客户端本地化
- 文档:README / .env.example / compose 模板补 CDROP_VAPID_* 配置

注:sqlc v1.31.1 对 query 文件里的多字节字符会错位偏移、生成损坏 SQL,故 query 注释保持纯
ASCII(文件内留有警示)。
2026-06-18 16:56:47 +08:00

373 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { resyncPush } from "../net/push";
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();
// 已开启推送的浏览器:登录后把订阅重新登记到当前用户名下(自愈换用户 /
// endpoint 轮换 / locale 变更);未开启或桌面端为 no-op。
void resyncPush();
// 桌面:本机复制 → 上传云端(复用 uploadClipboard 链路;浏览器里为 no-op)。
// 上传失败静默——不打断用户,下次复制再试。
const offNativeClipboard = onNativeClipboard((text) =>
{
void uploadClipboard(text).catch(() => { /* ignore */ });
});
return () =>
{
ctrl.abort();
stopICEServerRefresh();
offNativeClipboard();
};
}, [ user?.id, selfDeviceName ]);
const handleLogout = () =>
{
logout();
navigate({ to: "/login", search: { dev_user: undefined } });
};
if (fullscreen) { return <Outlet />; }
return (
<AppShell header={{ height: 56 }} padding="md">
<AppShell.Header>
<Group h="100%" px="md" justify="space-between" wrap="nowrap" gap="xs">
<Link
to="/"
style={{
textDecoration: "none",
color: "inherit",
minWidth: 0,
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
}}
>
<Title
order={4}
fw={600}
style={{ whiteSpace: "nowrap", fontSize: "var(--fs-22)" }}
>
<Wordmark />
</Title>
</Link>
{user && (
<Group gap={8} wrap="nowrap">
<ConnectionIcon connected={sseConnected} />
<ThemeQuickToggle theme={theme} onChange={setTheme} />
<Menu position="bottom-end" width={240} withArrow shadow="md">
<Menu.Target>
<UnstyledButton
aria-label={t("nav.accountMenu")}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "4px 6px",
borderRadius: 6,
}}
>
<UserAvatar
name={user.name}
avatarUrl={user.avatar}
size={28}
/>
<Text size="sm" visibleFrom="sm" truncate maw={140}>
{user.name}
</Text>
<ChevronDown size={14} />
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Stack gap={2} px="sm" pt="xs" pb={6}>
<Text size="sm" fw={500} truncate>{user.name}</Text>
<Text size="xs" c="dimmed">
{t("nav.thisDeviceLabel")}
<Text span fw={500}>{selfDeviceName}</Text>
</Text>
<Text
size="xs"
style={{ color: sseConnected ? "var(--state-online)" : "var(--text-muted)" }}
>
{sseConnected
? `${t("app.connected")}`
: `${t("app.connecting")}`}
</Text>
</Stack>
<Menu.Divider />
<Menu.Label>{t("nav.theme")}</Menu.Label>
{/* System 列于最上 — 默认跟随系统,最常用。 */}
<ThemeItem
mode="system"
label={t("nav.theme.system")}
icon={<Monitor size={14} />}
current={theme}
onSelect={setTheme}
/>
<ThemeItem
mode="light"
label={t("nav.theme.light")}
icon={<Sun size={14} />}
current={theme}
onSelect={setTheme}
/>
<ThemeItem
mode="dark"
label={t("nav.theme.dark")}
icon={<Moon size={14} />}
current={theme}
onSelect={setTheme}
/>
<Menu.Divider />
<Menu.Item
leftSection={<Settings size={14} />}
onClick={() => navigate({ to: "/settings" })}
>
{t("nav.settings")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>{t("nav.language")}</Menu.Label>
<LocaleItem
locale="zh-CN"
label="简体中文"
current={locale}
onSelect={setLocale}
/>
<LocaleItem
locale="zh-TW"
label="繁體中文"
current={locale}
onSelect={setLocale}
/>
<LocaleItem
locale="en-US"
label="English"
current={locale}
onSelect={setLocale}
/>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<LogOut size={14} />}
onClick={handleLogout}
>
{t("nav.signOut")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
)}
</Group>
</AppShell.Header>
<AppShell.Main>
{sseReconnecting && (
<div style={{ marginBottom: "var(--space-4)" }}>
<Callout
tone="error"
icon={<WifiOff size={16} />}
title={t("app.disconnectedTitle")}
>
{t("app.disconnectedHint")}
</Callout>
</div>
)}
<Outlet />
</AppShell.Main>
</AppShell>
);
}
// Header 上的快捷按钮:单击在三态间循环 system → light → dark → system。
// 图标随当前模式切换;hover 显示 tooltip 提示下一步会切到什么。
function ThemeQuickToggle(props: { theme: ThemeMode; onChange: (m: ThemeMode) => void })
{
const { theme, onChange } = props;
const next: ThemeMode = theme === "system" ? "light" : theme === "light" ? "dark" : "system";
const icon = theme === "light" ? <Sun size={16} />
: theme === "dark" ? <Moon size={16} />
: <Monitor size={16} />;
const label = `${t("nav.themeToggle")}${t(`nav.theme.${theme}` as const)}${t(`nav.theme.${next}` as const)}`;
return (
<Tooltip label={label}>
<IconButton
aria-label={label}
size="sm"
onClick={() => onChange(next)}
>
{icon}
</IconButton>
</Tooltip>
);
}
function ThemeItem(props: {
mode: ThemeMode;
label: string;
icon: ReactNode;
current: ThemeMode;
onSelect: (m: ThemeMode) => void;
})
{
const { mode, label, icon, current, onSelect } = props;
const active = mode === current;
return (
<Menu.Item
leftSection={active
? <Check size={14} />
: <span style={{ display: "inline-block", width: 14 }} />}
rightSection={icon}
onClick={() => { if (!active) { onSelect(mode); } }}
>
{label}
</Menu.Item>
);
}
function LocaleItem(props: {
locale: Locale;
label: string;
current: Locale;
onSelect: (l: Locale) => void;
})
{
const { locale, label, current, onSelect } = props;
const active = locale === current;
return (
<Menu.Item
leftSection={active
? <Check size={14} />
: <span style={{ display: "inline-block", width: 14 }} />}
onClick={() => { if (!active) { onSelect(locale); } }}
>
{label}
</Menu.Item>
);
}
function ConnectionIcon(props: { connected: boolean })
{
const { connected } = props;
const color = connected ? "var(--state-online)" : "var(--text-muted)";
const label = connected ? t("app.connected") : t("app.connecting");
return (
<span
aria-label={label}
title={label}
style={{ display: "inline-flex", alignItems: "center" }}
>
{connected
? <Wifi size={16} color={color} />
: <WifiOff size={16} color={color} />}
</span>
);
}
// 这些路由自己撑满 viewportroot layout 不再套 AppShell。
function isAuthRoute(pathname: string): boolean
{
return pathname === "/login"
|| pathname === "/setup"
|| pathname.startsWith("/oauth/");
}
function initial(name: string): string
{
const trimmed = name.trim();
if (!trimmed) { return "?"; }
// 取首个码点(兼容 emoji / 多字节中文姓名)。
const first = Array.from(trimmed)[0];
return first.toUpperCase();
}
// 头像:优先 Casdoor 提供的图(user.avatar)。URL 缺失或加载失败时回退到
// 字母方块 —— 浅色主题白底黑字、深色主题黑底白字(与 Dracula 调色对比最强)。
function UserAvatar(props: { name: string; avatarUrl?: string; size?: number })
{
const { name, avatarUrl, size = 28 } = props;
const [ broken, setBroken ] = useState(false);
const fontSize = Math.round(size * 0.42);
const baseStyle: React.CSSProperties = {
width: size,
height: size,
borderRadius: "50%",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
flexShrink: 0,
userSelect: "none",
};
if (avatarUrl && !broken)
{
return (
<img
src={avatarUrl}
alt=""
referrerPolicy="no-referrer"
onError={() => setBroken(true)}
style={{ ...baseStyle, objectFit: "cover" }}
/>
);
}
// 字母方块:颜色由语义令牌驱动,主题切换时自然反转。
const letterStyle: React.CSSProperties = {
...baseStyle,
background: "var(--avatar-bg)",
color: "var(--avatar-fg)",
fontFamily: "var(--font-sans)",
fontWeight: 600,
fontSize,
lineHeight: 1,
letterSpacing: 0,
};
return <span style={letterStyle} aria-label={name}>{initial(name)}</span>;
}