cdrop — 跨 OS 剪贴板与文件传输服务

Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。

- 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。
- 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。
- 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。
- 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。

架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。

本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
This commit is contained in:
2026-06-15 21:38:28 +08:00
commit f21fa5b5e8
239 changed files with 29010 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
import { zhCN } from "./locales/zh-CN";
import { zhTW } from "./locales/zh-TW";
import { enUS } from "./locales/en-US";
// t() 仍是纯函数,方便 lib 层非 React 上下文里调用。
// 切换语言由 store 触发:store.setLocale 会同时同步到此模块(写 localStorage
// + 更新 module state);UI 通过 RouterProvider key={locale} 强制重挂载。
// zhCN 用 as const 锁定 key 集合作为类型推导基准,但值仍按 string 处理,
// 否则 Partial<TranslationDict> 会要求其他 locale 重复同样的 literal。
export type TranslationKey = keyof typeof zhCN;
export type TranslationDict = Record<TranslationKey, string>;
export type Locale = "zh-CN" | "zh-TW" | "en-US";
export const SUPPORTED_LOCALES: readonly Locale[] = [ "zh-CN", "zh-TW", "en-US" ] as const;
const dictionaries: Record<Locale, Partial<TranslationDict>> = {
"zh-CN": zhCN,
"zh-TW": zhTW,
"en-US": enUS,
};
const STORAGE_KEY = "cdrop.locale";
function readStorageLocale(): Locale | null
{
if (typeof window === "undefined") { return null; }
const v = window.localStorage.getItem(STORAGE_KEY);
if (v === "zh-CN" || v === "zh-TW" || v === "en-US") { return v; }
return null;
}
// 默认 locale:遍历 navigator.languages,先撞到一个中文标签(zh*)就按下面
// 的细分逻辑判定简繁;先撞到 en 取英文;二者都没有则回退英文。
//
// 中文细分:
// 1. 标签内含 "hans" → 简体;含 "hant" → 繁体
// 2. 区域子标签:cn / sg → 简体;tw / hk / mo → 繁体
// 3. 都不命中 → 简体(覆盖裸 "zh" 与未知地区)
function detectBrowserLocale(): Locale
{
if (typeof navigator === "undefined") { return "en-US"; }
const langs = navigator.languages && navigator.languages.length > 0
? navigator.languages
: ( navigator.language ? [ navigator.language ] : [] );
for (const raw of langs)
{
const l = raw.toLowerCase();
if (l.startsWith("zh")) { return classifyChinese(l); }
if (l.startsWith("en")) { return "en-US"; }
}
return "en-US";
}
function classifyChinese(lower: string): Locale
{
if (lower.includes("hans")) { return "zh-CN"; }
if (lower.includes("hant")) { return "zh-TW"; }
const parts = lower.split("-");
for (const p of parts)
{
if (p === "cn" || p === "sg") { return "zh-CN"; }
if (p === "tw" || p === "hk" || p === "mo") { return "zh-TW"; }
}
return "zh-CN";
}
let currentLocale: Locale = readStorageLocale() ?? detectBrowserLocale();
if (typeof document !== "undefined")
{
document.documentElement.lang = currentLocale;
}
const PLACEHOLDER = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
export function setLocale(locale: Locale): void
{
currentLocale = locale;
if (typeof window !== "undefined")
{
window.localStorage.setItem(STORAGE_KEY, locale);
}
if (typeof document !== "undefined")
{
document.documentElement.lang = locale;
}
}
export function getLocale(): Locale
{
return currentLocale;
}
export function t(key: TranslationKey, vars?: Record<string, string | number>): string
{
// currentLocale 缺失的 key 自动 fallback 到 zh-CN。
const template = dictionaries[currentLocale][key] ?? zhCN[key];
if (!vars) { return template; }
return template.replace(PLACEHOLDER, (_, name: string) =>
{
const v = vars[name];
return v === undefined ? "" : String(v);
});
}
+243
View File
@@ -0,0 +1,243 @@
import type { TranslationDict } from "../index";
// 缺失的 key 会自动 fallback 到 zh-CN(基准词典)。
export const enUS: Partial<TranslationDict> = {
// ---- app / shell -----------------------------------------------------
"app.brand": "Commilitia Drop",
"deviceType.browser": "Browser",
"deviceType.macos": "macOS client",
"deviceType.windows": "Windows client",
"deviceType.linux": "Linux client",
"deviceType.ios": "iOS client",
"deviceType.shortcut": "Shortcut",
"app.connected": "Online",
"app.connecting": "Connecting…",
"app.disconnectedTitle": "Reconnecting",
"app.disconnectedHint":
"Lost connection to the server — retrying automatically. No need to refresh.",
// ---- header / user menu ---------------------------------------------
"nav.accountMenu": "Account menu",
"nav.thisDeviceLabel": "This device: ",
"nav.settings": "Settings",
"nav.signOut": "Sign out",
"nav.backToHome": "← Home",
"nav.language": "语言 / Language",
"nav.theme": "主题 / Theme",
"nav.theme.light": "Light",
"nav.theme.dark": "Dark",
"nav.theme.system": "Follow system",
"nav.themeToggle": "Toggle theme",
// ---- home -----------------------------------------------------------
"home.greeting": "Hello, {{name}}",
"home.deviceList.title": "Devices",
"home.deviceList.onlineSuffix": "({{count}} online)",
"home.deviceList.showOffline": "Show offline ({{count}})",
"home.deviceList.empty":
"No peer devices yet. Open the app in another tab with a different device name (Settings → Device name) to see it here.",
"home.tabs.file": "File",
"home.tabs.message": "Message",
"home.tabs.clipboard": "Clipboard",
"home.file.dropzone": "Drop a file here or click to browse",
"home.file.selectedSize": "{{name}} ({{size}})",
"home.file.rejected": "File rejected",
"home.cta.selectDeviceAndFile": "Select a device and a file",
"home.cta.selectFile": "Select a file",
"home.cta.selectDevice": "Select a device",
"home.cta.sendTo": "Send to {{name}}",
"home.message.placeholder": "Type a message to {{name}}…",
"home.message.placeholderEmpty": "Select a device first",
"home.message.hint": "Enter to send, Shift+Enter for newline. Max 4 KB.",
"home.message.title": "Messages",
"home.message.countSuffix": "({{count}})",
"home.message.clearAll": "Clear all",
"home.transfer.active": "Active",
"home.transfer.history": "Recent transfers",
"home.clipboard.cloudTitle": "Latest from cloud",
"home.clipboard.cloudFrom": "From {{name}} · {{time}}",
"home.clipboard.cloudFromSelf": "From this device · {{time}}",
"home.clipboard.cloudEmpty": "Nothing synced yet.",
"home.clipboard.copyToLocal": "Copy to local clipboard",
"home.clipboard.copyToLocalSuccess": "Copied to local clipboard",
"home.clipboard.uploadTitle": "Sync local clipboard",
"home.clipboard.uploadHint": "Plain text only, max 64 KB. Styles (bold / color) are stripped.",
"home.clipboard.uploadButton": "Upload local clipboard",
"home.clipboard.uploading": "Uploading…",
"home.clipboard.uploadEmpty": "Local clipboard is empty",
"home.clipboard.refresh": "Refresh",
"home.clipboard.clear": "Clear cloud",
"home.clipboard.clearConfirm": "Clear the cloud clipboard? All devices will see empty content.",
"home.clipboard.clearSuccess": "Cloud clipboard cleared",
"home.clipboard.hidden": "Content hidden — click Reveal to show",
"home.clipboard.reveal": "Reveal",
"home.clipboard.hide": "Hide",
// ---- setup ----------------------------------------------------------
"setup.title": "Name this device",
"setup.help": "The name is shown to your other devices. Avoid spaces, ≤32 chars.",
"setup.field": "Device name",
"setup.save": "Save and continue",
// ---- login ----------------------------------------------------------
"login.title": "Sign in",
"login.dev.help":
"Dev mode: pick any user identifier. Two tabs with the same identifier simulate two devices of one user.",
"login.dev.field": "Dev user id",
"login.dev.continue": "Continue",
"login.prod.help": "Sign in via Casdoor.",
"login.prod.button": "Sign in with Casdoor",
// ---- oauth ----------------------------------------------------------
"oauth.failed": "Sign-in failed",
"oauth.signingIn": "Signing in…",
"oauth.missingParams": "Missing 'code' or 'state' query parameter",
// ---- settings -------------------------------------------------------
"settings.title": "Settings",
"settings.currentDevice.title": "This device",
"settings.deviceName.field": "Device name",
"settings.deviceName.save": "Save",
"settings.deviceName.unchanged": "Name is unchanged",
"settings.deviceName.empty": "Name cannot be empty",
"settings.deviceName.asciiOnly": "Device name must use ASCII characters only (letters, numbers, symbols).",
"settings.deviceName.success": "Device name updated",
"settings.unregister.current": "Unregister this device",
"settings.unregister.currentHint":
"Removes this device's registration and signs you out.",
"settings.unregister.currentConfirm":
"Unregistering this device will remove its registration and sign you out. Continue?",
"settings.unregister.peerConfirm": "Remove device \"{{name}}\"?",
"settings.peers.title": "Other devices",
"settings.peers.empty": "No other devices.",
"settings.peers.remove": "Remove",
"settings.peers.removing": "Removing…",
"settings.error.delete": "Action failed: {{message}}",
"settings.online": "Online",
"settings.offline": "Offline",
"settings.lastSeen": "Last seen {{time}}",
"settings.lastSeenNever": "Never connected",
"settings.account.title": "Account",
"settings.account.signOut": "Sign out",
"settings.desktop.title": "Desktop",
"settings.desktop.clipboardSync": "Auto-sync clipboard",
"settings.desktop.clipboardSyncHint": "Copies are uploaded to the cloud, and clipboard updates from your other devices are applied here.",
"settings.desktop.launchAtLogin": "Launch at login",
"settings.desktop.launchAtLoginHint": "Start cdrop in the background after you sign in (lives in the menu bar).",
"settings.desktop.downloadDir": "Download folder",
"settings.desktop.downloadDirHint": "Received files are saved here. Leave unset to use the system Downloads folder.",
"settings.desktop.downloadDirChoose": "Change…",
"settings.desktop.downloadDirReset": "Reset to default",
"settings.desktop.downloadDirPicker": "Choose download folder",
"settings.desktop.ttlNote": "For security, cloud clipboard content is cleared automatically after 5 minutes.",
// ---- iOS Shortcut tokens ---------------------------------------------
"settings.shortcut.title": "iOS Shortcut",
"settings.shortcut.intro": "Issue a long-lived, clipboard-only, revocable token for the iOS Shortcuts app. Even if leaked it can only read/write the clipboard — nothing else.",
"settings.shortcut.labelField": "Label",
"settings.shortcut.labelPlaceholder": "e.g. My iPhone",
"settings.shortcut.issue": "Issue",
"settings.shortcut.empty": "No tokens issued yet.",
"settings.shortcut.loadError": "Failed to load tokens.",
"settings.shortcut.retry": "Retry",
"settings.shortcut.revoke": "Revoke",
"settings.shortcut.revoking": "Revoking…",
"settings.shortcut.revokeConfirm": "Revoking immediately disables any Shortcut using this token. Continue?",
"settings.shortcut.revoked": "Revoked",
"settings.shortcut.revokedToast": "Token revoked",
"settings.shortcut.expired": "Expired",
"settings.shortcut.expiresAt": "Expires {{date}}",
"settings.shortcut.lastUsed": "Last used {{time}}",
"settings.shortcut.neverUsed": "Never used",
"settings.shortcut.unavailable": "Shortcut tokens are not enabled on the server.",
"settings.shortcut.tooMany": "You have reached the active-token limit. Revoke an old one first.",
"settings.shortcut.issueError": "Failed: {{message}}",
"settings.shortcut.issuedTitle": "Token issued",
"settings.shortcut.onceWarning": "This token is shown only once. Copy it now — you won't be able to see it again after closing.",
"settings.shortcut.tokenLabel": "Token (Bearer)",
"settings.shortcut.copy": "Copy",
"settings.shortcut.copyFailed": "Copy failed — select and copy manually.",
"settings.shortcut.copied": "Copied",
"settings.shortcut.baseLabel": "Server address",
"settings.shortcut.guideTitle": "Set up on iOS (manual sync)",
"settings.shortcut.guideIntro": "Build two shortcuts in the iPhone Shortcuts app, filling in the server address and token above on first run:",
"settings.shortcut.guideDeviceName": "Add header X-Device-Name: your-device-name (ASCII only) to both shortcuts so it shows as one device in the list.",
"settings.shortcut.guidePullTitle": "Pull (cloud → local clipboard)",
"settings.shortcut.guidePull1": "'Get Contents of URL': set the URL to your-server-address + /api/clipboard, method GET, add header Authorization: Bearer your-token.",
"settings.shortcut.guidePull2": "'Get Dictionary Value': key content.",
"settings.shortcut.guidePull3": "If non-empty, 'Copy to Clipboard'; optionally add 'Show Notification'.",
"settings.shortcut.guidePushTitle": "Push (local clipboard → cloud)",
"settings.shortcut.guidePush1": "'Get Clipboard'; stop if empty.",
"settings.shortcut.guidePush2": "'Get Contents of URL': same URL, method PUT, headers Authorization: Bearer your-token and Content-Type: application/json, JSON body: {\"content\": clipboard, \"content_type\": \"text/plain\"}.",
"settings.shortcut.guidePush3": "Optionally add 'Show Notification'.",
"settings.shortcut.guideToleranceNote": "A single run may fail on a flaky network — just retry. The automatic polling version (next phase) retries silently in the background.",
"settings.shortcut.done": "Done",
// ---- transfer states / modes ----------------------------------------
"transfer.savedTo": "Saved to {{path}}",
"transfer.saveFailed": "Failed to save file: {{error}}",
"transfer.action.cancel": "Cancel",
"transfer.action.delete": "Delete",
"transfer.action.relayNow": "Relay now",
"transfer.action.details": "Details",
"transfer.state.PENDING": "Pending",
"transfer.state.ACCEPTED": "Accepted",
"transfer.state.P2P_ACTIVE": "Transferring",
"transfer.state.RELAY_ACTIVE": "Relaying",
"transfer.state.DONE": "Done",
"transfer.state.FAILED": "Failed",
"transfer.state.CANCELLED": "Cancelled",
"transfer.mode.p2p": "P2P",
"transfer.mode.relay": "Relay",
// ---- client phase (real-time telemetry; tells "waiting" from "stuck") ----
"transfer.phase.initializing": "Initializing…",
"transfer.phase.waiting_accept": "Waiting for the other side to accept…",
"transfer.phase.ice_gathering": "Gathering network candidates…",
"transfer.phase.ice_checking": "Establishing direct tunnel…",
"transfer.phase.ice_connected": "Tunnel established",
"transfer.phase.dc_open": "Channel open",
"transfer.phase.transferring": "Transferring",
"transfer.phase.completing": "Finalizing…",
"transfer.phase.fallback_pending": "Falling back to relay…",
"transfer.phase.relay_uploading": "Uploading via relay",
"transfer.phase.relay_downloading": "Downloading via relay",
"transfer.stalled": "Looks stalled ({{seconds}}s without progress)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/s",
// ---- debug expansion ------------------------------------------------
"transfer.debug.toggle": "Debug",
"transfer.debug.iceGathering": "Gathering",
"transfer.debug.iceConnection": "Connection",
"transfer.debug.candidatesLocal": "Local candidates",
"transfer.debug.candidatesRemote": "Remote candidates",
"transfer.debug.selectedPair": "Selected pair",
"transfer.debug.noPair": "No nominated pair yet",
"transfer.debug.candidatesLine":
"host {{host}} · mDNS {{mdns}} · srflx {{srflx}} · prflx {{prflx}} · relay {{relay}}",
"transfer.debug.dataChannel": "Data channel",
"transfer.debug.dcLine": "{{state}} · buffered {{buffered}}",
// ---- time -----------------------------------------------------------
"time.justNow": "just now",
"time.secondsAgo": "{{n}}s ago",
"time.minutesAgo": "{{n}}m ago",
"time.hoursAgo": "{{n}}h ago",
// ---- common ---------------------------------------------------------
"common.dismiss": "Dismiss",
"common.cancel": "Cancel",
"common.confirm": "Confirm",
// ---- errors ---------------------------------------------------------
"errors.messageEmpty": "Message is empty",
"errors.messageOverflow": "Message exceeds 4 KB; send a file instead",
"errors.noReceiver": "No receiver selected",
"errors.devTokenMissing":
"Dev mode: VITE_CDROP_DEV_TOKEN is not set in .env.local",
"errors.noAccessToken": "No access token; user must log in",
"errors.clipboardUnavailable": "Browser clipboard API unavailable (requires HTTPS + permission)",
"errors.clipboardOverflow": "Content too large; exceeds {{max}} byte limit",
"errors.clipboardWriteFailed": "Failed to write to local clipboard: {{message}}",
};
+240
View File
@@ -0,0 +1,240 @@
// zh-CN 是 i18n 类型推导的基准词典:必须包含全部 key。其他 locale
// 缺失的 key 会自动 fallback 到此处。
export const zhCN = {
// ---- 应用 / 全局 -----------------------------------------------------
"app.brand": "Commilitia Drop",
"deviceType.browser": "浏览器",
"deviceType.macos": "macOS 客户端",
"deviceType.windows": "Windows 客户端",
"deviceType.linux": "Linux 客户端",
"deviceType.ios": "iOS 客户端",
"deviceType.shortcut": "快捷指令",
"app.connected": "已连接",
"app.connecting": "正在连接…",
"app.disconnectedTitle": "正在重连",
"app.disconnectedHint": "与服务器的连接已断开,正在自动重连,无需刷新页面。",
// ---- 顶部栏 / 用户菜单 ------------------------------------------------
"nav.accountMenu": "账号菜单",
"nav.thisDeviceLabel": "本机:",
"nav.settings": "设置",
"nav.signOut": "退出登录",
"nav.backToHome": "← 主页",
"nav.language": "语言 / Language",
"nav.theme": "主题 / Theme",
"nav.theme.light": "浅色",
"nav.theme.dark": "深色",
"nav.theme.system": "跟随系统",
"nav.themeToggle": "切换主题",
// ---- 主页 ------------------------------------------------------------
"home.greeting": "你好,{{name}}",
"home.deviceList.title": "设备",
"home.deviceList.onlineSuffix": "{{count}} 台在线)",
"home.deviceList.showOffline": "显示离线设备({{count}}",
"home.deviceList.empty":
"暂无其他设备。在另一标签页或设备上以相同账户登录,并设置一个不同的设备名(设置 → 设备名称),即可在此处看到它。",
"home.tabs.file": "文件",
"home.tabs.message": "消息",
"home.tabs.clipboard": "剪贴板",
"home.file.dropzone": "拖放文件到此处或点击选择",
"home.file.selectedSize": "{{name}}{{size}}",
"home.file.rejected": "文件已拒绝",
"home.cta.selectDeviceAndFile": "请选择设备与文件",
"home.cta.selectFile": "请选择文件",
"home.cta.selectDevice": "请选择设备",
"home.cta.sendTo": "发送到 {{name}}",
"home.message.placeholder": "发送消息到 {{name}}…",
"home.message.placeholderEmpty": "请先选择设备",
"home.message.hint": "回车发送,Shift + 回车换行,最长4 KB。",
"home.message.title": "消息",
"home.message.countSuffix": "{{count}}",
"home.message.clearAll": "全部清除",
"home.transfer.active": "进行中",
"home.transfer.history": "最近的传输",
"home.clipboard.cloudTitle": "云端最新",
"home.clipboard.cloudFrom": "来自 {{name}} · {{time}}",
"home.clipboard.cloudFromSelf": "来自本机 · {{time}}",
"home.clipboard.cloudEmpty": "尚未同步任何内容。",
"home.clipboard.copyToLocal": "复制到本机剪贴板",
"home.clipboard.copyToLocalSuccess": "已复制到本机剪贴板",
"home.clipboard.uploadTitle": "同步本机剪贴板",
"home.clipboard.uploadHint": "仅纯文本,最大64 KB;样式(粗体/颜色等)会被剥除。",
"home.clipboard.uploadButton": "上传本机剪贴板",
"home.clipboard.uploading": "上传中…",
"home.clipboard.uploadEmpty": "本机剪贴板为空",
"home.clipboard.refresh": "刷新",
"home.clipboard.clear": "清空云端",
"home.clipboard.clearConfirm": "确认清空云端剪贴板?所有设备将看到空内容。",
"home.clipboard.clearSuccess": "已清空云端剪贴板",
"home.clipboard.hidden": "内容已隐藏 · 点击“显示”查看",
"home.clipboard.reveal": "显示",
"home.clipboard.hide": "隐藏",
// ---- 首次设置 --------------------------------------------------------
"setup.title": "为本机命名",
"setup.help": "此名称会展示给你的其他设备。建议避免空格,长度不超过 32 字符。",
"setup.field": "设备名称",
"setup.save": "保存并继续",
// ---- 登录 ------------------------------------------------------------
"login.title": "登录",
"login.dev.help":
"Dev模式:随意填写一个用户标识。同标识的两个标签页将模拟同一用户的两台设备。",
"login.dev.field": "Dev用户标识",
"login.dev.continue": "继续",
"login.prod.help": "通过Casdoor登录。",
"login.prod.button": "使用Casdoor登录",
// ---- OAuth 回调 -------------------------------------------------------
"oauth.failed": "登录失败",
"oauth.signingIn": "正在登录…",
"oauth.missingParams": "缺少code或state查询参数",
// ---- 设置页 ----------------------------------------------------------
"settings.title": "设置",
"settings.currentDevice.title": "当前设备",
"settings.deviceName.field": "设备名称",
"settings.deviceName.save": "保存",
"settings.deviceName.unchanged": "新名称与当前一致",
"settings.deviceName.empty": "名称不能为空",
"settings.deviceName.asciiOnly": "设备名称只能使用 ASCII 字符(英文字母、数字、符号)。",
"settings.deviceName.success": "设备名称已更新",
"settings.unregister.current": "注销当前设备",
"settings.unregister.currentHint": "将退出登录并移除此设备的注册。",
"settings.unregister.currentConfirm":
"注销当前设备会移除此设备的注册并退出登录,确认继续?",
"settings.unregister.peerConfirm": "确认要移除设备“{{name}}”?",
"settings.peers.title": "其他设备",
"settings.peers.empty": "暂无其他设备。",
"settings.peers.remove": "移除",
"settings.peers.removing": "正在移除…",
"settings.error.delete": "操作失败:{{message}}",
"settings.online": "在线",
"settings.offline": "离线",
"settings.lastSeen": "上次活跃 {{time}}",
"settings.lastSeenNever": "尚未上线",
"settings.account.title": "账号",
"settings.account.signOut": "退出登录",
"settings.desktop.title": "桌面",
"settings.desktop.clipboardSync": "剪贴板自动同步",
"settings.desktop.clipboardSyncHint": "复制即上传到云端,并接收其他设备的剪贴板更新。",
"settings.desktop.launchAtLogin": "开机自启",
"settings.desktop.launchAtLoginHint": "登录系统后自动在后台启动 cdrop(菜单栏常驻)。",
"settings.desktop.downloadDir": "下载目录",
"settings.desktop.downloadDirHint": "接收到的文件保存到此目录;留空则使用系统下载目录。",
"settings.desktop.downloadDirChoose": "更改…",
"settings.desktop.downloadDirReset": "恢复默认",
"settings.desktop.downloadDirPicker": "选择下载目录",
"settings.desktop.ttlNote": "出于安全考虑,云端剪贴板内容会在 5 分钟后自动清除。",
// ---- iOS 快捷指令令牌 ------------------------------------------------
"settings.shortcut.title": "iOS 快捷指令",
"settings.shortcut.intro": "为 iOS“快捷指令”签发长效、仅限剪贴板、可随时吊销的专用令牌。即便令牌泄漏也只能读写剪贴板、无法触及其他数据。",
"settings.shortcut.labelField": "备注名称",
"settings.shortcut.labelPlaceholder": "例如:我的 iPhone",
"settings.shortcut.issue": "签发",
"settings.shortcut.empty": "尚未签发任何令牌。",
"settings.shortcut.loadError": "令牌列表加载失败。",
"settings.shortcut.retry": "重试",
"settings.shortcut.revoke": "吊销",
"settings.shortcut.revoking": "正在吊销…",
"settings.shortcut.revokeConfirm": "吊销后使用此令牌的快捷指令将立即失效,确认继续?",
"settings.shortcut.revoked": "已吊销",
"settings.shortcut.revokedToast": "令牌已吊销",
"settings.shortcut.expired": "已过期",
"settings.shortcut.expiresAt": "到期 {{date}}",
"settings.shortcut.lastUsed": "上次使用 {{time}}",
"settings.shortcut.neverUsed": "尚未使用",
"settings.shortcut.unavailable": "服务器未启用快捷指令令牌。",
"settings.shortcut.tooMany": "活跃令牌已达上限,请先吊销旧令牌。",
"settings.shortcut.issueError": "操作失败:{{message}}",
"settings.shortcut.issuedTitle": "令牌已签发",
"settings.shortcut.onceWarning": "令牌仅显示这一次,请立即复制保存;关闭后无法再次查看。",
"settings.shortcut.tokenLabel": "令牌(Bearer",
"settings.shortcut.copy": "复制",
"settings.shortcut.copyFailed": "复制失败,请手动选择复制",
"settings.shortcut.copied": "已复制",
"settings.shortcut.baseLabel": "服务器地址",
"settings.shortcut.guideTitle": "在 iOS 上手动搭建(手动同步)",
"settings.shortcut.guideIntro": "在 iPhone 的“快捷指令”App 中新建两个指令,首次运行时填入上面的服务器地址与令牌:",
"settings.shortcut.guideDeviceName": "两个指令都加请求头 X-Device-Name: 你的设备名(仅 ASCII),让它在设备列表里统一显示。",
"settings.shortcut.guidePullTitle": "拉取(云端 → 本机剪贴板)",
"settings.shortcut.guidePull1": "“获取 URL 内容”:地址设为 你的服务器地址 加 /api/clipboard,方法 GET,请求头加 Authorization: Bearer 你的令牌。",
"settings.shortcut.guidePull2": "“获取词典值”:取键 content。",
"settings.shortcut.guidePull3": "内容非空时用“拷贝到剪贴板”写入,可再加“显示通知”提示已拉取。",
"settings.shortcut.guidePushTitle": "上传(本机剪贴板 → 云端)",
"settings.shortcut.guidePush1": "“获取剪贴板”;若为空则停止。",
"settings.shortcut.guidePush2": "“获取 URL 内容”:地址同上,方法 PUT,请求头加 Authorization: Bearer 你的令牌 与 Content-Type: application/json,请求体选 JSON{\"content\": 剪贴板, \"content_type\": \"text/plain\"}。",
"settings.shortcut.guidePush3": "可加“显示通知”提示已上传。",
"settings.shortcut.guideToleranceNote": "网络不稳定时单次运行可能失败,重试即可;自动轮询版会在后台静默重试(下一阶段提供)。",
"settings.shortcut.done": "完成",
// ---- 传输状态 / 模式 -------------------------------------------------
"transfer.savedTo": "已保存到 {{path}}",
"transfer.saveFailed": "保存文件失败:{{error}}",
"transfer.action.cancel": "取消",
"transfer.action.delete": "删除",
"transfer.action.relayNow": "立即中继",
"transfer.action.details": "详情",
"transfer.state.PENDING": "等待中",
"transfer.state.ACCEPTED": "已接受",
"transfer.state.P2P_ACTIVE": "传输中",
"transfer.state.RELAY_ACTIVE": "中继中",
"transfer.state.DONE": "已完成",
"transfer.state.FAILED": "失败",
"transfer.state.CANCELLED": "已取消",
"transfer.mode.p2p": "P2P",
"transfer.mode.relay": "中继",
// ---- 客户端 phase(实时遥测,区分等待中和卡住)----------------------
"transfer.phase.initializing": "正在初始化…",
"transfer.phase.waiting_accept": "等待对端接受…",
"transfer.phase.ice_gathering": "收集网络候选…",
"transfer.phase.ice_checking": "建立直连通道…",
"transfer.phase.ice_connected": "通道已连接",
"transfer.phase.dc_open": "信道已打开",
"transfer.phase.transferring": "传输中",
"transfer.phase.completing": "收尾中…",
"transfer.phase.fallback_pending": "切换到中继路径…",
"transfer.phase.relay_uploading": "中继上传中",
"transfer.phase.relay_downloading": "中继下载中",
"transfer.stalled": "似乎卡住了({{seconds}} 秒无进度)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/秒",
// ---- 调试展开区 -----------------------------------------------------
"transfer.debug.toggle": "调试信息",
"transfer.debug.iceGathering": "候选收集",
"transfer.debug.iceConnection": "连接状态",
"transfer.debug.candidatesLocal": "本端候选",
"transfer.debug.candidatesRemote": "对端候选",
"transfer.debug.selectedPair": "选中候选对",
"transfer.debug.noPair": "尚未选定候选对",
"transfer.debug.candidatesLine":
"host {{host}} · mDNS {{mdns}} · srflx {{srflx}} · prflx {{prflx}} · relay {{relay}}",
"transfer.debug.dataChannel": "数据信道",
"transfer.debug.dcLine": "{{state}} · 待发送 {{buffered}}",
// ---- 时间 ------------------------------------------------------------
"time.justNow": "刚刚",
"time.secondsAgo": "{{n}} 秒前",
"time.minutesAgo": "{{n}} 分钟前",
"time.hoursAgo": "{{n}} 小时前",
// ---- 通用 ------------------------------------------------------------
"common.dismiss": "移除",
"common.cancel": "取消",
"common.confirm": "确认",
// ---- 错误(lib/* 中抛出,呈现到 Alert-------------------------------
"errors.messageEmpty": "消息为空",
"errors.messageOverflow": "消息超过4 KB;请改用文件传输",
"errors.noReceiver": "未选择接收方",
"errors.devTokenMissing":
"Dev模式:未在.env.local设置VITE_CDROP_DEV_TOKEN",
"errors.noAccessToken": "无访问令牌:用户必须先登录",
"errors.clipboardUnavailable": "浏览器不支持剪贴板API(需HTTPS与权限)",
"errors.clipboardOverflow": "内容过大,超过 {{max}} 字节上限",
"errors.clipboardWriteFailed": "写入本机剪贴板失败:{{message}}",
} as const;
+244
View File
@@ -0,0 +1,244 @@
import type { TranslationDict } from "../index";
// 繁體中文(臺灣用語)。在地化詞彙:檔案 / 訊息 / 剪貼簿 / 設定 / 裝置 / 線上 /
// 連線 / 重新整理 / 伺服器 / 使用者 / 位元組 等。引號使用「」/『』直角形式
// (與簡體 "" 不同),切勿在此檔案執行 MatchedQuotesToFullwidth。
// 缺失的 key 會自動 fallback 到 zh-CN(基準詞典)。
export const zhTW: Partial<TranslationDict> = {
// ---- 應用 / 全域 -----------------------------------------------------
"app.brand": "Commilitia Drop",
"deviceType.browser": "瀏覽器",
"deviceType.macos": "macOS 用戶端",
"deviceType.windows": "Windows 用戶端",
"deviceType.linux": "Linux 用戶端",
"deviceType.ios": "iOS 用戶端",
"deviceType.shortcut": "捷徑",
"app.connected": "已連線",
"app.connecting": "正在連線…",
"app.disconnectedTitle": "正在重新連線",
"app.disconnectedHint": "與伺服器的連線已中斷,正在自動重新連線,無需重新整理頁面。",
// ---- 頂部列 / 使用者選單 ---------------------------------------------
"nav.accountMenu": "帳號選單",
"nav.thisDeviceLabel": "本機:",
"nav.settings": "設定",
"nav.signOut": "登出",
"nav.backToHome": "← 首頁",
"nav.language": "語言 / Language",
"nav.theme": "主題 / Theme",
"nav.theme.light": "淺色",
"nav.theme.dark": "深色",
"nav.theme.system": "跟隨系統",
"nav.themeToggle": "切換主題",
// ---- 首頁 ------------------------------------------------------------
"home.greeting": "你好,{{name}}",
"home.deviceList.title": "裝置",
"home.deviceList.onlineSuffix": "{{count}} 部線上)",
"home.deviceList.showOffline": "顯示離線裝置({{count}}",
"home.deviceList.empty":
"尚無其他裝置。在另一個分頁或裝置上以同一帳號登入,並設定一個不同的裝置名稱(設定 → 裝置名稱),即可在此處看到它。",
"home.tabs.file": "檔案",
"home.tabs.message": "訊息",
"home.tabs.clipboard": "剪貼簿",
"home.file.dropzone": "拖曳檔案到此處或點選選擇",
"home.file.selectedSize": "{{name}}{{size}}",
"home.file.rejected": "檔案已拒絕",
"home.cta.selectDeviceAndFile": "請選擇裝置與檔案",
"home.cta.selectFile": "請選擇檔案",
"home.cta.selectDevice": "請選擇裝置",
"home.cta.sendTo": "傳送到 {{name}}",
"home.message.placeholder": "傳送訊息給 {{name}}…",
"home.message.placeholderEmpty": "請先選擇裝置",
"home.message.hint": "Enter傳送,Shift + Enter換行,最長4 KB。",
"home.message.title": "訊息",
"home.message.countSuffix": "{{count}}",
"home.message.clearAll": "全部清除",
"home.transfer.active": "進行中",
"home.transfer.history": "最近的傳輸",
"home.clipboard.cloudTitle": "雲端最新",
"home.clipboard.cloudFrom": "來自 {{name}} · {{time}}",
"home.clipboard.cloudFromSelf": "來自本機 · {{time}}",
"home.clipboard.cloudEmpty": "尚未同步任何內容。",
"home.clipboard.copyToLocal": "複製到本機剪貼簿",
"home.clipboard.copyToLocalSuccess": "已複製到本機剪貼簿",
"home.clipboard.uploadTitle": "同步本機剪貼簿",
"home.clipboard.uploadHint": "僅純文字,最大64 KB;樣式(粗體 / 顏色等)會被剝除。",
"home.clipboard.uploadButton": "上傳本機剪貼簿",
"home.clipboard.uploading": "上傳中…",
"home.clipboard.uploadEmpty": "本機剪貼簿為空",
"home.clipboard.refresh": "重新整理",
"home.clipboard.clear": "清空雲端",
"home.clipboard.clearConfirm": "確認清空雲端剪貼簿?所有裝置將看到空內容。",
"home.clipboard.clearSuccess": "已清空雲端剪貼簿",
"home.clipboard.hidden": "內容已隱藏 · 點擊「顯示」查看",
"home.clipboard.reveal": "顯示",
"home.clipboard.hide": "隱藏",
// ---- 首次設定 --------------------------------------------------------
"setup.title": "為本機命名",
"setup.help": "此名稱會顯示給你的其他裝置。建議避免空格,長度不超過 32 個字元。",
"setup.field": "裝置名稱",
"setup.save": "儲存並繼續",
// ---- 登入 ------------------------------------------------------------
"login.title": "登入",
"login.dev.help":
"Dev模式:隨意填寫一個使用者識別碼。同識別碼的兩個分頁將模擬同一使用者的兩部裝置。",
"login.dev.field": "Dev使用者識別碼",
"login.dev.continue": "繼續",
"login.prod.help": "透過Casdoor登入。",
"login.prod.button": "透過Casdoor登入",
// ---- OAuth 回呼 -------------------------------------------------------
"oauth.failed": "登入失敗",
"oauth.signingIn": "正在登入…",
"oauth.missingParams": "缺少code或state查詢參數",
// ---- 設定頁 ----------------------------------------------------------
"settings.title": "設定",
"settings.currentDevice.title": "目前裝置",
"settings.deviceName.field": "裝置名稱",
"settings.deviceName.save": "儲存",
"settings.deviceName.unchanged": "新名稱與目前一致",
"settings.deviceName.empty": "名稱不能為空",
"settings.deviceName.asciiOnly": "裝置名稱只能使用 ASCII 字元(英文字母、數字、符號)。",
"settings.deviceName.success": "裝置名稱已更新",
"settings.unregister.current": "解除註冊本裝置",
"settings.unregister.currentHint": "將登出並移除本裝置的註冊。",
"settings.unregister.currentConfirm":
"解除註冊本裝置會移除其註冊並登出,確認繼續?",
"settings.unregister.peerConfirm": "確認要移除裝置「{{name}}」?",
"settings.peers.title": "其他裝置",
"settings.peers.empty": "尚無其他裝置。",
"settings.peers.remove": "移除",
"settings.peers.removing": "正在移除…",
"settings.error.delete": "操作失敗:{{message}}",
"settings.online": "線上",
"settings.offline": "離線",
"settings.lastSeen": "上次活躍 {{time}}",
"settings.lastSeenNever": "尚未上線",
"settings.account.title": "帳號",
"settings.account.signOut": "登出",
"settings.desktop.title": "桌面",
"settings.desktop.clipboardSync": "剪貼簿自動同步",
"settings.desktop.clipboardSyncHint": "複製後即上傳雲端,並接收其他裝置的剪貼簿更新。",
"settings.desktop.launchAtLogin": "開機自動啟動",
"settings.desktop.launchAtLoginHint": "登入系統後自動在背景啟動 cdrop(常駐選單列)。",
"settings.desktop.downloadDir": "下載資料夾",
"settings.desktop.downloadDirHint": "接收到的檔案會儲存至此資料夾;留空則使用系統下載資料夾。",
"settings.desktop.downloadDirChoose": "變更…",
"settings.desktop.downloadDirReset": "還原預設",
"settings.desktop.downloadDirPicker": "選擇下載資料夾",
"settings.desktop.ttlNote": "基於安全考量,雲端剪貼簿內容會在 5 分鐘後自動清除。",
// ---- iOS 捷徑權杖 ----------------------------------------------------
"settings.shortcut.title": "iOS 捷徑",
"settings.shortcut.intro": "為 iOS「捷徑」簽發長效、僅限剪貼簿、可隨時撤銷的專用權杖。即使權杖外洩也只能讀寫剪貼簿、無法觸及其他資料。",
"settings.shortcut.labelField": "備註名稱",
"settings.shortcut.labelPlaceholder": "例如:我的 iPhone",
"settings.shortcut.issue": "簽發",
"settings.shortcut.empty": "尚未簽發任何權杖。",
"settings.shortcut.loadError": "權杖清單載入失敗。",
"settings.shortcut.retry": "重試",
"settings.shortcut.revoke": "撤銷",
"settings.shortcut.revoking": "正在撤銷…",
"settings.shortcut.revokeConfirm": "撤銷後使用此權杖的捷徑將立即失效,確認繼續?",
"settings.shortcut.revoked": "已撤銷",
"settings.shortcut.revokedToast": "權杖已撤銷",
"settings.shortcut.expired": "已過期",
"settings.shortcut.expiresAt": "到期 {{date}}",
"settings.shortcut.lastUsed": "上次使用 {{time}}",
"settings.shortcut.neverUsed": "尚未使用",
"settings.shortcut.unavailable": "伺服器未啟用捷徑權杖。",
"settings.shortcut.tooMany": "使用中的權杖已達上限,請先撤銷舊權杖。",
"settings.shortcut.issueError": "操作失敗:{{message}}",
"settings.shortcut.issuedTitle": "權杖已簽發",
"settings.shortcut.onceWarning": "權杖僅顯示這一次,請立即複製保存;關閉後無法再次檢視。",
"settings.shortcut.tokenLabel": "權杖(Bearer",
"settings.shortcut.copy": "複製",
"settings.shortcut.copyFailed": "複製失敗,請手動選取複製",
"settings.shortcut.copied": "已複製",
"settings.shortcut.baseLabel": "伺服器位址",
"settings.shortcut.guideTitle": "在 iOS 上手動建立(手動同步)",
"settings.shortcut.guideIntro": "在 iPhone 的「捷徑」App 中新增兩個捷徑,首次執行時填入上方的伺服器位址與權杖:",
"settings.shortcut.guideDeviceName": "兩個捷徑都加標頭 X-Device-Name: 你的裝置名稱(僅 ASCII),讓它在裝置清單中統一顯示。",
"settings.shortcut.guidePullTitle": "拉取(雲端 → 本機剪貼簿)",
"settings.shortcut.guidePull1": "「取得 URL 內容」:網址設為 你的伺服器位址 加 /api/clipboard,方法 GET,標頭加 Authorization: Bearer 你的權杖。",
"settings.shortcut.guidePull2": "「取得字典值」:取鍵 content。",
"settings.shortcut.guidePull3": "內容非空時用「複製到剪貼簿」寫入,可再加「顯示通知」提示已拉取。",
"settings.shortcut.guidePushTitle": "上傳(本機剪貼簿 → 雲端)",
"settings.shortcut.guidePush1": "「取得剪貼簿」;若為空則停止。",
"settings.shortcut.guidePush2": "「取得 URL 內容」:網址同上,方法 PUT,標頭加 Authorization: Bearer 你的權杖 與 Content-Type: application/json,請求內容選 JSON{\"content\": 剪貼簿, \"content_type\": \"text/plain\"}。",
"settings.shortcut.guidePush3": "可加「顯示通知」提示已上傳。",
"settings.shortcut.guideToleranceNote": "網路不穩時單次執行可能失敗,重試即可;自動輪詢版會在背景靜默重試(下一階段提供)。",
"settings.shortcut.done": "完成",
// ---- 傳輸狀態 / 模式 -------------------------------------------------
"transfer.savedTo": "已儲存至 {{path}}",
"transfer.saveFailed": "儲存檔案失敗:{{error}}",
"transfer.action.cancel": "取消",
"transfer.action.delete": "刪除",
"transfer.action.relayNow": "立即中繼",
"transfer.action.details": "詳情",
"transfer.state.PENDING": "等待中",
"transfer.state.ACCEPTED": "已接受",
"transfer.state.P2P_ACTIVE": "傳輸中",
"transfer.state.RELAY_ACTIVE": "中繼中",
"transfer.state.DONE": "已完成",
"transfer.state.FAILED": "失敗",
"transfer.state.CANCELLED": "已取消",
"transfer.mode.p2p": "P2P",
"transfer.mode.relay": "中繼",
// ---- 客戶端 phase(即時遙測,區分等待中和卡住)---------------------
"transfer.phase.initializing": "正在初始化…",
"transfer.phase.waiting_accept": "等待對端接受…",
"transfer.phase.ice_gathering": "收集網路候選…",
"transfer.phase.ice_checking": "建立直連通道…",
"transfer.phase.ice_connected": "通道已連線",
"transfer.phase.dc_open": "通道已開啟",
"transfer.phase.transferring": "傳輸中",
"transfer.phase.completing": "收尾中…",
"transfer.phase.fallback_pending": "切換到中繼路徑…",
"transfer.phase.relay_uploading": "中繼上傳中",
"transfer.phase.relay_downloading": "中繼下載中",
"transfer.stalled": "似乎卡住了({{seconds}} 秒無進度)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/秒",
// ---- 偵錯展開區 -----------------------------------------------------
"transfer.debug.toggle": "偵錯資訊",
"transfer.debug.iceGathering": "候選收集",
"transfer.debug.iceConnection": "連線狀態",
"transfer.debug.candidatesLocal": "本端候選",
"transfer.debug.candidatesRemote": "對端候選",
"transfer.debug.selectedPair": "選中候選對",
"transfer.debug.noPair": "尚未選定候選對",
"transfer.debug.candidatesLine":
"host {{host}} · mDNS {{mdns}} · srflx {{srflx}} · prflx {{prflx}} · relay {{relay}}",
"transfer.debug.dataChannel": "資料通道",
"transfer.debug.dcLine": "{{state}} · 待傳送 {{buffered}}",
// ---- 時間 ------------------------------------------------------------
"time.justNow": "剛剛",
"time.secondsAgo": "{{n}} 秒前",
"time.minutesAgo": "{{n}} 分鐘前",
"time.hoursAgo": "{{n}} 小時前",
// ---- 通用 ------------------------------------------------------------
"common.dismiss": "移除",
"common.cancel": "取消",
"common.confirm": "確認",
// ---- 錯誤(lib/* 中拋出,呈現到 Alert-----------------------------
"errors.messageEmpty": "訊息為空",
"errors.messageOverflow": "訊息超過4 KB;請改用檔案傳輸",
"errors.noReceiver": "未選擇接收方",
"errors.devTokenMissing":
"Dev模式:未在.env.local設定VITE_CDROP_DEV_TOKEN",
"errors.noAccessToken": "無存取權杖:使用者必須先登入",
"errors.clipboardUnavailable": "瀏覽器不支援剪貼簿API(需HTTPS與權限)",
"errors.clipboardOverflow": "內容過大,超過 {{max}} 位元組上限",
"errors.clipboardWriteFailed": "寫入本機剪貼簿失敗:{{message}}",
};