cdrop — 跨 OS 剪贴板与文件传输服务
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。 - 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。 - 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。 - 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。 - 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。 架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user