feat: DSL 磁力聚合 — 搜索/解析/预览/投递 + cilisousuo 源 + 中英双语 + Docker 部署
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
<AppToasts />
|
||||
</template>
|
||||
@@ -0,0 +1,429 @@
|
||||
/* ============================================================
|
||||
设计令牌:深色工业控制台 / 网络信号台
|
||||
近黑蓝底 + 发丝线 + 信号橙点缀,等宽字体承载数据
|
||||
============================================================ */
|
||||
:root {
|
||||
--bg: #08090b;
|
||||
--panel: #0d0f12;
|
||||
--panel-2: #121519;
|
||||
--panel-3: #171b20;
|
||||
--line: #1d2228;
|
||||
--line-2: #2a3138;
|
||||
--fg: #d6dbdf;
|
||||
--fg-dim: #8d979f;
|
||||
--fg-mute: #5c656d;
|
||||
--acc: #ff6a1f;
|
||||
--acc-soft: rgba(255, 106, 31, 0.12);
|
||||
--acc-line: rgba(255, 106, 31, 0.42);
|
||||
--ok: #2fd39f;
|
||||
--warn: #ffc046;
|
||||
--err: #ff5757;
|
||||
--ok-soft: rgba(47, 211, 159, 0.12);
|
||||
--err-soft: rgba(255, 87, 87, 0.12);
|
||||
|
||||
--display: Bahnschrift, 'Segoe UI Variable Display', 'Segoe UI', 'Microsoft YaHei UI', sans-serif;
|
||||
--body: 'Segoe UI', 'Microsoft YaHei UI', 'PingFang SC', 'Hiragino Sans GB', system-ui, sans-serif;
|
||||
--mono: 'Cascadia Mono', 'Cascadia Code', Consolas, 'JetBrains Mono', 'DejaVu Sans Mono', monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #__nuxt { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--body);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* 背景网格:轻微的技术图纸感 */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(255, 255, 255, 0.016) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.016) 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(120% 80% at 50% -20%, rgba(255, 106, 31, 0.055), transparent 60%);
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
::selection { background: var(--acc); color: #0b0b0b; }
|
||||
|
||||
/* 滚动条 */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: var(--line-2); border: 3px solid var(--bg); }
|
||||
::-webkit-scrollbar-thumb:hover { background: #3a434c; }
|
||||
|
||||
/* ================= 布局 ================= */
|
||||
.shell { position: relative; z-index: 1; min-height: 100%; display: flex; flex-direction: column; }
|
||||
.wrap { width: 100%; max-width: 1320px; margin: 0 auto; padding: 0 28px; }
|
||||
.grow { flex: 1; }
|
||||
|
||||
/* ================= 顶栏 ================= */
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
background: rgba(8, 9, 11, 0.86);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.topbar-in { display: flex; align-items: center; gap: 26px; height: 58px; }
|
||||
|
||||
.brand { display: flex; align-items: center; gap: 10px; font-family: var(--display); }
|
||||
.brand-mark {
|
||||
width: 26px; height: 26px;
|
||||
display: grid; place-items: center;
|
||||
border: 1px solid var(--acc-line);
|
||||
background: var(--acc-soft);
|
||||
color: var(--acc);
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.06em;
|
||||
}
|
||||
.brand-name { font-size: 15px; letter-spacing: 0.16em; text-transform: uppercase; font-weight: 600; }
|
||||
.brand-sub { font-family: var(--mono); font-size: 10px; color: var(--fg-mute); letter-spacing: 0.1em; }
|
||||
|
||||
.nav { display: flex; gap: 2px; margin-left: 6px; }
|
||||
.nav a {
|
||||
position: relative;
|
||||
padding: 7px 13px;
|
||||
font-size: 13px;
|
||||
color: var(--fg-dim);
|
||||
border: 1px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.nav a:hover { color: var(--fg); border-color: var(--line); background: var(--panel); }
|
||||
.nav a.active { color: var(--acc); border-color: var(--acc-line); background: var(--acc-soft); }
|
||||
|
||||
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--panel);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--fg-mute); }
|
||||
.pill.on { border-color: rgba(47, 211, 159, 0.3); color: var(--ok); }
|
||||
.pill.on .dot { background: var(--ok); box-shadow: 0 0 8px var(--ok); }
|
||||
.pill.off { border-color: rgba(255, 87, 87, 0.3); color: var(--err); }
|
||||
.pill.off .dot { background: var(--err); }
|
||||
.pill.warn { border-color: rgba(255, 192, 70, 0.3); color: var(--warn); }
|
||||
.pill.warn .dot { background: var(--warn); }
|
||||
|
||||
/* ================= 通用组件 ================= */
|
||||
.page { padding: 34px 0 90px; }
|
||||
|
||||
.label {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-mute);
|
||||
}
|
||||
|
||||
h1 { font-family: var(--display); font-size: 34px; line-height: 1.15; margin: 10px 0 8px; font-weight: 600; letter-spacing: -0.005em; }
|
||||
h2 { font-family: var(--display); font-size: 19px; margin: 0; font-weight: 600; letter-spacing: 0.01em; }
|
||||
h3 { font-family: var(--display); font-size: 15px; margin: 0; font-weight: 600; }
|
||||
.sub { color: var(--fg-dim); max-width: 62ch; font-size: 13.5px; }
|
||||
|
||||
.panel {
|
||||
position: relative;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.panel::before {
|
||||
content: '';
|
||||
position: absolute; left: -1px; top: -1px; bottom: -1px;
|
||||
width: 2px; background: var(--acc);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.panel-hd {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, var(--panel-2), var(--panel));
|
||||
}
|
||||
.panel-hd .spacer { margin-left: auto; }
|
||||
.panel-bd { padding: 18px; }
|
||||
.panel-bd.tight { padding: 0; }
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 8px 15px;
|
||||
background: var(--panel-2);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--line-2);
|
||||
font-family: var(--body);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.14s, border-color 0.14s, color 0.14s, transform 0.08s;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: var(--panel-3); border-color: #3a434c; }
|
||||
.btn:active:not(:disabled) { transform: translateY(1px); }
|
||||
.btn:disabled { opacity: 0.42; cursor: not-allowed; }
|
||||
.btn-primary { background: var(--acc); border-color: var(--acc); color: #12100e; font-weight: 600; }
|
||||
.btn-primary:hover:not(:disabled) { background: #ff7f3d; border-color: #ff7f3d; }
|
||||
.btn-ghost { background: transparent; border-color: var(--line); color: var(--fg-dim); }
|
||||
.btn-ghost:hover:not(:disabled) { color: var(--fg); background: var(--panel-2); }
|
||||
.btn-danger { border-color: rgba(255, 87, 87, 0.35); color: var(--err); background: transparent; }
|
||||
.btn-danger:hover:not(:disabled) { background: var(--err-soft); }
|
||||
.btn-sm { padding: 5px 10px; font-size: 12px; }
|
||||
|
||||
.input, .select, .textarea {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: #0a0c0e;
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--fg);
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
transition: border-color 0.14s, box-shadow 0.14s;
|
||||
}
|
||||
.input:focus, .select:focus, .textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--acc-line);
|
||||
box-shadow: 0 0 0 3px var(--acc-soft);
|
||||
}
|
||||
.input::placeholder, .textarea::placeholder { color: var(--fg-mute); }
|
||||
.textarea { resize: vertical; min-height: 108px; line-height: 1.6; }
|
||||
.select { font-family: var(--body); cursor: pointer; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field > .label { color: var(--fg-dim); }
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
|
||||
.grid-4 { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
|
||||
|
||||
/* 标签页 */
|
||||
.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--line); margin: 22px 0 0; }
|
||||
.tabs button {
|
||||
padding: 10px 18px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
color: var(--fg-mute);
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
cursor: pointer;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.14s, background 0.14s;
|
||||
}
|
||||
.tabs button:hover { color: var(--fg-dim); background: var(--panel); }
|
||||
.tabs button.on { color: var(--acc); background: var(--panel); border-color: var(--line); border-bottom-color: var(--panel); }
|
||||
|
||||
/* 勾选 */
|
||||
.check { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; color: var(--fg-dim); user-select: none; }
|
||||
.check input { appearance: none; width: 15px; height: 15px; border: 1px solid var(--line-2); background: #0a0c0e; cursor: pointer; position: relative; }
|
||||
.check input:checked { background: var(--acc); border-color: var(--acc); }
|
||||
.check input:checked::after { content: '✓'; position: absolute; inset: 0; display: grid; place-items: center; color: #12100e; font-size: 11px; font-weight: 700; }
|
||||
.check:hover { color: var(--fg); }
|
||||
|
||||
/* 表格 */
|
||||
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.table th {
|
||||
text-align: left;
|
||||
padding: 9px 12px;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-mute);
|
||||
border-bottom: 1px solid var(--line-2);
|
||||
background: var(--panel-2);
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
.table td { padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||
.table tbody tr { transition: background 0.1s; }
|
||||
.table tbody tr:hover { background: rgba(255, 255, 255, 0.022); }
|
||||
.table .num { font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.table .right { text-align: right; }
|
||||
.table .name { color: var(--fg); word-break: break-word; }
|
||||
|
||||
.mono { font-family: var(--mono); }
|
||||
.dim { color: var(--fg-dim); }
|
||||
.mute { color: var(--fg-mute); }
|
||||
.ok { color: var(--ok); }
|
||||
.warn { color: var(--warn); }
|
||||
.err { color: var(--err); }
|
||||
.acc { color: var(--acc); }
|
||||
.small { font-size: 12px; }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.break { word-break: break-all; }
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--panel-2);
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--fg-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tag.acc { border-color: var(--acc-line); color: var(--acc); background: var(--acc-soft); }
|
||||
.tag.ok { border-color: rgba(47, 211, 159, 0.32); color: var(--ok); background: var(--ok-soft); }
|
||||
.tag.err { border-color: rgba(255, 87, 87, 0.32); color: var(--err); background: var(--err-soft); }
|
||||
|
||||
.stat { display: flex; flex-direction: column; gap: 3px; padding: 11px 14px; border: 1px solid var(--line); background: var(--panel); }
|
||||
.stat .v { font-family: var(--mono); font-size: 17px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.empty { padding: 38px 18px; text-align: center; color: var(--fg-mute); font-family: var(--mono); font-size: 12px; }
|
||||
|
||||
.note { display: flex; gap: 10px; padding: 11px 14px; border: 1px solid var(--line-2); background: var(--panel-2); font-size: 12.5px; color: var(--fg-dim); }
|
||||
.note.err { border-color: rgba(255, 87, 87, 0.3); background: var(--err-soft); color: #ffb3b3; }
|
||||
.note.warn { border-color: rgba(255, 192, 70, 0.3); background: rgba(255, 192, 70, 0.08); color: #ffd98a; }
|
||||
.note.ok { border-color: rgba(47, 211, 159, 0.28); background: var(--ok-soft); color: #a6f0d8; }
|
||||
|
||||
.row { display: flex; align-items: center; gap: 10px; }
|
||||
.row.center { justify-content: center; }
|
||||
.row.wrap { flex-wrap: wrap; }
|
||||
.stack { display: flex; flex-direction: column; gap: 14px; }
|
||||
.stack.sm { gap: 8px; }
|
||||
.mt { margin-top: 14px; }
|
||||
.mt-lg { margin-top: 26px; }
|
||||
.py-md { padding-top: 14px; padding-bottom: 14px; }
|
||||
.mx-lg { margin-left: 20px; margin-right: 20px; }
|
||||
.spread { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
|
||||
.panel-ft { padding: 10px 14px; border-top: 1px solid var(--line-2); background: var(--panel-2); }
|
||||
|
||||
.kv { display: grid; grid-template-columns: 104px 1fr; gap: 6px 14px; font-size: 13px; }
|
||||
.kv dt { color: var(--fg-mute); font-family: var(--mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; padding-top: 2px; }
|
||||
.kv dd { margin: 0; }
|
||||
|
||||
/* 拖放区 */
|
||||
.drop {
|
||||
border: 1px dashed var(--line-2);
|
||||
background: var(--panel-2);
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.drop:hover, .drop.over { border-color: var(--acc); background: var(--acc-soft); }
|
||||
.drop .big { font-family: var(--display); font-size: 17px; margin-bottom: 6px; }
|
||||
|
||||
/* 文件树缩进 */
|
||||
.file-indent { display: inline-block; color: var(--fg-mute); }
|
||||
.file-path { font-family: var(--mono); font-size: 12.5px; }
|
||||
.file-dir { color: var(--fg-mute); }
|
||||
.file-name { color: var(--fg); }
|
||||
|
||||
.bar-track { height: 3px; background: var(--line); width: 100%; overflow: hidden; }
|
||||
.bar-fill { height: 100%; background: var(--acc); }
|
||||
|
||||
/* toast */
|
||||
.toasts { position: fixed; right: 22px; bottom: 22px; z-index: 90; display: flex; flex-direction: column; gap: 9px; width: min(420px, calc(100vw - 44px)); }
|
||||
.toast {
|
||||
display: flex; gap: 10px; align-items: flex-start;
|
||||
padding: 12px 14px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--line-2);
|
||||
border-left: 2px solid var(--acc);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.55);
|
||||
font-size: 13px;
|
||||
animation: toast-in 0.22s cubic-bezier(0.2, 0.9, 0.3, 1);
|
||||
}
|
||||
.toast.ok { border-left-color: var(--ok); }
|
||||
.toast.err { border-left-color: var(--err); }
|
||||
.toast.warn { border-left-color: var(--warn); }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
/* 页面进入的错落动画 */
|
||||
.rise { animation: rise 0.42s cubic-bezier(0.16, 1, 0.3, 1) both; }
|
||||
.rise-2 { animation-delay: 0.06s; }
|
||||
.rise-3 { animation-delay: 0.12s; }
|
||||
.rise-4 { animation-delay: 0.18s; }
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.spin { display: inline-block; width: 11px; height: 11px; border: 1.5px solid var(--line-2); border-top-color: var(--acc); border-radius: 50%; animation: spin 0.7s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.footer { border-top: 1px solid var(--line); padding: 16px 0 26px; color: var(--fg-mute); font-family: var(--mono); font-size: 11px; }
|
||||
|
||||
/* 图片预览弹层 */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3vh 3vw;
|
||||
background: rgba(4, 5, 6, 0.87);
|
||||
backdrop-filter: blur(3px);
|
||||
animation: overlay-in 0.18s ease both;
|
||||
}
|
||||
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.lightbox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(1120px, 100%);
|
||||
max-height: 94vh;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line-2);
|
||||
box-shadow: 0 40px 100px rgba(0, 0, 0, 0.72);
|
||||
animation: rise 0.26s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
.lightbox-bd {
|
||||
flex: 1;
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: auto;
|
||||
/* 细网格底纹,透明 PNG 也能看清边界 */
|
||||
background:
|
||||
linear-gradient(rgba(255, 255, 255, 0.018) 1px, transparent 1px) 0 0 / 100% 28px,
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.018) 1px, transparent 1px) 0 0 / 28px 100%,
|
||||
var(--bg);
|
||||
}
|
||||
.lightbox-img { display: block; max-width: 100%; max-height: 76vh; }
|
||||
.lightbox-ft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.lightbox-ft > .mono { flex: 1; text-align: center; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.wrap { padding: 0 16px; }
|
||||
.grid-4, .grid-3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
h1 { font-size: 26px; }
|
||||
.nav a { padding: 6px 9px; font-size: 12px; }
|
||||
.hide-sm { display: none; }
|
||||
.overlay { padding: 0; }
|
||||
.lightbox { max-height: 100vh; width: 100%; border: none; }
|
||||
.lightbox-img { max-height: 66vh; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
const toasts = useToasts()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toasts">
|
||||
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.kind" @click="dismissToast(t.id)">
|
||||
<span class="mono small" :class="t.kind === 'err' ? 'err' : t.kind === 'ok' ? 'ok' : 'acc'">
|
||||
{{ t.kind === 'err' ? '✕' : t.kind === 'warn' ? '!' : t.kind === 'ok' ? '✓' : 'i' }}
|
||||
</span>
|
||||
<span>
|
||||
<strong style="font-weight: 600">{{ t.title }}</strong>
|
||||
<span v-if="t.detail" class="dim" style="display: block; margin-top: 2px; font-size: 12px">{{ t.detail }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import type { TorrentFileEntry } from '~/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
files: TorrentFileEntry[] | null
|
||||
total: number
|
||||
resolving?: boolean
|
||||
resolveError?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ resolve: []; preview: [file: TorrentFileEntry] }>()
|
||||
|
||||
const keyword = ref('')
|
||||
const sort = ref<'size' | 'name'>('size')
|
||||
|
||||
/** 图片且体积没超上限,才值得从 swarm 拉下来预览 */
|
||||
function previewable(file: TorrentFileEntry): boolean {
|
||||
return file.length > 0 && file.length <= MAX_IMAGE_PREVIEW_BYTES && isImagePath(file.path)
|
||||
}
|
||||
|
||||
const visible = computed(() => {
|
||||
let list = props.files || []
|
||||
if (keyword.value) {
|
||||
const kw = keyword.value.toLowerCase()
|
||||
list = list.filter((f) => f.path.toLowerCase().includes(kw))
|
||||
}
|
||||
const sorted = [...list]
|
||||
if (sort.value === 'size') sorted.sort((a, b) => b.length - a.length)
|
||||
else sorted.sort((a, b) => a.path.localeCompare(b.path))
|
||||
return sorted.slice(0, 500)
|
||||
})
|
||||
|
||||
const truncated = computed(() => (props.files?.length || 0) > 500)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="resolving" class="empty">
|
||||
<span class="spin" /> 正在通过 DHT / tracker 获取种子元数据,通常几秒到几十秒…
|
||||
</div>
|
||||
|
||||
<div v-else-if="!files" class="stack sm" style="padding: 18px">
|
||||
<div class="note warn">
|
||||
这个种子只有 infoHash,还没有文件清单。可以点击下面的按钮在线拉取元数据(需要本机能连上 DHT / tracker)。
|
||||
</div>
|
||||
<div v-if="resolveError" class="note err">{{ resolveError }}</div>
|
||||
<div>
|
||||
<button class="btn btn-primary" @click="emit('resolve')">在线解析文件清单</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="panel-hd" style="border-top: none">
|
||||
<span class="label">FILE LIST</span>
|
||||
<span class="mono small dim">{{ formatNumber(files.length) }} 个文件 · {{ formatBytes(total) }}</span>
|
||||
<span class="spacer" />
|
||||
<input v-model="keyword" class="input" style="width: 200px" placeholder="过滤文件名…" />
|
||||
<select v-model="sort" class="select" style="width: 120px">
|
||||
<option value="size">按大小</option>
|
||||
<option value="name">按名称</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="max-height: 520px; overflow: auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 58%">路径</th>
|
||||
<th class="right" style="width: 14%">大小</th>
|
||||
<th class="right" style="width: 10%">占比</th>
|
||||
<th style="width: 76px" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(f, i) in visible" :key="i">
|
||||
<td class="file-path">
|
||||
<span class="file-dir">{{ splitPath(f.path).dir }}</span><span class="file-name">{{ splitPath(f.path).name }}</span>
|
||||
</td>
|
||||
<td class="num right">{{ formatBytes(f.length) }}</td>
|
||||
<td class="num right mute">{{ filePercent(f.length, total) }}</td>
|
||||
<td class="right">
|
||||
<button
|
||||
v-if="previewable(f)"
|
||||
class="btn btn-sm btn-ghost"
|
||||
:title="`从做种节点拉取这张图片用于预览:${f.path}`"
|
||||
@click="emit('preview', f)"
|
||||
>
|
||||
预览
|
||||
</button>
|
||||
<span v-else-if="isImagePath(f.path)" class="dim small" title="图片体积超出在线预览上限">过大</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="truncated" class="empty">结果较多,表格只展示前 500 条,可用过滤框缩小范围</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import type { TorrentFileEntry } from '~/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
infoHash: string
|
||||
magnet: string
|
||||
/** 全部可预览的图片,按文件清单原始顺序 */
|
||||
images: TorrentFileEntry[]
|
||||
index: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: []; 'update:index': [value: number] }>()
|
||||
|
||||
const status = ref<'loading' | 'ok' | 'err'>('loading')
|
||||
const errorText = ref('')
|
||||
const src = ref('')
|
||||
let objectUrl = ''
|
||||
|
||||
const current = computed<TorrentFileEntry | undefined>(() => props.images[props.index])
|
||||
const requestUrl = computed(
|
||||
() =>
|
||||
`/api/torrent/${props.infoHash}/preview?path=${encodeURIComponent(
|
||||
current.value?.path || ''
|
||||
)}&magnet=${encodeURIComponent(props.magnet)}`
|
||||
)
|
||||
|
||||
function revoke() {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
objectUrl = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
revoke()
|
||||
src.value = ''
|
||||
errorText.value = ''
|
||||
status.value = 'loading'
|
||||
try {
|
||||
const res = await fetch(requestUrl.value)
|
||||
if (!res.ok) {
|
||||
// 服务端用 statusMessage 带上可读原因:没有做种节点 / 图片过大 / 文件不存在
|
||||
const text = await res.text()
|
||||
let message = text
|
||||
try {
|
||||
const json = JSON.parse(text)
|
||||
message = json.statusMessage || json.message || text
|
||||
} catch {
|
||||
/* 不是 JSON 就用原文 */
|
||||
}
|
||||
throw new Error(message || `请求失败(HTTP ${res.status})`)
|
||||
}
|
||||
objectUrl = URL.createObjectURL(await res.blob())
|
||||
src.value = objectUrl
|
||||
status.value = 'ok'
|
||||
} catch (err) {
|
||||
errorText.value = errorMessage(err)
|
||||
status.value = 'err'
|
||||
}
|
||||
}
|
||||
|
||||
function step(delta: number) {
|
||||
const next = props.index + delta
|
||||
if (next < 0 || next >= props.images.length) return
|
||||
emit('update:index', next)
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') emit('close')
|
||||
else if (e.key === 'ArrowLeft') step(-1)
|
||||
else if (e.key === 'ArrowRight') step(1)
|
||||
}
|
||||
|
||||
watch(() => [props.index, props.infoHash, props.magnet], load, { immediate: true })
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKey))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
revoke()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overlay" @click.self="emit('close')">
|
||||
<div class="lightbox">
|
||||
<div class="panel-hd">
|
||||
<span class="label">IMAGE</span>
|
||||
<span class="mono small truncate" style="max-width: 36ch" :title="current?.path">
|
||||
{{ splitPath(current?.path || '').name }}
|
||||
</span>
|
||||
<span class="spacer" />
|
||||
<span class="mono small dim">{{ index + 1 }} / {{ images.length }}</span>
|
||||
<span class="mono small dim">{{ formatBytes(current?.length) }}</span>
|
||||
<button class="btn btn-sm btn-ghost" @click="emit('close')">关闭</button>
|
||||
</div>
|
||||
|
||||
<div class="lightbox-bd">
|
||||
<div v-if="status === 'loading'" class="empty">
|
||||
<span class="spin" /> 正在从做种节点拉取这张图片,取决于对方的上传速度…
|
||||
</div>
|
||||
<div v-else-if="status === 'err'" class="stack sm" style="padding: 22px">
|
||||
<div class="note err">{{ errorText }}</div>
|
||||
<div class="row">
|
||||
<button class="btn btn-sm" @click="load">重试</button>
|
||||
<a class="btn btn-sm btn-ghost" :href="requestUrl" target="_blank" rel="noreferrer noopener">
|
||||
直接打开图片地址
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<img v-else :src="src" :alt="current?.path || 'preview'" class="lightbox-img" />
|
||||
</div>
|
||||
|
||||
<div class="lightbox-ft">
|
||||
<button class="btn btn-sm" :disabled="index <= 0" @click="step(-1)">← 上一张</button>
|
||||
<span class="mono small dim truncate" style="max-width: 44ch" :title="current?.path">{{ current?.path }}</span>
|
||||
<button class="btn btn-sm" :disabled="index >= images.length - 1" @click="step(1)">下一张 →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import type { ClientsResponse, TorrentView } from '~/types/api'
|
||||
|
||||
const props = defineProps<{ torrent: TorrentView }>()
|
||||
|
||||
const clients = ref<ClientsResponse | null>(null)
|
||||
const loading = ref(false)
|
||||
const sending = ref(false)
|
||||
const selectedId = ref('')
|
||||
const dir = ref('')
|
||||
|
||||
const statuses = computed(() => clients.value?.statuses || [])
|
||||
const onlineStatuses = computed(() => statuses.value.filter((s) => s.online))
|
||||
const current = computed(() => statuses.value.find((s) => s.id === selectedId.value) || null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
clients.value = await $fetch<ClientsResponse>('/api/clients')
|
||||
const firstOnline = clients.value.statuses.find((s) => s.online)
|
||||
if (!selectedId.value) selectedId.value = (firstOnline || clients.value.statuses[0])?.id || ''
|
||||
syncDir()
|
||||
} catch (err) {
|
||||
pushToast('err', '读取下载器配置失败', errorMessage(err))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function syncDir() {
|
||||
const cfg = clients.value?.configs.find((c) => c.id === selectedId.value)
|
||||
dir.value = cfg?.downloadDir || ''
|
||||
}
|
||||
|
||||
watch(selectedId, syncDir)
|
||||
|
||||
async function send() {
|
||||
if (!selectedId.value) return
|
||||
sending.value = true
|
||||
try {
|
||||
const res = await $fetch<{ ok: boolean; message: string }>('/api/send', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
clientId: selectedId.value,
|
||||
infoHash: props.torrent.infoHash,
|
||||
magnet: props.torrent.magnet || undefined,
|
||||
name: props.torrent.name,
|
||||
downloadDir: dir.value || undefined
|
||||
}
|
||||
})
|
||||
pushToast('ok', '投递成功', res.message)
|
||||
refreshHealth()
|
||||
} catch (err) {
|
||||
pushToast('err', '投递失败', errorMessage(err))
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="panel-hd">
|
||||
<span class="label">SEND TO LOCAL CLIENT</span>
|
||||
<span class="mono small" :class="onlineStatuses.length ? 'ok' : 'err'">
|
||||
{{ loading ? '探测中…' : `${onlineStatuses.length}/${statuses.length} 在线` }}
|
||||
</span>
|
||||
<span class="spacer" />
|
||||
<button class="btn btn-sm btn-ghost" :disabled="loading" @click="load">重新探测</button>
|
||||
<NuxtLink to="/settings" class="btn btn-sm btn-ghost">配置</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="panel-bd stack sm">
|
||||
<div v-if="!statuses.length" class="note warn">
|
||||
还没有配置任何下载器。去「设置」里填 qBittorrent / Transmission / aria2 的地址和账号密码。
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="row wrap">
|
||||
<div
|
||||
v-for="s in statuses"
|
||||
:key="s.id"
|
||||
class="pill"
|
||||
:class="s.online ? 'on' : 'off'"
|
||||
:title="s.error || s.endpoint"
|
||||
>
|
||||
<i class="dot" />
|
||||
{{ s.name }}
|
||||
<span class="mute">{{ s.version ? `v${s.version}` : s.online ? '' : '离线' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<label class="field">
|
||||
<span class="label">目标下载器</span>
|
||||
<select v-model="selectedId" class="select">
|
||||
<option v-for="s in statuses" :key="s.id" :value="s.id">
|
||||
{{ s.name }}({{ s.online ? '在线' : '离线' }})
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">下载目录(留空用客户端默认)</span>
|
||||
<input v-model="dir" class="input" placeholder="D:\\Downloads" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="current && !current.online" class="note err">{{ current.error || '该下载器当前不可用' }}</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="btn btn-primary" :disabled="sending || !selectedId" @click="send">
|
||||
<span v-if="sending" class="spin" />
|
||||
{{ sending ? '投递中…' : '投递到本机下载器' }}
|
||||
</button>
|
||||
<span class="mute small">种子交给本机客户端下载,服务器不中转文件内容</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import type { TorrentFileEntry, TorrentView } from '~/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
torrent: TorrentView
|
||||
/** 是否允许在线解析磁力链文件清单 */
|
||||
canResolve?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ resolved: [payload: { files: TorrentFileEntry[]; size: number; name: string }] }>()
|
||||
|
||||
const local = ref<TorrentView>({ ...props.torrent })
|
||||
const resolving = ref(false)
|
||||
const showTrackers = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.torrent,
|
||||
(next) => {
|
||||
local.value = { ...next }
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const files = computed(() => local.value.files)
|
||||
const total = computed(() => files.value?.reduce((s, f) => s + f.length, 0) || local.value.size || 0)
|
||||
|
||||
/** 可在线预览的图片:按文件清单原始顺序,预览弹窗靠这个顺序翻页 */
|
||||
const previewImages = computed<TorrentFileEntry[]>(() =>
|
||||
(files.value || []).filter((f) => f.length > 0 && f.length <= MAX_IMAGE_PREVIEW_BYTES && isImagePath(f.path))
|
||||
)
|
||||
const previewIndex = ref<number | null>(null)
|
||||
|
||||
function openPreview(file: TorrentFileEntry) {
|
||||
const index = previewImages.value.findIndex((f) => f.path === file.path)
|
||||
if (index >= 0) previewIndex.value = index
|
||||
}
|
||||
|
||||
// 文件清单变了(例如刚在线解析完),旧的索引可能已经失效
|
||||
watch(files, () => {
|
||||
previewIndex.value = null
|
||||
})
|
||||
|
||||
const sourceIcon: Record<string, string> = {
|
||||
apibay: 'The Pirate Bay',
|
||||
nyaa: 'Nyaa',
|
||||
cilisousuo: '磁力搜索',
|
||||
upload: '本地上传',
|
||||
magnet: '磁力链',
|
||||
database: '数据库'
|
||||
}
|
||||
|
||||
async function doResolve() {
|
||||
resolving.value = true
|
||||
try {
|
||||
const res = await $fetch<{ files: TorrentFileEntry[]; size: number; name: string }>(
|
||||
`/api/torrent/${local.value.infoHash}/resolve`,
|
||||
{ method: 'POST', body: { magnet: local.value.magnet } }
|
||||
)
|
||||
local.value.files = res.files
|
||||
local.value.size = res.size
|
||||
local.value.fileCount = res.files.length
|
||||
local.value.resolveError = null
|
||||
if (res.name) local.value.name = res.name
|
||||
emit('resolved', res)
|
||||
pushToast('ok', '文件清单已解析', `共 ${res.files.length} 个文件 / ${formatBytes(res.size)}`)
|
||||
} catch (err) {
|
||||
const msg = errorMessage(err)
|
||||
local.value.resolveError = msg
|
||||
pushToast('err', '解析失败', msg)
|
||||
} finally {
|
||||
resolving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyMagnet() {
|
||||
const ok = await copyText(local.value.magnet)
|
||||
pushToast(ok ? 'ok' : 'err', ok ? '磁力链已复制到剪贴板' : '复制失败,请手动选中复制')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 如果没有文件清单,且是磁力链接(有 magnet),则自动尝试解析
|
||||
if (!local.value.files && local.value.magnet && props.canResolve !== false) {
|
||||
doResolve()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stack">
|
||||
<div class="panel">
|
||||
<div class="panel-hd">
|
||||
<span class="label">TORRENT</span>
|
||||
<span class="mono small truncate" style="max-width: 46ch" :title="local.name">{{ local.name }}</span>
|
||||
<span class="spacer" />
|
||||
<span class="tag">{{ sourceIcon[local.source || ''] || local.source || '未知来源' }}</span>
|
||||
<span v-if="local.category" class="tag acc">{{ local.category }}</span>
|
||||
</div>
|
||||
|
||||
<div class="panel-bd stack">
|
||||
<div class="grid-4">
|
||||
<div class="stat">
|
||||
<span class="label">总大小</span>
|
||||
<span class="v">{{ formatBytes(total) }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">文件数</span>
|
||||
<span class="v">{{ local.files ? formatNumber(local.files.length) : local.fileCount ?? '未知' }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">做种 / 下载</span>
|
||||
<span class="v">
|
||||
<span class="ok">{{ local.seeders ?? '—' }}</span>
|
||||
<span class="mute"> / </span>
|
||||
<span class="warn">{{ local.leechers ?? '—' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">发布时间</span>
|
||||
<span class="v small">{{ local.creationDate ? formatDate(local.creationDate) : '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="kv">
|
||||
<dt>infoHash</dt>
|
||||
<dd class="mono break small">{{ local.infoHash }}</dd>
|
||||
<dt>Trackers</dt>
|
||||
<dd>
|
||||
<button class="btn btn-sm btn-ghost" @click="showTrackers = !showTrackers">
|
||||
{{ local.trackers?.length || 0 }} 个{{ showTrackers ? ' · 收起' : ' · 展开' }}
|
||||
</button>
|
||||
<div v-if="showTrackers" class="stack sm mt" style="max-height: 180px; overflow: auto">
|
||||
<span v-for="t in local.trackers" :key="t" class="mono small dim break">{{ t }}</span>
|
||||
</div>
|
||||
</dd>
|
||||
<dt v-if="local.comment">备注</dt>
|
||||
<dd v-if="local.comment" class="small dim">{{ local.comment }}</dd>
|
||||
</dl>
|
||||
|
||||
<div class="row wrap">
|
||||
<button class="btn btn-sm" @click="copyMagnet">复制磁力链</button>
|
||||
<a v-if="local.hasFile" class="btn btn-sm" :href="`/api/torrent/${local.infoHash}/file`">下载 .torrent</a>
|
||||
<a v-if="local.sourceUrl" class="btn btn-sm btn-ghost" :href="local.sourceUrl" target="_blank" rel="noreferrer noopener">
|
||||
打开来源页面
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SendPanel :torrent="local" />
|
||||
|
||||
<div v-if="previewImages.length" class="note">
|
||||
文件清单里有 {{ previewImages.length }} 张图片可以点「预览」直接看:只会按需拉取这几张图所在的分片,不会下载整个种子。
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-bd tight">
|
||||
<FileTable
|
||||
:files="files"
|
||||
:total="total"
|
||||
:resolving="resolving"
|
||||
:resolve-error="local.resolveError"
|
||||
@resolve="doResolve"
|
||||
@preview="openPreview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImagePreview
|
||||
v-if="previewIndex !== null"
|
||||
:info-hash="local.infoHash"
|
||||
:magnet="local.magnet"
|
||||
:images="previewImages"
|
||||
:index="previewIndex"
|
||||
@update:index="previewIndex = $event"
|
||||
@close="previewIndex = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { HealthResponse } from '~/types/api'
|
||||
|
||||
/** 全局健康状态:Mongo / webtorrent / 客户端在线数,用于顶栏状态灯与页面提示 */
|
||||
export function useHealth() {
|
||||
return useState<HealthResponse | null>('health', () => null)
|
||||
}
|
||||
|
||||
export async function refreshHealth() {
|
||||
const state = useHealth()
|
||||
try {
|
||||
state.value = await $fetch<HealthResponse>('/api/health')
|
||||
} catch {
|
||||
state.value = null
|
||||
}
|
||||
return state.value
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export type ToastKind = 'info' | 'ok' | 'err' | 'warn'
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
kind: ToastKind
|
||||
title: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
let seq = 0
|
||||
|
||||
export function useToasts() {
|
||||
return useState<Toast[]>('toasts', () => [])
|
||||
}
|
||||
|
||||
export function dismissToast(id: number) {
|
||||
const toasts = useToasts()
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||
}
|
||||
|
||||
export function pushToast(kind: ToastKind, title: string, detail?: string, ttl = 5200) {
|
||||
const toasts = useToasts()
|
||||
const id = ++seq
|
||||
toasts.value = [...toasts.value, { id, kind, title, detail }]
|
||||
if (import.meta.client && ttl > 0) {
|
||||
setTimeout(() => dismissToast(id), ttl)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
/** 从 $fetch 抛出的错误里抠出可读信息 */
|
||||
export function errorMessage(err: unknown): string {
|
||||
const e = err as { statusMessage?: string; data?: { statusMessage?: string; message?: string }; message?: string }
|
||||
return (
|
||||
e?.data?.statusMessage ||
|
||||
e?.statusMessage ||
|
||||
e?.data?.message ||
|
||||
e?.message ||
|
||||
'请求失败'
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { locale, setLocale, type Locale } from '~/utils/i18n'
|
||||
|
||||
const route = useRoute()
|
||||
const health = useHealth()
|
||||
|
||||
const lang = computed<Locale>({
|
||||
get: () => locale.value,
|
||||
set: (v) => setLocale(v)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
refreshHealth()
|
||||
})
|
||||
|
||||
const mongoPill = computed(() => {
|
||||
if (!health.value) return { cls: '', text: 'MONGO ?' }
|
||||
return health.value.mongo.ok
|
||||
? { cls: 'on', text: `MONGO · ${health.value.mongo.db}` }
|
||||
: { cls: 'off', text: 'MONGO 未连接' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
<div class="wrap topbar-in">
|
||||
<NuxtLink to="/" class="brand">
|
||||
<span class="brand-mark">BT</span>
|
||||
<span>
|
||||
<span class="brand-name">Torrent Console</span><br />
|
||||
<span class="brand-sub">预览台 / 投递器</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
|
||||
<nav class="nav">
|
||||
<NuxtLink to="/" :class="{ active: route.path === '/' }">搜索</NuxtLink>
|
||||
<NuxtLink to="/downloads" :class="{ active: route.path === '/downloads' }">任务</NuxtLink>
|
||||
<NuxtLink to="/history" :class="{ active: route.path === '/history' }">历史</NuxtLink>
|
||||
<NuxtLink to="/settings" :class="{ active: route.path === '/settings' }">设置</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<div class="topbar-right">
|
||||
<span class="pill hide-sm" :class="health && !health.webTorrent ? 'warn' : ''">
|
||||
<i class="dot" />
|
||||
{{ health?.webTorrent ? '磁力解析就绪' : '磁力解析不可用' }}
|
||||
</span>
|
||||
<NuxtLink to="/settings" class="pill" :class="mongoPill.cls" :title="health?.mongo.error || ''">
|
||||
<i class="dot" />
|
||||
{{ mongoPill.text }}
|
||||
</NuxtLink>
|
||||
<select class="pill lang-select" v-model="lang" title="Language" aria-label="Language">
|
||||
<option value="en">EN</option>
|
||||
<option value="zh">中文</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="grow wrap">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="wrap spread">
|
||||
<span>本地自托管 · 单进程 Nuxt 单体应用</span>
|
||||
<span class="mute">种子只做解析与预览,文件内容由你自己的下载器拉取</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import type { BtTask, ClientTaskGroup } from '~/server/utils/clients'
|
||||
|
||||
const { data, refresh, pending } = await useFetch<{
|
||||
groups: ClientTaskGroup[]
|
||||
totalCount: number
|
||||
onlineCount: number
|
||||
}>('/api/clients/tasks')
|
||||
|
||||
const timer = ref<any>(null)
|
||||
|
||||
onMounted(() => {
|
||||
timer.value = setInterval(() => {
|
||||
if (!pending.value) refresh()
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer.value) clearInterval(timer.value)
|
||||
})
|
||||
|
||||
function statusCls(status: BtTask['status']) {
|
||||
switch (status) {
|
||||
case 'downloading': return 'ok'
|
||||
case 'seeding': return 'acc'
|
||||
case 'error': return 'err'
|
||||
case 'paused': return 'mute'
|
||||
case 'checking': return 'warn'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
function statusText(status: BtTask['status']) {
|
||||
switch (status) {
|
||||
case 'downloading': return '下载中'
|
||||
case 'seeding': return '做种中'
|
||||
case 'paused': return '已暂停'
|
||||
case 'error': return '错误'
|
||||
case 'queued': return '排队中'
|
||||
case 'checking': return '校验中'
|
||||
case 'completed': return '已完成'
|
||||
default: return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
function formatEta(seconds?: number) {
|
||||
if (seconds === undefined || seconds >= 8640000) return '—'
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
|
||||
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<section class="rise">
|
||||
<p class="label">ACTIVE · DOWNLOADS</p>
|
||||
<h1>实时下载状态</h1>
|
||||
<p class="sub">直接从你配置的 qBittorrent / Transmission / aria2 客户端拉取的实时数据。</p>
|
||||
</section>
|
||||
|
||||
<div class="grid-4 mt rise rise-2">
|
||||
<div class="stat">
|
||||
<span class="label">活跃任务总数</span>
|
||||
<span class="v">{{ data?.totalCount ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">在线下载器</span>
|
||||
<span class="v">{{ data?.onlineCount ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">刷新频率</span>
|
||||
<span class="v small">每 5 秒自动同步</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<button class="btn btn-sm btn-ghost mt-sm" :disabled="pending" @click="refresh()">
|
||||
{{ pending ? '同步中…' : '手动刷新' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="group in data?.groups" :key="group.clientId" class="stack mt rise rise-3">
|
||||
<div class="panel">
|
||||
<div class="panel-hd">
|
||||
<span class="label">{{ group.clientName }}</span>
|
||||
<span class="pill" :class="group.online ? 'on' : 'off'">
|
||||
<i class="dot" />
|
||||
{{ group.online ? '在线' : '离线' }}
|
||||
</span>
|
||||
<span v-if="group.error" class="small err ml">{{ group.error }}</span>
|
||||
<span class="spacer" />
|
||||
<span class="mono small dim">{{ group.tasks.length }} 个任务</span>
|
||||
</div>
|
||||
|
||||
<div class="panel-bd tight">
|
||||
<table v-if="group.tasks.length" class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40%">任务名称</th>
|
||||
<th style="width: 15%">进度</th>
|
||||
<th class="right">大小</th>
|
||||
<th class="right hide-sm">下载速度</th>
|
||||
<th class="right hide-sm">剩余时间</th>
|
||||
<th class="right">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in group.tasks" :key="t.infoHash">
|
||||
<td class="name">
|
||||
<NuxtLink class="mono small truncate" :to="`/torrent/${t.infoHash}`" style="max-width: 40ch">
|
||||
{{ t.name }}
|
||||
</NuxtLink>
|
||||
</td>
|
||||
<td>
|
||||
<div class="row sm">
|
||||
<div class="grow" style="height: 4px; background: var(--line); border-radius: 2px; overflow: hidden">
|
||||
<div
|
||||
style="height: 100%; background: var(--acc); transition: width 0.3s"
|
||||
:style="{ width: (t.progress * 100) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<span class="mono small" style="min-width: 4ch; text-align: right">
|
||||
{{ (t.progress * 100).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="num right">{{ formatBytes(t.size) }}</td>
|
||||
<td class="num right hide-sm">
|
||||
<span :class="t.dlspeed > 0 ? 'ok' : 'dim'">
|
||||
{{ t.dlspeed > 0 ? `${formatBytes(t.dlspeed)}/s` : '—' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="num right dim hide-sm">{{ formatEta(t.eta) }}</td>
|
||||
<td class="right">
|
||||
<span class="tag" :class="statusCls(t.status)">{{ statusText(t.status) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty">暂无任务</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!data?.groups.length && !pending" class="empty mt">
|
||||
还没有配置任何下载器,或者所有下载器都已禁用。
|
||||
<NuxtLink to="/settings" class="btn btn-sm btn-primary ml">去设置</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ml { margin-left: 12px; }
|
||||
.mt-sm { margin-top: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
import type { TorrentView } from '~/types/api'
|
||||
|
||||
interface SearchItem {
|
||||
_id: string
|
||||
keyword: string
|
||||
providers: string[]
|
||||
resultCount: number
|
||||
durationMs: number
|
||||
ok: boolean
|
||||
error: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const { data, refresh, pending } = await useFetch<{
|
||||
items: SearchItem[]
|
||||
hot: { keyword: string; count: number; last: string }[]
|
||||
total: number
|
||||
torrentTotal: number
|
||||
offline: boolean
|
||||
}>('/api/history')
|
||||
|
||||
const torrents = ref<TorrentView[]>([])
|
||||
const torrentsPending = ref(false)
|
||||
|
||||
async function loadTorrents() {
|
||||
torrentsPending.value = true
|
||||
try {
|
||||
const res = await $fetch<{ items: TorrentView[] }>('/api/torrents', { query: { limit: 30 } })
|
||||
torrents.value = res.items
|
||||
} catch {
|
||||
torrents.value = []
|
||||
} finally {
|
||||
torrentsPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOne(id: string) {
|
||||
try {
|
||||
await $fetch('/api/history', { method: 'DELETE', query: { id } })
|
||||
pushToast('ok', '已删除该条记录')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
pushToast('err', '删除失败', errorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
if (!confirm('确定清空全部搜索历史?')) return
|
||||
try {
|
||||
const res = await $fetch<{ deleted: number }>('/api/history', { method: 'DELETE' })
|
||||
pushToast('ok', `已清空 ${res.deleted} 条记录`)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
pushToast('err', '清空失败', errorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadTorrents)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<section class="rise">
|
||||
<p class="label">HISTORY · LIBRARY</p>
|
||||
<h1>搜索历史与种子库</h1>
|
||||
<p class="sub">每次搜索都会落到 MongoDB 的 searches 集合;搜到和解析过的种子按 infoHash 去重存在 torrents 集合。</p>
|
||||
</section>
|
||||
|
||||
<div v-if="data?.offline" class="note warn mt rise rise-2">
|
||||
MongoDB 未连接,历史记录不可用。启动本地 MongoDB 后刷新本页即可。
|
||||
</div>
|
||||
|
||||
<div class="grid-4 mt rise rise-2">
|
||||
<div class="stat"><span class="label">搜索总次数</span><span class="v">{{ formatNumber(data?.total) }}</span></div>
|
||||
<div class="stat"><span class="label">库中种子数</span><span class="v">{{ formatNumber(data?.torrentTotal) }}</span></div>
|
||||
<div class="stat"><span class="label">高频关键词</span><span class="v">{{ data?.hot?.length || 0 }}</span></div>
|
||||
<div class="stat">
|
||||
<span class="label">状态</span>
|
||||
<span class="v small" :class="data?.offline ? 'err' : 'ok'">{{ data?.offline ? '未连接' : '正常' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data?.hot?.length" class="panel mt rise rise-3">
|
||||
<div class="panel-hd"><span class="label">TOP KEYWORDS</span><span class="spacer" /><span class="mute small">点击直接再搜一次</span></div>
|
||||
<div class="panel-bd row wrap">
|
||||
<NuxtLink
|
||||
v-for="h in data.hot"
|
||||
:key="h.keyword"
|
||||
class="tag acc"
|
||||
:to="{ path: '/', query: { q: h.keyword } }"
|
||||
style="padding: 4px 10px; font-size: 12px"
|
||||
>
|
||||
{{ h.keyword }} ×{{ h.count }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel mt rise rise-3">
|
||||
<div class="panel-hd">
|
||||
<span class="label">SEARCH LOG</span>
|
||||
<span class="spacer" />
|
||||
<button class="btn btn-sm btn-ghost" :disabled="pending" @click="refresh()">
|
||||
{{ pending ? '刷新中…' : '刷新' }}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" :disabled="!data?.items?.length" @click="clearAll">清空</button>
|
||||
</div>
|
||||
<div class="panel-bd tight">
|
||||
<table v-if="data?.items?.length" class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 42%">关键词</th>
|
||||
<th class="right">结果数</th>
|
||||
<th class="right hide-sm">耗时</th>
|
||||
<th class="hide-sm">源</th>
|
||||
<th class="right">时间</th>
|
||||
<th class="right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="it in data.items" :key="it._id">
|
||||
<td>
|
||||
<NuxtLink class="name" :to="{ path: '/', query: { q: it.keyword } }">{{ it.keyword }}</NuxtLink>
|
||||
<span v-if="!it.ok && it.error" class="small err" style="display: block">{{ it.error }}</span>
|
||||
</td>
|
||||
<td class="num right" :class="it.ok ? '' : 'err'">{{ it.resultCount }}</td>
|
||||
<td class="num right dim hide-sm">{{ it.durationMs }}ms</td>
|
||||
<td class="hide-sm"><span class="tag">{{ it.providers?.join(' + ') || '—' }}</span></td>
|
||||
<td class="num right dim">{{ formatRelative(it.createdAt) }}</td>
|
||||
<td class="right">
|
||||
<button class="btn btn-sm btn-ghost" @click="removeOne(it._id)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty">还没有搜索记录</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel mt rise rise-4">
|
||||
<div class="panel-hd">
|
||||
<span class="label">RECENT TORRENTS</span>
|
||||
<span class="spacer" />
|
||||
<button class="btn btn-sm btn-ghost" :disabled="torrentsPending" @click="loadTorrents">
|
||||
{{ torrentsPending ? '加载中…' : '刷新' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel-bd tight">
|
||||
<table v-if="torrents.length" class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 48%">名称</th>
|
||||
<th>来源</th>
|
||||
<th class="right">大小</th>
|
||||
<th class="right hide-sm">文件</th>
|
||||
<th class="right">更新</th>
|
||||
<th class="right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in torrents" :key="t.infoHash">
|
||||
<td class="name">{{ t.name }}</td>
|
||||
<td><span class="tag">{{ t.source }}</span></td>
|
||||
<td class="num right">{{ formatBytes(t.size) }}</td>
|
||||
<td class="num right dim hide-sm">{{ t.files ? t.files.length : t.fileCount ?? '未解析' }}</td>
|
||||
<td class="num right dim">{{ formatRelative(t.resolvedAt || t.creationDate) }}</td>
|
||||
<td class="right">
|
||||
<NuxtLink class="btn btn-sm" :to="{ path: `/torrent/${t.infoHash}`, query: { magnet: t.magnet } }">打开</NuxtLink>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty">库里还没有种子</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,271 @@
|
||||
<script setup lang="ts">
|
||||
import type { SearchResponse, TorrentView } from '~/types/api'
|
||||
|
||||
type Tab = 'search' | 'file' | 'magnet'
|
||||
|
||||
const route = useRoute()
|
||||
const health = useHealth()
|
||||
|
||||
const tab = ref<Tab>('search')
|
||||
|
||||
/* ---------------- 关键词搜索 ---------------- */
|
||||
const keyword = ref('')
|
||||
const activeProviders = ref<string[]>([])
|
||||
const searching = ref(false)
|
||||
const response = ref<SearchResponse | null>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const pagedResults = computed(() => {
|
||||
if (!response.value) return []
|
||||
const start = (page.value - 1) * pageSize.value
|
||||
return response.value.results.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
const totalPages = computed(() => {
|
||||
if (!response.value) return 0
|
||||
return Math.ceil(response.value.results.length / pageSize.value)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const data = health.value || (await refreshHealth())
|
||||
if (data) activeProviders.value = data.enabledProviders
|
||||
const q = String(route.query.q || '').trim()
|
||||
if (q) {
|
||||
keyword.value = q
|
||||
await doSearch()
|
||||
}
|
||||
})
|
||||
|
||||
/* ---------------- 上传 / 磁力 ---------------- */
|
||||
const dropping = ref(false)
|
||||
const parsing = ref(false)
|
||||
const parsed = ref<TorrentView | null>(null)
|
||||
const magnetInput = ref('')
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
async function doSearch(term?: string) {
|
||||
const q = (term ?? keyword.value).trim()
|
||||
if (!q) {
|
||||
pushToast('warn', '请输入关键词')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果输入的是 infoHash,直接跳转到详情页
|
||||
if (/^[a-f0-9]{40}$/i.test(q)) {
|
||||
navigateTo(`/torrent/${q.toLowerCase()}`)
|
||||
return
|
||||
}
|
||||
|
||||
keyword.value = q
|
||||
searching.value = true
|
||||
page.value = 1
|
||||
try {
|
||||
response.value = await $fetch<SearchResponse>('/api/search', {
|
||||
query: { q, providers: activeProviders.value.join(',') }
|
||||
})
|
||||
if (!response.value.results.length) {
|
||||
pushToast('warn', '没有搜到结果', '可以换个关键词,或检查代理设置')
|
||||
}
|
||||
} catch (err) {
|
||||
pushToast('err', '搜索失败', errorMessage(err))
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function pickFile(file: File) {
|
||||
parsing.value = true
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const res = await $fetch<{ torrent: TorrentView; stored: boolean }>('/api/parse', {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
parsed.value = res.torrent
|
||||
pushToast('ok', '种子解析完成', `${res.torrent.files?.length || 0} 个文件 / ${formatBytes(res.torrent.size)}`)
|
||||
} catch (err) {
|
||||
pushToast('err', '解析失败', errorMessage(err))
|
||||
} finally {
|
||||
parsing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFileChange(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (file) pickFile(file)
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dropping.value = false
|
||||
const file = event.dataTransfer?.files?.[0]
|
||||
if (file) pickFile(file)
|
||||
}
|
||||
|
||||
async function parseMagnet() {
|
||||
const magnet = magnetInput.value.trim()
|
||||
if (!magnet) {
|
||||
pushToast('warn', '请粘贴磁力链')
|
||||
return
|
||||
}
|
||||
parsing.value = true
|
||||
try {
|
||||
const res = await $fetch<{ torrent: TorrentView }>('/api/parse', { method: 'POST', body: { magnet } })
|
||||
parsed.value = res.torrent
|
||||
pushToast('ok', '磁力链解析完成', '文件清单需要点击「在线解析」拉取')
|
||||
} catch (err) {
|
||||
pushToast('err', '解析失败', errorMessage(err))
|
||||
} finally {
|
||||
parsing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function previewLink(item: TorrentView) {
|
||||
return {
|
||||
path: `/torrent/${item.infoHash}`,
|
||||
query: { magnet: item.magnet, name: item.name }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<section class="rise">
|
||||
<p class="label">SEARCH · PARSE · HANDOFF</p>
|
||||
<h1>搜种子、看清里面有什么,再丢给本机下载器</h1>
|
||||
<p class="sub">
|
||||
搜索走公开磁力源,预览只看文件清单(不下服务器),确认后把种子投递给本机的
|
||||
qBittorrent / Transmission / aria2,由它们完成实际下载。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div v-if="health && !health.mongo.ok" class="note warn mt rise rise-2">
|
||||
MongoDB 未连接,搜索记录与种子缓存不会被保存(其余功能可用)。启动本地 MongoDB 后刷新即可:
|
||||
<span class="mono">{{ health.mongo.uri }}</span>
|
||||
</div>
|
||||
|
||||
<div class="tabs rise rise-2">
|
||||
<button :class="{ on: tab === 'search' }" @click="tab = 'search'">关键词搜索</button>
|
||||
<button :class="{ on: tab === 'file' }" @click="tab = 'file'">上传 .torrent</button>
|
||||
<button :class="{ on: tab === 'magnet' }" @click="tab = 'magnet'">磁力链</button>
|
||||
</div>
|
||||
|
||||
<!-- ============ 关键词搜索 ============ -->
|
||||
<div v-show="tab === 'search'" class="stack mt rise rise-3">
|
||||
<form class="panel" @submit.prevent="doSearch()">
|
||||
<div class="panel-hd">
|
||||
<span class="label">QUERY</span>
|
||||
<span class="spacer" />
|
||||
<span class="mono small mute">{{ health?.searchProxy === '已配置' ? '经代理访问' : '直连访问' }}</span>
|
||||
</div>
|
||||
<div class="panel-bd stack sm">
|
||||
<div class="row">
|
||||
<input v-model="keyword" class="input" placeholder="输入片名 / 剧名 / 关键词,回车搜索" autofocus />
|
||||
<button class="btn btn-primary" type="submit" :disabled="searching">
|
||||
<span v-if="searching" class="spin" />
|
||||
{{ searching ? '搜索中' : '搜索' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="row wrap">
|
||||
<span class="label">搜索源</span>
|
||||
<label v-for="p in health?.providers || []" :key="p.id" class="check">
|
||||
<input v-model="activeProviders" type="checkbox" :value="p.id" />
|
||||
{{ p.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="response" class="row wrap small">
|
||||
<span class="mono dim">
|
||||
{{ response.results.length }} 条结果 · {{ response.durationMs }}ms ·
|
||||
{{ response.stored ? '已写入 MongoDB' : 'MongoDB 未连接,未记录' }}
|
||||
</span>
|
||||
<span class="spacer" />
|
||||
<span v-for="r in response.reports" :key="r.id" class="tag" :class="r.ok ? 'ok' : 'err'" :title="r.error || ''">
|
||||
{{ r.label }} {{ r.ok ? `${r.count} 条 / ${r.ms}ms` : r.error }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="response" class="panel rise">
|
||||
<div class="panel-bd tight">
|
||||
<table v-if="response.results.length" class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 46%">名称</th>
|
||||
<th class="hide-sm">来源</th>
|
||||
<th class="right">大小</th>
|
||||
<th class="right hide-sm">文件</th>
|
||||
<th class="right">做种</th>
|
||||
<th class="right hide-sm">下载</th>
|
||||
<th class="right hide-sm">发布</th>
|
||||
<th class="right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in pagedResults" :key="item.infoHash">
|
||||
<td class="name">{{ item.name }}</td>
|
||||
<td class="hide-sm"><span class="tag">{{ item.sourceLabel }}</span></td>
|
||||
<td class="num right">{{ formatBytes(item.size) }}</td>
|
||||
<td class="num right hide-sm dim">{{ item.fileCount ?? '—' }}</td>
|
||||
<td class="num right ok">{{ item.seeders ?? '—' }}</td>
|
||||
<td class="num right warn hide-sm">{{ item.leechers ?? '—' }}</td>
|
||||
<td class="num right dim hide-sm">{{ formatRelative(item.addedAt) }}</td>
|
||||
<td class="right">
|
||||
<NuxtLink class="btn btn-sm" :to="previewLink(item)">预览</NuxtLink>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty">没有结果。换个关键词,或在「设置」里配置代理后再试。</div>
|
||||
</div>
|
||||
|
||||
<div v-if="response && response.results.length > pageSize" class="panel-ft row center py-md">
|
||||
<button class="btn btn-sm btn-ghost" :disabled="page <= 1" @click="page--">上一页</button>
|
||||
<span class="mono small dim mx-lg">第 {{ page }} / {{ totalPages }} 页</span>
|
||||
<button class="btn btn-sm btn-ghost" :disabled="page >= totalPages" @click="page++">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!searching" class="panel">
|
||||
<div class="empty">输入关键词开始搜索</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 上传 .torrent ============ -->
|
||||
<div v-show="tab === 'file'" class="stack mt rise rise-3">
|
||||
<div
|
||||
class="drop panel"
|
||||
:class="{ over: dropping }"
|
||||
@click="fileInput?.click()"
|
||||
@dragover.prevent="dropping = true"
|
||||
@dragleave.prevent="dropping = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<div class="big">{{ parsing ? '解析中…' : '把 .torrent 文件拖到这里,或点击选择' }}</div>
|
||||
<p class="mute small">文件只在服务端解析出文件清单,不会上传到任何第三方</p>
|
||||
<input ref="fileInput" type="file" accept=".torrent,application/x-bittorrent" hidden @change="onFileChange" />
|
||||
</div>
|
||||
<TorrentDetail v-if="parsed" :torrent="parsed" :can-resolve="true" />
|
||||
</div>
|
||||
|
||||
<!-- ============ 磁力链 ============ -->
|
||||
<div v-show="tab === 'magnet'" class="stack mt rise rise-3">
|
||||
<div class="panel">
|
||||
<div class="panel-hd"><span class="label">MAGNET URI</span></div>
|
||||
<div class="panel-bd stack sm">
|
||||
<textarea v-model="magnetInput" class="textarea" placeholder="magnet:?xt=urn:btih:…" />
|
||||
<div class="row">
|
||||
<button class="btn btn-primary" :disabled="parsing" @click="parseMagnet">
|
||||
<span v-if="parsing" class="spin" />
|
||||
解析磁力链
|
||||
</button>
|
||||
<span class="mute small">磁力链需要联网从 DHT / tracker 拉取元数据,才能看到文件清单</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TorrentDetail v-if="parsed" :torrent="parsed" :can-resolve="true" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,339 @@
|
||||
<script setup lang="ts">
|
||||
import type { BtClientConfig, ClientsResponse, ClientStatus } from '~/types/api'
|
||||
|
||||
interface DownloadItem {
|
||||
_id: string
|
||||
name: string
|
||||
clientName: string
|
||||
clientType: string
|
||||
downloadDir: string | null
|
||||
ok: boolean
|
||||
message: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const health = useHealth()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const clients = ref<ClientsResponse | null>(null)
|
||||
const configs = ref<BtClientConfig[]>([])
|
||||
const statuses = ref<ClientStatus[]>([])
|
||||
const downloads = ref<DownloadItem[]>([])
|
||||
const testState = ref<Record<string, { testing?: boolean; ok?: boolean; error?: string; version?: string; endpoint?: string }>>({})
|
||||
|
||||
const enabledProviders = ref<string[]>([])
|
||||
const searchProxy = ref('')
|
||||
const searchTimeout = ref(12000)
|
||||
const metadataTimeout = ref(30000)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [c, , d] = await Promise.all([
|
||||
$fetch<ClientsResponse>('/api/clients'),
|
||||
refreshHealth(),
|
||||
$fetch<{ items: DownloadItem[] }>('/api/downloads', { query: { limit: 25 } })
|
||||
])
|
||||
clients.value = c
|
||||
configs.value = JSON.parse(JSON.stringify(c.configs))
|
||||
statuses.value = c.statuses
|
||||
downloads.value = d.items
|
||||
enabledProviders.value = c.settings.enabledProviders
|
||||
searchProxy.value = c.settings.searchProxy
|
||||
searchTimeout.value = c.settings.searchTimeout
|
||||
metadataTimeout.value = c.settings.metadataTimeout
|
||||
} catch (err) {
|
||||
pushToast('err', '读取配置失败', errorMessage(err))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const adapters = computed(() => clients.value?.adapters || [])
|
||||
const statusOf = (id: string) => statuses.value.find((s) => s.id === id)
|
||||
|
||||
function statusPill(cfg: BtClientConfig): ClientStatus | undefined {
|
||||
return statusOf(cfg.id)
|
||||
}
|
||||
|
||||
function addClient() {
|
||||
const seq = configs.value.length + 1
|
||||
configs.value.push({
|
||||
id: `client-${Date.now().toString(36)}`,
|
||||
type: 'qbittorrent',
|
||||
name: `新下载器 ${seq}`,
|
||||
host: '127.0.0.1',
|
||||
port: 8080,
|
||||
username: 'admin',
|
||||
password: '',
|
||||
downloadDir: '',
|
||||
enabled: true,
|
||||
useHttps: false
|
||||
})
|
||||
}
|
||||
|
||||
function removeClient(index: number) {
|
||||
configs.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function onTypeChange(cfg: BtClientConfig) {
|
||||
const adapter = adapters.value.find((a) => a.type === cfg.type)
|
||||
if (adapter) {
|
||||
cfg.port = adapter.defaultPort
|
||||
cfg.path = adapter.defaultPath || undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function testClient(cfg: BtClientConfig) {
|
||||
testState.value = { ...testState.value, [cfg.id]: { testing: true } }
|
||||
try {
|
||||
const res = await $fetch<{ ok: boolean; online: boolean; version: string | null; endpoint: string; error?: string }>(
|
||||
'/api/clients/test',
|
||||
{ method: 'POST', body: { client: cfg } }
|
||||
)
|
||||
testState.value = {
|
||||
...testState.value,
|
||||
[cfg.id]: { ok: res.ok, version: res.version || undefined, endpoint: res.endpoint, error: res.error }
|
||||
}
|
||||
pushToast(res.ok ? 'ok' : 'err', res.ok ? `${cfg.name} 连接正常` : `${cfg.name} 连接失败`, res.error || undefined)
|
||||
} catch (err) {
|
||||
const msg = errorMessage(err)
|
||||
testState.value = { ...testState.value, [cfg.id]: { ok: false, error: msg } }
|
||||
pushToast('err', '测试失败', msg)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await $fetch('/api/clients', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
btClients: configs.value,
|
||||
enabledProviders: enabledProviders.value,
|
||||
searchProxy: searchProxy.value,
|
||||
searchTimeout: searchTimeout.value,
|
||||
metadataTimeout: metadataTimeout.value
|
||||
}
|
||||
})
|
||||
pushToast('ok', '配置已保存')
|
||||
await load()
|
||||
} catch (err) {
|
||||
pushToast('err', '保存失败', errorMessage(err))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<section class="rise">
|
||||
<p class="label">SETTINGS · ADAPTERS</p>
|
||||
<h1>搜索源与本机下载器</h1>
|
||||
<p class="sub">
|
||||
所有配置写在 MongoDB 的 settings 集合里(没连库时回落到 .env)。下载器地址默认指向本机,如果服务部署在别的机器上,改成本机可访问的地址即可。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="row mt rise rise-2">
|
||||
<button class="btn btn-primary" :disabled="saving || loading" @click="save">
|
||||
<span v-if="saving" class="spin" />{{ saving ? '保存中…' : '保存全部配置' }}
|
||||
</button>
|
||||
<button class="btn btn-ghost" :disabled="loading" @click="load">重新加载</button>
|
||||
<span v-if="health && !health.mongo.ok" class="small warn">MongoDB 未连接,配置只能在本次运行内生效</span>
|
||||
</div>
|
||||
|
||||
<!-- ============ 搜索源 ============ -->
|
||||
<div class="panel mt rise rise-2">
|
||||
<div class="panel-hd"><span class="label">SEARCH SOURCES</span></div>
|
||||
<div class="panel-bd stack">
|
||||
<div class="row wrap">
|
||||
<span class="label">启用的搜索源</span>
|
||||
<label v-for="p in health?.providers || []" :key="p.id" class="check">
|
||||
<input v-model="enabledProviders" type="checkbox" :value="p.id" />
|
||||
{{ p.label }}
|
||||
<span class="mute small">{{ p.homepage }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid-3">
|
||||
<label class="field">
|
||||
<span class="label">HTTP 代理(搜索源用)</span>
|
||||
<input v-model="searchProxy" class="input" placeholder="http://127.0.0.1:7890 / 留空直连" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">搜索超时(毫秒)</span>
|
||||
<input v-model.number="searchTimeout" class="input" type="number" min="1000" max="60000" step="500" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">元数据解析超时(毫秒)</span>
|
||||
<input v-model.number="metadataTimeout" class="input" type="number" min="3000" max="180000" step="1000" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
公开磁力站在部分网络环境下不可直连,配好代理后「设置 → 保存」再回搜索页重试即可。代理只作用于搜索源请求,不影响下载器通信。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 下载器 ============ -->
|
||||
<div class="panel mt rise rise-3">
|
||||
<div class="panel-hd">
|
||||
<span class="label">LOCAL BT CLIENTS</span>
|
||||
<span class="mono small" :class="statuses.some((s) => s.online) ? 'ok' : 'err'">
|
||||
{{ statuses.filter((s) => s.online).length }}/{{ statuses.length }} 在线
|
||||
</span>
|
||||
<span class="spacer" />
|
||||
<button class="btn btn-sm btn-ghost" @click="addClient">+ 新增下载器</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-bd stack">
|
||||
<div v-for="(cfg, index) in configs" :key="cfg.id" class="panel" style="background: var(--panel-2)">
|
||||
<div class="panel-hd" style="background: var(--panel-3)">
|
||||
<label class="check">
|
||||
<input v-model="cfg.enabled" type="checkbox" />
|
||||
启用
|
||||
</label>
|
||||
<span class="spacer" />
|
||||
<span v-if="statusPill(cfg)" class="pill" :class="statusPill(cfg)!.online ? 'on' : 'off'" :title="statusPill(cfg)!.error || ''">
|
||||
<i class="dot" />
|
||||
{{ statusPill(cfg)!.online ? `在线 ${statusPill(cfg)!.version || ''}` : '离线' }}
|
||||
</span>
|
||||
<button class="btn btn-sm" :disabled="testState[cfg.id]?.testing" @click="testClient(cfg)">
|
||||
<span v-if="testState[cfg.id]?.testing" class="spin" />测试连接
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" @click="removeClient(index)">删除</button>
|
||||
</div>
|
||||
<div class="panel-bd stack sm">
|
||||
<div class="grid-4">
|
||||
<label class="field">
|
||||
<span class="label">名称</span>
|
||||
<input v-model="cfg.name" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">类型</span>
|
||||
<select v-model="cfg.type" class="select" @change="onTypeChange(cfg)">
|
||||
<option v-for="a in adapters" :key="a.type" :value="a.type">{{ a.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">主机</span>
|
||||
<input v-model="cfg.host" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">端口</span>
|
||||
<input v-model.number="cfg.port" class="input" type="number" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid-4">
|
||||
<label class="field">
|
||||
<span class="label">RPC 路径(可空)</span>
|
||||
<input v-model="cfg.path" class="input" :placeholder="adapters.find((a) => a.type === cfg.type)?.defaultPath || '默认'" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">用户名{{ cfg.type === 'aria2' ? '(aria2 用不到)' : '' }}</span>
|
||||
<input v-model="cfg.username" class="input" :disabled="cfg.type === 'aria2'" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">{{ cfg.type === 'aria2' ? 'RPC 密钥 --rpc-secret' : '密码' }}</span>
|
||||
<input
|
||||
v-model="cfg.password"
|
||||
class="input"
|
||||
:type="cfg.type === 'aria2' ? 'text' : 'password'"
|
||||
:placeholder="cfg.type === 'aria2' ? 'aria2 的 --rpc-secret 值' : ''"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">默认下载目录</span>
|
||||
<input v-model="cfg.downloadDir" class="input" placeholder="留空用客户端默认" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="check">
|
||||
<input v-model="cfg.useHttps" type="checkbox" />
|
||||
使用 HTTPS
|
||||
</label>
|
||||
<span v-if="testState[cfg.id]?.error" class="small err">{{ testState[cfg.id]?.error }}</span>
|
||||
<span v-else-if="testState[cfg.id]?.ok" class="small ok">
|
||||
可达 {{ testState[cfg.id]?.endpoint }} {{ testState[cfg.id]?.version ? `· v${testState[cfg.id]?.version}` : '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<span v-if="cfg.type === 'qbittorrent'">
|
||||
qBittorrent:开启「工具 → 选项 → Web UI」,默认端口 8080;首次访问会让客户端把本机 IP 加入白名单。
|
||||
</span>
|
||||
<span v-else-if="cfg.type === 'transmission'">
|
||||
Transmission:开启远程控制,默认端口 9091,RPC 路径 <span class="mono">/transmission/rpc</span>;没设用户名密码就留空。
|
||||
</span>
|
||||
<span v-else>
|
||||
aria2:启动时加 <span class="mono">--enable-rpc --rpc-listen-all=true --rpc-secret=你的密钥</span>,把密钥填到「RPC 密钥」框。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!configs.length" class="empty">还没有下载器,点右上角「新增下载器」</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 下载记录 ============ -->
|
||||
<div class="panel mt rise rise-4">
|
||||
<div class="panel-hd"><span class="label">HANDOFF LOG</span><span class="spacer" /><span class="mute small">最近 25 条投递记录</span></div>
|
||||
<div class="panel-bd tight">
|
||||
<table v-if="downloads.length" class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 44%">任务</th>
|
||||
<th>下载器</th>
|
||||
<th class="hide-sm">目录</th>
|
||||
<th>结果</th>
|
||||
<th class="right">时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="d in downloads" :key="d._id">
|
||||
<td class="name">{{ d.name }}</td>
|
||||
<td><span class="tag">{{ d.clientName || d.clientId }}</span></td>
|
||||
<td class="small mute hide-sm">{{ d.downloadDir || '默认' }}</td>
|
||||
<td>
|
||||
<span class="tag" :class="d.ok ? 'ok' : 'err'">{{ d.ok ? '成功' : '失败' }}</span>
|
||||
<span class="small mute" style="display: block">{{ d.message }}</span>
|
||||
</td>
|
||||
<td class="num right dim">{{ formatRelative(d.createdAt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty">还没有投递记录</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 环境信息 ============ -->
|
||||
<div class="panel mt rise rise-4">
|
||||
<div class="panel-hd"><span class="label">RUNTIME</span></div>
|
||||
<div class="panel-bd">
|
||||
<dl class="kv">
|
||||
<dt>MongoDB</dt>
|
||||
<dd>
|
||||
<span :class="health?.mongo.ok ? 'ok' : 'err'">{{ health?.mongo.ok ? '已连接' : '未连接' }}</span>
|
||||
<span class="mono small dim"> · {{ health?.mongo.uri }} / {{ health?.mongo.db }}</span>
|
||||
<div v-if="health?.mongo.error" class="small err mono break">{{ health.mongo.error }}</div>
|
||||
</dd>
|
||||
<dt>磁力解析</dt>
|
||||
<dd :class="health?.webTorrent ? 'ok' : 'err'">
|
||||
{{ health?.webTorrent ? 'webtorrent 可用,可通过 DHT/tracker 取文件清单' : 'webtorrent 不可用,磁力链只能显示 infoHash' }}
|
||||
</dd>
|
||||
<dt>搜索源</dt>
|
||||
<dd class="mono small">{{ health?.providers?.map((p) => p.label).join(' / ') }}</dd>
|
||||
<dt>代理</dt>
|
||||
<dd class="mono small">{{ health?.searchProxy }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import type { TorrentView } from '~/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const infoHash = computed(() => String(route.params.infohash || '').toLowerCase())
|
||||
const magnet = computed(() => String(route.query.magnet || ''))
|
||||
|
||||
const { data, error } = await useFetch<{ torrent: TorrentView; source: string }>(
|
||||
() => `/api/torrent/${infoHash.value}`,
|
||||
{ query: { magnet }, watch: [infoHash, magnet] }
|
||||
)
|
||||
|
||||
useHead(() => ({ title: data.value?.torrent?.name ? `${data.value.torrent.name} · 种子预览` : '种子预览' }))
|
||||
|
||||
refreshHealth()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="row small rise">
|
||||
<NuxtLink to="/" class="btn btn-sm btn-ghost">← 返回搜索</NuxtLink>
|
||||
<span class="mono mute truncate">{{ infoHash }}</span>
|
||||
<span v-if="data?.source" class="tag">{{ data.source === 'database' ? '来自 MongoDB 缓存' : '来自链接参数' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="panel mt rise rise-2">
|
||||
<div class="panel-bd stack sm">
|
||||
<span class="label">NOT FOUND</span>
|
||||
<h2>拿不到这个种子的信息</h2>
|
||||
<p class="sub">{{ errorMessage(error) }}</p>
|
||||
<p class="mute small">
|
||||
如果你是直接从外部链接进来的,请带上 magnet 参数,例如
|
||||
<span class="mono">/torrent/<infoHash>?magnet=magnet:?xt=urn:btih:…</span>
|
||||
</p>
|
||||
<div><NuxtLink to="/" class="btn btn-primary">回到搜索页</NuxtLink></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="data?.torrent" class="mt rise rise-2">
|
||||
<TorrentDetail :torrent="data.torrent" :can-resolve="true" />
|
||||
</div>
|
||||
|
||||
<div v-else class="empty mt">
|
||||
<span class="spin" /> 加载中…
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
import { locale, translateDocument } from '~/utils/i18n'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const applyLangAttr = () => {
|
||||
if (import.meta.client) {
|
||||
document.documentElement.lang = locale.value === 'zh' ? 'zh-CN' : 'en'
|
||||
}
|
||||
}
|
||||
|
||||
const run = () => {
|
||||
translateDocument()
|
||||
applyLangAttr()
|
||||
}
|
||||
|
||||
// 初次翻译:等 DOM 就绪
|
||||
if (import.meta.client) {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', run, { once: true })
|
||||
} else {
|
||||
run()
|
||||
}
|
||||
}
|
||||
|
||||
// 客户端渲染 / 数据刷新会改变 DOM,用 MutationObserver 兜底重新翻译
|
||||
if (import.meta.client) {
|
||||
let scheduled = false
|
||||
const schedule = () => {
|
||||
if (scheduled) return
|
||||
scheduled = true
|
||||
requestAnimationFrame(() => {
|
||||
scheduled = false
|
||||
translateDocument()
|
||||
})
|
||||
}
|
||||
const observer = new MutationObserver(schedule)
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
|
||||
|
||||
// 路由切换
|
||||
const router = useRouter()
|
||||
router.afterEach(() => nextTick(schedule))
|
||||
|
||||
// 语言切换时整体重翻
|
||||
watch(locale, () => {
|
||||
applyLangAttr()
|
||||
translateDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
export interface TorrentFileEntry {
|
||||
path: string
|
||||
length: number
|
||||
}
|
||||
|
||||
export interface TorrentView {
|
||||
infoHash: string
|
||||
name: string
|
||||
size: number
|
||||
files: TorrentFileEntry[] | null
|
||||
fileCount: number | null
|
||||
trackers: string[]
|
||||
magnet: string
|
||||
source?: string
|
||||
sourceLabel?: string
|
||||
sourceUrl?: string | null
|
||||
category?: string | null
|
||||
seeders?: number | null
|
||||
leechers?: number | null
|
||||
comment?: string | null
|
||||
creationDate?: string | null
|
||||
resolvedAt?: string | null
|
||||
resolveError?: string | null
|
||||
/** 服务端是否保存了原始 .torrent 文件,可提供下载 */
|
||||
hasFile?: boolean
|
||||
}
|
||||
|
||||
export interface SearchResult extends TorrentView {
|
||||
sourceLabel: string
|
||||
}
|
||||
|
||||
export interface ProviderReport {
|
||||
id: string
|
||||
label: string
|
||||
ok: boolean
|
||||
count: number
|
||||
ms: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
query: string
|
||||
durationMs: number
|
||||
results: SearchResult[]
|
||||
reports: ProviderReport[]
|
||||
stored: boolean
|
||||
}
|
||||
|
||||
export interface ClientStatus {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
label: string
|
||||
endpoint: string
|
||||
online: boolean
|
||||
version?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface BtClientConfig {
|
||||
id: string
|
||||
type: 'qbittorrent' | 'transmission' | 'aria2'
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
path?: string
|
||||
username?: string
|
||||
password?: string
|
||||
downloadDir?: string
|
||||
useHttps?: boolean
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface ClientAdapterMeta {
|
||||
type: string
|
||||
label: string
|
||||
defaultPort: number
|
||||
defaultPath: string | null
|
||||
}
|
||||
|
||||
export interface ClientsResponse {
|
||||
adapters: ClientAdapterMeta[]
|
||||
configs: BtClientConfig[]
|
||||
statuses: ClientStatus[]
|
||||
online: number
|
||||
total: number
|
||||
settings: {
|
||||
searchProxy: string
|
||||
searchTimeout: number
|
||||
metadataTimeout: number
|
||||
enabledProviders: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
ok: boolean
|
||||
time: string
|
||||
mongo: { ok: boolean; uri: string; db: string; error: string | null }
|
||||
webTorrent: boolean
|
||||
providers: { id: string; label: string; homepage: string }[]
|
||||
enabledProviders: string[]
|
||||
searchProxy: string
|
||||
clients: { id: string; type: string; name: string; endpoint: string; enabled: boolean }[]
|
||||
}
|
||||
@@ -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