feat: 放宽 MongoDB 超时(Atlas) + 热搜 3D 词云(Top10) + i18n 补充
This commit is contained in:
+6
-3
@@ -1,4 +1,7 @@
|
|||||||
# MongoDB 连接(留空则回落到本地默认,配置/历史不持久化)
|
# MongoDB 连接(留空则回落到本地默认,配置/历史不持久化)
|
||||||
|
# 本地: mongodb://127.0.0.1:27017
|
||||||
|
# 云端 Atlas 示例(把 <PASSWORD> 换成真实密码,数据库会建在 MONGODB_DB 指定的库,不会落到 test):
|
||||||
|
# MONGODB_URI=mongodb://devops:<PASSWORD>@ac-eqksaup-shard-00-00.utlbkzb.mongodb.net:27017,ac-eqksaup-shard-00-01.utlbkzb.mongodb.net:27017,ac-eqksaup-shard-00-02.utlbkzb.mongodb.net:27017/?ssl=true&replicaSet=atlas-ofsrbz-shard-0&authSource=admin&appName=devops
|
||||||
MONGODB_URI=mongodb://127.0.0.1:27017
|
MONGODB_URI=mongodb://127.0.0.1:27017
|
||||||
MONGODB_DB=torrent_console
|
MONGODB_DB=torrent_console
|
||||||
|
|
||||||
@@ -11,9 +14,9 @@ SEARCH_TIMEOUT=12000
|
|||||||
METADATA_TIMEOUT=30000
|
METADATA_TIMEOUT=30000
|
||||||
|
|
||||||
# 各搜索源基地址(一般无需修改)
|
# 各搜索源基地址(一般无需修改)
|
||||||
APIBAY_BASE=https://apibay.org
|
APIBAY_BASE=
|
||||||
NYAA_BASE=https://nyaa.si
|
NYAA_BASE=
|
||||||
CILISOUSUO_BASE=https://cilisousuo.cc
|
CILISOUSUO_BASE=
|
||||||
|
|
||||||
# 生产运行监听(仅直接运行 .output 时需要)
|
# 生产运行监听(仅直接运行 .output 时需要)
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
interface Word {
|
||||||
|
text: string
|
||||||
|
weight: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
words: Word[]
|
||||||
|
max?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ select: [keyword: string] }>()
|
||||||
|
|
||||||
|
const R = 130
|
||||||
|
const tagEls = ref<(HTMLElement | null)[]>([])
|
||||||
|
const paused = ref(false)
|
||||||
|
let raf = 0
|
||||||
|
let ry = 0
|
||||||
|
let rx = 0.15
|
||||||
|
const bases: { x: number; y: number; z: number }[] = []
|
||||||
|
|
||||||
|
/** 用斐波那契球把 N 个点均匀铺在球面上 */
|
||||||
|
function buildBases(n: number) {
|
||||||
|
bases.length = 0
|
||||||
|
const golden = Math.PI * (3 - Math.sqrt(5))
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const phi = Math.acos(1 - (2 * (i + 0.5)) / n)
|
||||||
|
const theta = golden * i
|
||||||
|
bases.push({
|
||||||
|
x: Math.sin(phi) * Math.cos(theta),
|
||||||
|
y: Math.sin(phi) * Math.sin(theta),
|
||||||
|
z: Math.cos(phi)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
if (!paused.value) {
|
||||||
|
ry += 0.0022
|
||||||
|
rx += 0.0006
|
||||||
|
}
|
||||||
|
const maxW = Math.max(1, ...props.words.map((w) => w.weight))
|
||||||
|
const cosY = Math.cos(ry)
|
||||||
|
const sinY = Math.sin(ry)
|
||||||
|
const cosX = Math.cos(rx)
|
||||||
|
const sinX = Math.sin(rx)
|
||||||
|
for (let i = 0; i < bases.length; i++) {
|
||||||
|
const el = tagEls.value[i]
|
||||||
|
if (!el) continue
|
||||||
|
const b = bases[i]
|
||||||
|
// 绕 Y 轴旋转
|
||||||
|
const x1 = b.x * cosY + b.z * sinY
|
||||||
|
const z1 = -b.x * sinY + b.z * cosY
|
||||||
|
const y1 = b.y
|
||||||
|
// 绕 X 轴旋转
|
||||||
|
const y2 = y1 * cosX - z1 * sinX
|
||||||
|
const z2 = y1 * sinX + z1 * cosX
|
||||||
|
const x2 = x1
|
||||||
|
|
||||||
|
const depth = (z2 + 1) / 2 // 0(远) ~ 1(近)
|
||||||
|
const scale = 0.55 + depth * 0.85
|
||||||
|
const w = props.words[i]
|
||||||
|
const size = 13 + (w.weight / maxW) * 16
|
||||||
|
|
||||||
|
el.style.transform = `translate(-50%, -50%) translate3d(${x2 * R}px, ${y2 * R}px, ${z2 * R}px) scale(${scale})`
|
||||||
|
el.style.opacity = String(0.35 + depth * 0.65)
|
||||||
|
el.style.zIndex = String(Math.round(depth * 100))
|
||||||
|
el.style.fontSize = `${size}px`
|
||||||
|
el.style.color = depth > 0.78 ? 'var(--acc)' : depth > 0.5 ? 'var(--fg)' : 'var(--mute)'
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
buildBases(props.words.length)
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => cancelAnimationFrame(raf))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.words.length,
|
||||||
|
(n) => buildBases(n)
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cloud">
|
||||||
|
<div class="cloud-hd">
|
||||||
|
<span class="label">HOT KEYWORDS</span>
|
||||||
|
<span class="spacer" />
|
||||||
|
<span class="mute small">点击关键词直接搜索</span>
|
||||||
|
</div>
|
||||||
|
<div class="cloud-stage" @mouseenter="paused = true" @mouseleave="paused = false">
|
||||||
|
<button
|
||||||
|
v-for="(w, i) in words"
|
||||||
|
:key="w.text"
|
||||||
|
class="cloud-tag"
|
||||||
|
:ref="(el) => { tagEls[i] = (el as unknown as HTMLElement) || null }"
|
||||||
|
@click="emit('select', w.text)"
|
||||||
|
>
|
||||||
|
{{ w.text }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cloud {
|
||||||
|
margin-top: 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
padding: 12px 14px 4px;
|
||||||
|
}
|
||||||
|
.cloud-hd {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.cloud-stage {
|
||||||
|
position: relative;
|
||||||
|
height: 300px;
|
||||||
|
perspective: 900px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cloud-tag {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.2s;
|
||||||
|
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
.cloud-tag:hover {
|
||||||
|
color: var(--acc) !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+17
-1
@@ -6,6 +6,17 @@ type Tab = 'search' | 'file' | 'magnet'
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const health = useHealth()
|
const health = useHealth()
|
||||||
|
|
||||||
|
/* ---------------- 热搜 3D 词云 ---------------- */
|
||||||
|
const { data: hotData, refresh: refreshHot } = await useFetch<{ keywords: { keyword: string; count: number }[] }>('/api/hot', {
|
||||||
|
query: { limit: 10 }
|
||||||
|
})
|
||||||
|
const hotWords = computed(() => (hotData.value?.keywords || []).map((k) => ({ text: k.keyword, weight: k.count })))
|
||||||
|
|
||||||
|
function onHotSelect(kw: string) {
|
||||||
|
if (tab.value !== 'search') tab.value = 'search'
|
||||||
|
doSearch(kw)
|
||||||
|
}
|
||||||
|
|
||||||
const tab = ref<Tab>('search')
|
const tab = ref<Tab>('search')
|
||||||
|
|
||||||
/* ---------------- 关键词搜索 ---------------- */
|
/* ---------------- 关键词搜索 ---------------- */
|
||||||
@@ -71,6 +82,7 @@ async function doSearch(term?: string) {
|
|||||||
pushToast('err', '搜索失败', errorMessage(err))
|
pushToast('err', '搜索失败', errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
searching.value = false
|
searching.value = false
|
||||||
|
refreshHot()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +153,7 @@ function previewLink(item: TorrentView) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="health && !health.mongo.ok" class="note warn mt rise rise-2">
|
<div v-if="health && !health.mongo.ok" class="note warn mt rise rise-2">
|
||||||
MongoDB 未连接,搜索记录与种子缓存不会被保存(其余功能可用)。启动本地 MongoDB 后刷新即可:
|
MongoDB 未连接,搜索记录与种子缓存不会被保存(其余功能可用)。在 .env 配置正确的 MONGODB_URI(云端实例请填真实密码)后刷新即可:
|
||||||
<span class="mono">{{ health.mongo.uri }}</span>
|
<span class="mono">{{ health.mongo.uri }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -177,6 +189,10 @@ function previewLink(item: TorrentView) {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<ClientOnly>
|
||||||
|
<KeywordCloud3D v-if="hotWords.length" :words="hotWords" @select="onHotSelect" />
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
<div v-if="response" class="row wrap small">
|
<div v-if="response" class="row wrap small">
|
||||||
<span class="mono dim">
|
<span class="mono dim">
|
||||||
{{ response.results.length }} 条结果 · {{ response.durationMs }}ms ·
|
{{ response.results.length }} 条结果 · {{ response.durationMs }}ms ·
|
||||||
|
|||||||
@@ -219,7 +219,13 @@ export const zh2en: Record<string, string> = {
|
|||||||
|
|
||||||
// ===== 输入框占位符 =====
|
// ===== 输入框占位符 =====
|
||||||
'输入片名 / 剧名 / 关键词,回车搜索': 'Movie / show / keyword, press Enter to search',
|
'输入片名 / 剧名 / 关键词,回车搜索': 'Movie / show / keyword, press Enter to search',
|
||||||
'http://127.0.0.1:7890 / 留空直连': 'http://127.0.0.1:7890 / blank for direct'
|
'http://127.0.0.1:7890 / 留空直连': 'http://127.0.0.1:7890 / blank for direct',
|
||||||
|
|
||||||
|
// ===== 热词云 / 搜索页提示 =====
|
||||||
|
'点击关键词直接搜索': 'Click a keyword to search',
|
||||||
|
'已写入 MongoDB': 'Saved to MongoDB',
|
||||||
|
'MongoDB 未连接,未记录': 'MongoDB not connected, not recorded',
|
||||||
|
'MongoDB 未连接,搜索记录与种子缓存不会被保存(其余功能可用)。在 .env 配置正确的 MONGODB_URI(云端实例请填真实密码)后刷新即可:': 'MongoDB not connected; search history and torrent cache will not be saved (other features still work). Set a valid MONGODB_URI in .env (use the real password for cloud instances) and refresh:'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const en2zh: Record<string, string> = Object.fromEntries(
|
export const en2zh: Record<string, string> = Object.fromEntries(
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { searchesCol, tryMongo } from '../utils/mongo'
|
||||||
|
|
||||||
|
/** 热搜关键词:按搜索次数降序,返回前 N 个 */
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const limit = Math.min(Number(getQuery(event).limit) || 10, 50)
|
||||||
|
|
||||||
|
const data = await tryMongo(async () => {
|
||||||
|
const col = await searchesCol()
|
||||||
|
const hot = await col
|
||||||
|
.aggregate<{ _id: string; count: number; last: Date }>([
|
||||||
|
{ $group: { _id: '$keyword', count: { $sum: 1 }, last: { $max: '$createdAt' } } },
|
||||||
|
{ $sort: { count: -1, last: -1 } },
|
||||||
|
{ $limit: limit }
|
||||||
|
])
|
||||||
|
.toArray()
|
||||||
|
return hot.map((h) => ({ keyword: h._id, count: h.count, last: h.last }))
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!data) return { keywords: [], offline: true }
|
||||||
|
return { keywords: data, offline: false }
|
||||||
|
})
|
||||||
@@ -15,8 +15,10 @@ export async function getClient(): Promise<MongoClient> {
|
|||||||
if (client) return client
|
if (client) return client
|
||||||
if (!connecting) {
|
if (!connecting) {
|
||||||
const next = new MongoClient(mongoUri(), {
|
const next = new MongoClient(mongoUri(), {
|
||||||
serverSelectionTimeoutMS: 3000,
|
serverSelectionTimeoutMS: 10000,
|
||||||
connectTimeoutMS: 3000
|
connectTimeoutMS: 20000,
|
||||||
|
socketTimeoutMS: 30000,
|
||||||
|
retryWrites: true
|
||||||
})
|
})
|
||||||
connecting = next
|
connecting = next
|
||||||
.connect()
|
.connect()
|
||||||
|
|||||||
Reference in New Issue
Block a user