92a03aeafd
页面 / 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(文件内留有警示)。
373 lines
15 KiB
TypeScript
373 lines
15 KiB
TypeScript
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>
|
||
);
|
||
}
|
||
|
||
// 这些路由自己撑满 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>;
|
||
}
|