feat: DSL 磁力聚合 — 搜索/解析/预览/投递 + cilisousuo 源 + 中英双语 + Docker 部署
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
export function formatBytes(bytes: number | null | undefined, digits = 2): string {
|
||||
const value = Number(bytes)
|
||||
if (!value || value < 0) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
let index = 0
|
||||
let n = value
|
||||
while (n >= 1024 && index < units.length - 1) {
|
||||
n /= 1024
|
||||
index++
|
||||
}
|
||||
return `${n.toFixed(index === 0 ? 0 : digits)} ${units[index]}`
|
||||
}
|
||||
|
||||
export function formatDate(input: string | number | Date | null | undefined): string {
|
||||
if (!input) return '—'
|
||||
const date = input instanceof Date ? input : new Date(input)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(
|
||||
date.getMinutes()
|
||||
)}`
|
||||
}
|
||||
|
||||
export function formatRelative(input: string | number | Date | null | undefined): string {
|
||||
if (!input) return '—'
|
||||
const date = input instanceof Date ? input : new Date(input)
|
||||
const diff = Date.now() - date.getTime()
|
||||
if (Number.isNaN(diff)) return '—'
|
||||
const min = Math.floor(diff / 60000)
|
||||
if (min < 1) return '刚刚'
|
||||
if (min < 60) return `${min} 分钟前`
|
||||
const hour = Math.floor(min / 60)
|
||||
if (hour < 24) return `${hour} 小时前`
|
||||
const day = Math.floor(hour / 24)
|
||||
if (day < 30) return `${day} 天前`
|
||||
return formatDate(date)
|
||||
}
|
||||
|
||||
export function formatNumber(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) return '—'
|
||||
return value.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
export function filePercent(length: number, total: number): string {
|
||||
if (!total) return '0%'
|
||||
const pct = (length / total) * 100
|
||||
return pct >= 10 ? `${pct.toFixed(0)}%` : `${pct.toFixed(1)}%`
|
||||
}
|
||||
|
||||
/** 把扁平的文件路径拆成 目录 + 文件名,便于展示层级 */
|
||||
export function splitPath(path: string): { dir: string; name: string } {
|
||||
const idx = path.lastIndexOf('/')
|
||||
if (idx < 0) return { dir: '', name: path }
|
||||
return { dir: path.slice(0, idx + 1), name: path.slice(idx + 1) }
|
||||
}
|
||||
|
||||
/** 可以尝试在线预览的图片扩展名 */
|
||||
export const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif']
|
||||
|
||||
/** 与服务端 preview 接口保持一致:超过这个大小就不做在线预览 */
|
||||
export const MAX_IMAGE_PREVIEW_BYTES = 12 * 1024 * 1024
|
||||
|
||||
export function isImagePath(path: string): boolean {
|
||||
const idx = path.lastIndexOf('.')
|
||||
if (idx < 0) return false
|
||||
return IMAGE_EXTENSIONS.includes(path.slice(idx + 1).toLowerCase())
|
||||
}
|
||||
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// 中文(源文) → 英文 映射。
|
||||
// 站点模板以中文书写,默认显示英文;切换到中文时回退到源文(即中文)。
|
||||
// 键已做空格归一化(连续空白合并为单个空格),便于整句匹配。
|
||||
export const zh2en: Record<string, string> = {
|
||||
// ===== 顶栏 / 布局 =====
|
||||
'预览台 / 投递器': 'Preview / Handoff',
|
||||
'搜索': 'Search',
|
||||
'任务': 'Tasks',
|
||||
'历史': 'History',
|
||||
'设置': 'Settings',
|
||||
'MONGO 未连接': 'MONGO disconnected',
|
||||
'磁力解析就绪': 'Magnet parsing ready',
|
||||
'磁力解析不可用': 'Magnet parsing unavailable',
|
||||
'本地自托管 · 单进程 Nuxt 单体应用': 'Self-hosted · single-process Nuxt app',
|
||||
'种子只做解析与预览,文件内容由你自己的下载器拉取': 'Torrents are only parsed and previewed; file contents are fetched by your own download client',
|
||||
|
||||
// ===== 种子详情页 [infohash] =====
|
||||
'← 返回搜索': '← Back to search',
|
||||
'来自 MongoDB 缓存': 'From MongoDB cache',
|
||||
'来自链接参数': 'From link parameter',
|
||||
'拿不到这个种子的信息': "Can't fetch info for this torrent",
|
||||
'如果你是直接从外部链接进来的,请带上 magnet 参数,例如': 'If you came from an external link, include a magnet parameter, e.g.',
|
||||
'回到搜索页': 'Back to search',
|
||||
'加载中…': 'Loading…',
|
||||
|
||||
// ===== 首页 index =====
|
||||
'SEARCH · PARSE · HANDOFF': 'SEARCH · PARSE · HANDOFF',
|
||||
'搜种子、看清里面有什么,再丢给本机下载器': 'Search torrents, see what is inside, then hand off to your local downloader',
|
||||
'qBittorrent / Transmission / aria2,由它们完成实际下载。': 'qBittorrent / Transmission / aria2, which do the actual downloading.',
|
||||
'经代理访问': 'Via proxy',
|
||||
'直连访问': 'Direct connection',
|
||||
'请输入关键词': 'Please enter a keyword',
|
||||
'没有搜到结果': 'No results found',
|
||||
'可以换个关键词,或检查代理设置': 'Try another keyword or check proxy settings',
|
||||
'没有结果。换个关键词,或在「设置」里配置代理后再试。': 'No results. Try another keyword, or configure a proxy in Settings.',
|
||||
'上一页': 'Previous',
|
||||
'下一页': 'Next',
|
||||
'预览': 'Preview',
|
||||
'输入关键词开始搜索': 'Enter a keyword to search',
|
||||
'关键词搜索': 'Keyword search',
|
||||
'上传 .torrent': 'Upload .torrent',
|
||||
'磁力链': 'Magnet link',
|
||||
'把 .torrent 文件拖到这里,或点击选择': 'Drag a .torrent file here, or click to choose',
|
||||
'文件只在服务端解析出文件清单,不会上传到任何第三方': 'Files are parsed into a file list on the server only; nothing is uploaded to any third party',
|
||||
'解析中…': 'Resolving…',
|
||||
'磁力链需要联网从 DHT / tracker 拉取元数据,才能看到文件清单': 'A magnet link needs to fetch metadata from DHT / trackers to show the file list',
|
||||
'解析磁力链': 'Resolve magnet',
|
||||
'名称': 'Name',
|
||||
'来源': 'Source',
|
||||
'大小': 'Size',
|
||||
'文件': 'Files',
|
||||
'做种': 'Seeders',
|
||||
'下载': 'Leechers',
|
||||
'发布': 'Added',
|
||||
'操作': 'Actions',
|
||||
'搜索源': 'Search sources',
|
||||
|
||||
// ===== 下载任务 downloads =====
|
||||
'实时下载状态': 'Live download status',
|
||||
'直接从你配置的 qBittorrent / Transmission / aria2 客户端拉取的实时数据。': 'Real-time data pulled directly from your configured qBittorrent / Transmission / aria2 clients.',
|
||||
'活跃任务总数': 'Total active tasks',
|
||||
'在线下载器': 'Online clients',
|
||||
'刷新频率': 'Refresh rate',
|
||||
'每 5 秒自动同步': 'Auto-sync every 5s',
|
||||
'手动刷新': 'Manual refresh',
|
||||
'同步中…': 'Syncing…',
|
||||
'任务名称': 'Task name',
|
||||
'进度': 'Progress',
|
||||
'下载速度': 'DL speed',
|
||||
'剩余时间': 'ETA',
|
||||
'状态': 'Status',
|
||||
'暂无任务': 'No tasks',
|
||||
'还没有配置任何下载器,或者所有下载器都已禁用。': 'No download clients configured, or all are disabled.',
|
||||
'去设置': 'Go to settings',
|
||||
'下载中': 'Downloading',
|
||||
'做种中': 'Seeding',
|
||||
'已暂停': 'Paused',
|
||||
'错误': 'Error',
|
||||
'排队中': 'Queued',
|
||||
'校验中': 'Checking',
|
||||
'已完成': 'Completed',
|
||||
'未知': 'Unknown',
|
||||
'在线': 'online',
|
||||
'离线': 'offline',
|
||||
|
||||
// ===== 历史 history =====
|
||||
'搜索历史与种子库': 'Search history & torrent library',
|
||||
'每次搜索都会落到 MongoDB 的 searches 集合;搜到和解析过的种子按 infoHash 去重存在 torrents 集合。': "Every search is stored in MongoDB's searches collection; found and parsed torrents are deduplicated by infoHash in the torrents collection.",
|
||||
'MongoDB 未连接,历史记录不可用。启动本地 MongoDB 后刷新本页即可。': 'MongoDB not connected, history unavailable. Start local MongoDB and refresh this page.',
|
||||
'搜索总次数': 'Total searches',
|
||||
'库中种子数': 'Torrents in library',
|
||||
'高频关键词': 'Top keywords',
|
||||
'未连接': 'Disconnected',
|
||||
'正常': 'OK',
|
||||
'点击直接再搜一次': 'Click to search again',
|
||||
'刷新': 'Refresh',
|
||||
'刷新中…': 'Refreshing…',
|
||||
'清空': 'Clear all',
|
||||
'关键词': 'Keyword',
|
||||
'结果数': 'Results',
|
||||
'耗时': 'Time',
|
||||
'时间': 'Time',
|
||||
'删除': 'Delete',
|
||||
'还没有搜索记录': 'No search history yet',
|
||||
'加载中…': 'Loading…',
|
||||
'更新': 'Updated',
|
||||
'打开': 'Open',
|
||||
'库里还没有种子': 'No torrents in library yet',
|
||||
'已删除该条记录': 'Record deleted',
|
||||
'删除失败': 'Delete failed',
|
||||
'确定清空全部搜索历史?': 'Clear all search history?',
|
||||
'清空失败': 'Clear failed',
|
||||
|
||||
// ===== 种子详情 TorrentDetail =====
|
||||
'未知来源': 'Unknown source',
|
||||
'磁力搜索': 'Magnet Search',
|
||||
'本地上传': 'Local upload',
|
||||
'数据库': 'Database',
|
||||
'文件清单已解析': 'File list resolved',
|
||||
'磁力链已复制到剪贴板': 'Magnet link copied to clipboard',
|
||||
'复制失败,请手动选中复制': 'Copy failed, please select and copy manually',
|
||||
'解析失败': 'Resolve failed',
|
||||
'总大小': 'Total size',
|
||||
'文件数': 'File count',
|
||||
'做种 / 下载': 'Seeders / Leechers',
|
||||
'发布时间': 'Published',
|
||||
'展开': 'Expand',
|
||||
'收起': 'Collapse',
|
||||
'备注': 'Comment',
|
||||
'复制磁力链': 'Copy magnet',
|
||||
'下载 .torrent': 'Download .torrent',
|
||||
'打开来源页面': 'Open source page',
|
||||
|
||||
// ===== SendPanel =====
|
||||
'SEND TO LOCAL CLIENT': 'SEND TO LOCAL CLIENT',
|
||||
'探测中…': 'Probing…',
|
||||
'重新探测': 'Re-probe',
|
||||
'配置': 'Configure',
|
||||
'还没有配置任何下载器。去「设置」里填 qBittorrent / Transmission / aria2 的地址和账号密码。': 'No download client configured. Go to Settings and provide qBittorrent / Transmission / aria2 address and credentials.',
|
||||
'目标下载器': 'Target client',
|
||||
'下载目录(留空用客户端默认)': 'Download dir (blank = client default)',
|
||||
'该下载器当前不可用': 'This client is currently unavailable',
|
||||
'投递到本机下载器': 'Send to local client',
|
||||
'投递中…': 'Sending…',
|
||||
'种子交给本机客户端下载,服务器不中转文件内容': 'The torrent is downloaded by your local client; the server does not proxy file contents',
|
||||
'读取下载器配置失败': 'Failed to read client config',
|
||||
'投递成功': 'Delivered',
|
||||
'投递失败': 'Delivery failed',
|
||||
|
||||
// ===== FileTable =====
|
||||
'正在通过 DHT / tracker 获取种子元数据,通常几秒到几十秒…': 'Fetching torrent metadata via DHT / tracker, usually takes seconds to tens of seconds…',
|
||||
'这个种子只有 infoHash,还没有文件清单。可以点击下面的按钮在线拉取元数据(需要本机能连上 DHT / tracker)。': 'This torrent only has an infoHash and no file list yet. Click the button below to fetch metadata online (requires the server to reach DHT / trackers).',
|
||||
'在线解析文件清单': 'Resolve file list online',
|
||||
'FILE LIST': 'FILE LIST',
|
||||
'过滤文件名…': 'Filter filenames…',
|
||||
'按大小': 'By size',
|
||||
'按名称': 'By name',
|
||||
'路径': 'Path',
|
||||
'占比': 'Ratio',
|
||||
'过大': 'Too large',
|
||||
'结果较多,表格只展示前 500 条,可用过滤框缩小范围': 'Too many results; table shows the first 500 rows. Use the filter to narrow down',
|
||||
|
||||
// ===== ImagePreview =====
|
||||
'IMAGE': 'IMAGE',
|
||||
'关闭': 'Close',
|
||||
'正在从做种节点拉取这张图片,取决于对方的上传速度…': 'Fetching this image from seeding peers, depending on their upload speed…',
|
||||
'重试': 'Retry',
|
||||
'直接打开图片地址': 'Open image URL directly',
|
||||
'上一张': 'Previous',
|
||||
'下一张': 'Next',
|
||||
|
||||
// ===== 设置 settings =====
|
||||
'搜索源与本机下载器': 'Search sources & local clients',
|
||||
'所有配置写在 MongoDB 的 settings 集合里(没连库时回落到 .env)。下载器地址默认指向本机,如果服务部署在别的机器上,改成本机可访问的地址即可。': "All config is stored in MongoDB's settings collection (falls back to .env when the DB is down). Downloader addresses point to localhost by default; change them to whatever address is reachable.",
|
||||
'保存中…': 'Saving…',
|
||||
'保存全部配置': 'Save all settings',
|
||||
'重新加载': 'Reload',
|
||||
'MongoDB 未连接,配置只能在本次运行内生效': 'MongoDB not connected; config only takes effect for this session',
|
||||
'启用的搜索源': 'Enabled search sources',
|
||||
'HTTP 代理(搜索源用)': 'HTTP proxy (for search sources)',
|
||||
'搜索超时(毫秒)': 'Search timeout (ms)',
|
||||
'元数据解析超时(毫秒)': 'Metadata parse timeout (ms)',
|
||||
'公开磁力站在部分网络环境下不可直连,配好代理后「设置 → 保存」再回搜索页重试即可。代理只作用于搜索源请求,不影响下载器通信。': 'Public magnet sites may be unreachable on some networks. After setting a proxy, Save in Settings then retry from the search page. The proxy only affects search requests, not downloader communication.',
|
||||
'LOCAL BT CLIENTS': 'LOCAL BT CLIENTS',
|
||||
'新增下载器': 'Add client',
|
||||
'启用': 'Enable',
|
||||
'测试连接': 'Test connection',
|
||||
'类型': 'Type',
|
||||
'主机': 'Host',
|
||||
'端口': 'Port',
|
||||
'RPC 路径(可空)': 'RPC path (optional)',
|
||||
'默认': 'default',
|
||||
'用户名': 'Username',
|
||||
'(aria2 用不到)': '(not used by aria2)',
|
||||
'RPC 密钥 --rpc-secret': 'RPC secret --rpc-secret',
|
||||
'密码': 'Password',
|
||||
'aria2 的 --rpc-secret 值': "aria2's --rpc-secret value",
|
||||
'默认下载目录': 'Default download dir',
|
||||
'留空用客户端默认': 'Blank = client default',
|
||||
'使用 HTTPS': 'Use HTTPS',
|
||||
'可达': 'Reachable',
|
||||
'qBittorrent:开启「工具 → 选项 → Web UI」,默认端口 8080;首次访问会让客户端把本机 IP 加入白名单。': 'qBittorrent: enable "Tools → Options → Web UI", default port 8080; first visit whitelists your local IP in the client.',
|
||||
'Transmission:开启远程控制,默认端口 9091,RPC 路径 /transmission/rpc;没设用户名密码就留空。': 'Transmission: enable remote control, default port 9091, RPC path /transmission/rpc; leave blank if no username/password.',
|
||||
'aria2:启动时加 --enable-rpc --rpc-listen-all=true --rpc-secret=你的密钥,把密钥填到「RPC 密钥」框。': 'aria2: start with --enable-rpc --rpc-listen-all=true --rpc-secret=YOUR_SECRET, and put the secret in the RPC secret field.',
|
||||
'还没有下载器,点右上角「新增下载器」': 'No downloaders yet. Click "Add client" at the top right.',
|
||||
'HANDOFF LOG': 'HANDOFF LOG',
|
||||
'最近 25 条投递记录': 'Last 25 handoff records',
|
||||
'下载器': 'Client',
|
||||
'目录': 'Dir',
|
||||
'结果': 'Result',
|
||||
'成功': 'Success',
|
||||
'失败': 'Failed',
|
||||
'还没有投递记录': 'No handoff records yet',
|
||||
'RUNTIME': 'RUNTIME',
|
||||
'已连接': 'Connected',
|
||||
'磁力解析': 'Magnet parsing',
|
||||
'webtorrent 可用,可通过 DHT/tracker 取文件清单': 'webtorrent available; can fetch file list via DHT/tracker',
|
||||
'webtorrent 不可用,磁力链只能显示 infoHash': 'webtorrent unavailable; magnet links can only show infoHash',
|
||||
|
||||
// ===== 输入框占位符 =====
|
||||
'输入片名 / 剧名 / 关键词,回车搜索': 'Movie / show / keyword, press Enter to search',
|
||||
'http://127.0.0.1:7890 / 留空直连': 'http://127.0.0.1:7890 / blank for direct'
|
||||
}
|
||||
|
||||
export const en2zh: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(zh2en).map(([zh, en]) => [en, zh])
|
||||
)
|
||||
|
||||
/** 归一化:折叠所有连续空白为单个空格,并去掉首尾空白 */
|
||||
export function normalizeWs(s: string): string {
|
||||
return s.trim().replace(/\s+/g, ' ')
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { en2zh, normalizeWs, zh2en } from './i18n-dict'
|
||||
|
||||
export type Locale = 'en' | 'zh'
|
||||
|
||||
const STORAGE_KEY = 'dsl-locale'
|
||||
|
||||
function readInitialLocale(): Locale {
|
||||
if (import.meta.client) {
|
||||
const v = localStorage.getItem(STORAGE_KEY)
|
||||
if (v === 'en' || v === 'zh') return v
|
||||
}
|
||||
return 'en' // 默认英文
|
||||
}
|
||||
|
||||
// 模块级响应式语言状态(默认英文)
|
||||
export const locale = ref<Locale>(readInitialLocale())
|
||||
|
||||
export function setLocale(next: Locale) {
|
||||
locale.value = next
|
||||
if (import.meta.client) {
|
||||
localStorage.setItem(STORAGE_KEY, next)
|
||||
document.documentElement.lang = next === 'zh' ? 'zh-CN' : 'en'
|
||||
translateDocument()
|
||||
}
|
||||
}
|
||||
|
||||
/** 把一段中文(或英文)文本按当前语言翻译;找不到映射时原样返回 */
|
||||
export function translateText(input: string): string {
|
||||
if (!input) return input
|
||||
const key = normalizeWs(input)
|
||||
const map = locale.value === 'zh' ? en2zh : zh2en
|
||||
return map[key] ?? input
|
||||
}
|
||||
|
||||
/** 遍历 root 下的文本节点与常见属性,按当前语言就地翻译 */
|
||||
export function walkAndTranslate(root: Node = document.body) {
|
||||
if (!root || !import.meta.client) return
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||
const fixes: { node: Text; text: string }[] = []
|
||||
let n: Node | null
|
||||
while ((n = walker.nextNode())) {
|
||||
const t = (n as Text).textContent || ''
|
||||
if (!t.trim()) continue
|
||||
const tr = translateText(t)
|
||||
if (tr !== t) fixes.push({ node: n as Text, text: tr })
|
||||
}
|
||||
for (const f of fixes) f.node.textContent = f.text
|
||||
|
||||
// 翻译属性(title / placeholder / alt)
|
||||
if (root instanceof HTMLElement) {
|
||||
root.querySelectorAll<HTMLElement>('*').forEach((el) => {
|
||||
for (const attr of ['title', 'placeholder', 'alt']) {
|
||||
const v = el.getAttribute(attr)
|
||||
if (!v) continue
|
||||
const tr = translateText(v)
|
||||
if (tr !== v) el.setAttribute(attr, tr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function translateDocument() {
|
||||
if (import.meta.client) walkAndTranslate(document.body)
|
||||
}
|
||||
Reference in New Issue
Block a user