v0.3.7: 恢复前端Vue源码 + 修复AdminDashboard 401根源

This commit is contained in:
2026-05-17 13:26:36 +08:00
parent 09be4c307e
commit 8cd4dabb60
178 changed files with 20570 additions and 5 deletions
+1 -1
View File
@@ -1 +1 @@
0.3.6
0.3.7
+1 -1
View File
@@ -1 +1 @@
0.3.6
0.3.7
+351
View File
@@ -0,0 +1,351 @@
<template>
<div class="home-page">
<div class="hero-section">
<template v-if="configLoaded">
<img v-if="siteLogo" :src="siteLogo" :alt="siteName || 'CloudSearch'" class="logo-img" @error="(e: any) => { (e.target as HTMLElement).style.display='none'; siteLogo='' }" />
<div v-else class="logo-text">{{ siteName || 'CloudSearch' }}</div>
</template>
<div class="search-box">
<el-input
v-model="query"
placeholder="搜索网盘资源,或粘贴视频/网盘链接..."
size="large"
clearable
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" size="large" @click="handleSearch" class="search-btn">
搜 索
</el-button>
</div>
<div class="quote-section" v-if="currentQuote">
<span class="quote-text">「 {{ currentQuote }} 」</span>
<span class="quote-author">---{{ quoteAuthor }}</span>
</div>
</div>
<div class="content-section">
<PromotionBanner v-if="homeBanners.length > 0" :promotions="homeBanners" />
<div v-if="categories.length > 0" class="rankings-grid">
<div
v-for="cat in categories"
:key="cat.category"
class="rank-panel"
>
<div class="panel-header">
<span class="panel-title">{{ getCategoryIcon(cat.category) }} {{ cat.label }}</span>
<div class="panel-tabs">
<span class="panel-tab" :class="{ active: activeTab[cat.category] === 'hot' }" @click="switchTab(cat.category, 'hot')">热榜</span>
<span class="panel-tab" :class="{ active: activeTab[cat.category] === 'newest' }" @click="switchTab(cat.category, 'newest')">最新</span>
</div>
</div>
<div class="panel-body">
<div
v-for="(item, idx) in visibleItems(cat)"
:key="cat.category + '-' + idx"
class="rank-item"
@click="searchTag(item.keyword)"
>
<span class="rank-idx" :class="{ 'top-three': idx < 3 }">{{ idx + 1 }}</span>
<span class="rank-name">{{ item.keyword }}</span>
<span class="rank-cnt" v-if="item.rating">⭐{{ item.rating }}</span>
<span class="rank-cnt" v-else>{{ item.searchCount }}</span>
</div>
<!-- 展开按钮 -->
<div v-if="hasMoreItems(cat)" class="rank-expand" @click="expandCategory(cat.category)">
展开全部 ▼
</div>
</div>
<div class="panel-footer">
<span v-if="cat.category !== 'hotsite'">数据来源:TMDB</span>
<span v-else>本站搜索数据</span>
<span class="footer-time">{{ fetchedAt }}</span>
</div>
</div>
</div>
</div>
<div v-if="siteDisclaimer" class="site-footer">
<div class="footer-inner">{{ siteDisclaimer }}</div>
<div class="footer-actions">
<el-button class="footer-disclaimer-btn" size="small" @click="openDisclaimer">📜 免责声明</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Search } from '@element-plus/icons-vue'
import PromotionBanner from '../components/PromotionBanner.vue'
import { getCategorizedRankings, getPromotions, getSiteConfig } from '../api'
import type { Promotion } from '../types'
const router = useRouter()
const query = ref('')
const categories = ref<any[]>([])
const expanded = reactive<Record<string, boolean>>({})
const activeTab = reactive<Record<string, string>>({})
const homeBanners = ref<Promotion[]>([])
const siteLogo = ref('')
const siteName = ref('')
const siteDisclaimer = ref('')
const configLoaded = ref(false)
const currentQuote = ref('')
const quoteAuthor = ref('')
const fetchedAt = ref('')
const INITIAL_SHOW = 8
const CATEGORY_ICONS: Record<string, string> = {
movie: '🎬', tv: '📺', western_movie: '🎥', western: '🌍',
donghua: '🐉', global_anime: '🌐',
domestic_variety: '🎤', global_variety: '🎭',
niche: '💎', hotsite: '🏆',
}
function getCategoryIcon(cat: string): string {
return CATEGORY_ICONS[cat] || '📋'
}
function getItems(cat: any): any[] {
const tab = activeTab[cat.category] || 'hot'
return tab === 'hot' ? (cat.hot || []) : (cat.newest || [])
}
function visibleItems(cat: any): any[] {
const items = getItems(cat)
return expanded[cat.category] ? items : items.slice(0, INITIAL_SHOW)
}
function hasMoreItems(cat: any): boolean {
const items = getItems(cat)
return items.length > INITIAL_SHOW && !expanded[cat.category]
}
function expandCategory(category: string) {
expanded[category] = true
}
function switchTab(category: string, tab: string) {
activeTab[category] = tab
// 切换标签时收起展开
expanded[category] = false
}
function openDisclaimer() {
window.open('/disclaimer/', '_blank')
}
onMounted(async () => {
// 一言 API
try {
const res = await fetch('https://v1.hitokoto.cn/')
const data = await res.json()
currentQuote.value = data.hitokoto || ''
quoteAuthor.value = data.from_who || data.from || ''
} catch {
currentQuote.value = '学而时习之,不亦说乎。'
quoteAuthor.value = '孔子'
}
try {
const [catsData, promos, siteCfg] = await Promise.all([
getCategorizedRankings(),
getPromotions(),
getSiteConfig(),
])
// 新格式: { fetchedAt, categories }
if (catsData.fetchedAt) {
fetchedAt.value = catsData.fetchedAt
categories.value = catsData.categories || []
} else {
// 兼容旧格式
categories.value = Array.isArray(catsData) ? catsData : []
}
for (const cat of categories.value) {
activeTab[cat.category] = 'hot'
expanded[cat.category] = false
}
homeBanners.value = promos.filter((p) => p.position === 'home_banner' && p.active)
if (siteCfg.site_logo) {
siteLogo.value = siteCfg.site_logo
}
if (siteCfg.site_name) {
siteName.value = siteCfg.site_name
}
if (siteCfg.site_disclaimer) {
siteDisclaimer.value = siteCfg.site_disclaimer
}
configLoaded.value = true
} catch (e) {
console.error('加载首页数据失败', e)
}
})
function handleSearch() {
const q = query.value.trim()
if (q) router.push('/search?q=' + encodeURIComponent(q))
}
function searchTag(tag: string) {
router.push('/search?q=' + encodeURIComponent(tag))
}
</script>
<style scoped>
.home-page { min-height: 100vh; display: flex; flex-direction: column; }
.hero-section {
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 60px 24px 40px;
}
.logo-text { font-size: 64px; font-weight: 700; color: var(--primary-color); margin-bottom: 32px; letter-spacing: -2px; }
.logo-img { max-width: 500px; max-height: 120px; width: auto; height: auto; object-fit: contain; margin-bottom: 32px; }
.search-box {
display: flex;
align-items: center;
width: 100%; max-width: 640px;
border: 1px solid #dfe1e5;
border-radius: 24px;
background: #fff;
box-shadow: none;
transition: box-shadow .2s, border-color .2s;
overflow: hidden;
}
.search-box:focus-within {
box-shadow: 0 1px 6px rgba(32,33,36,.28);
border-color: rgba(223,225,229,0);
}
.search-box :deep(.el-input__wrapper) {
border: none; box-shadow: none; background: transparent;
padding: 4px 20px; border-radius: 0;
}
.search-box :deep(.el-input__inner) {
font-size: 15px;
}
.search-btn {
flex-shrink: 0;
border: none;
border-radius: 999px;
padding: 0 24px;
height: 38px;
line-height: 38px;
margin: 4px;
font-size: 14px; font-weight: 600;
background: var(--primary-color); color: #fff;
cursor: pointer; transition: all .2s;
letter-spacing: 1px;
}
.search-btn:hover {
background: #3a7be0;
}
.search-btn:active {
background: #2d6ccf;
}
.quote-section { margin-top: 18px; max-width: 640px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.quote-text { font-size: 14px; color: #aab0b8; font-style: italic; letter-spacing: 0.5px; }
.quote-author { font-size: 12px; color: #c0c4cc; display: inline-block; margin-left: 4px; }
.content-section { max-width: 1500px; width: 100%; margin: 0 auto; padding: 0 16px 60px; }
.rankings-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px;
margin-top: 8px;
}
.rank-panel {
background: var(--bg-white,#fff);
border-radius: 12px; padding: 14px; border: 1px solid #ebeef5;
box-shadow: 0 1px 4px rgba(0,0,0,0.04); display: flex; flex-direction: column;
}
.panel-header {
display: flex; align-items: center; justify-content: space-between;
padding-bottom: 10px; border-bottom: 2px solid #f0f0f0; margin-bottom: 4px;
}
.panel-title { font-size: 15px; font-weight: 700; color: #303133; white-space: nowrap; }
.panel-tabs { display: flex; gap: 2px; background: #f0f2f5; border-radius: 6px; padding: 2px; }
.panel-tab {
font-size: 11px; padding: 3px 10px; border-radius: 5px; cursor: pointer;
color: #909399; font-weight: 500; transition: all .2s; user-select: none;
}
.panel-tab.active { background: #fff; color: var(--primary-color); font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.panel-body { flex: 1; display: flex; flex-direction: column; gap: 1px; }
.rank-item {
display: flex; align-items: center; gap: 8px; padding: 5px 6px;
border-radius: 6px; cursor: pointer; transition: background .15s;
}
.rank-item:hover { background: #f0f5ff; }
.rank-item:active { background: #e6f0ff; }
.rank-idx {
width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center;
justify-content: center; font-size: 12px; font-weight: 700;
color: #909399; background: #f0f0f0; flex-shrink: 0;
}
.rank-idx.top-three { background: var(--primary-color); color: #fff; }
.rank-name { flex: 1; min-width: 0; font-size: 13px; font-weight: 500; color: #303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rank-cnt { font-size: 11px; color: #c0c4cc; white-space: nowrap; flex-shrink: 0; }
.rank-expand {
text-align: center; padding: 6px; margin-top: 2px; font-size: 12px;
color: var(--primary-color); cursor: pointer; border-radius: 6px;
transition: background .15s; user-select: none;
}
.rank-expand:hover { background: #ecf5ff; }
.panel-footer {
margin-top: 8px; padding-top: 8px; border-top: 1px solid #f0f0f0;
display: flex; align-items: center; justify-content: space-between;
font-size: 11px; color: #c0c4cc;
}
.footer-time { font-family: monospace; font-size: 10px; }
@media (max-width: 900px) {
.hero-section { padding: 36px 16px 24px; }
.logo-text { font-size: 36px; margin-bottom: 20px; }
.logo-img { max-width: 360px; max-height: 80px; margin-bottom: 20px; }
.rankings-scroll { gap: 12px; }
}
.site-footer {
margin-top: auto;
padding: 20px 16px 32px;
background: #f9fafb;
border-top: 1px solid #ebeef5;
}
.footer-inner {
max-width: 800px;
margin: 0 auto;
font-size: 12px;
line-height: 1.8;
color: #909399;
text-align: center;
white-space: pre-line;
}
.footer-actions {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.footer-disclaimer-btn {
font-size: 12px !important;
color: #909399 !important;
}
.footer-disclaimer-btn:hover {
color: #409eff !important;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{d as B,o as N,a as V,c as I,b as n,t as c,f as e,w as t,h as g,v as y,j as i,k as r,C as M,l,D as T,G as j,H as q,I as z,J as A,u as D,z as H}from"./index-D279dJND.js";import{a as L,_ as R}from"./_plugin-vue_export-helper-1Z-znrfZ.js";const E={class:"admin-layout"},G={class:"admin-sidebar"},J={class:"sidebar-brand"},W={class:"sidebar-brand-text"},F={class:"sidebar-version"},K={class:"admin-content"},O={class:"content-header"},P={class:"content-breadcrumb"},Q={class:"breadcrumb-current"},U={class:"content-actions"},X={class:"content-body"},Y=B({__name:"AdminLayout",setup(Z){const d=D(),f=H(),m=g(""),_=g(""),b={dashboard:"仪表盘","cloud-configs-toggle":"网盘设置及授权","cloud-configs-cleanup":"存储清理","sys-site":"网站设置","sys-services":"外部服务 & 缓存","sys-strategy":"性能配置","sys-password":"修改管理员密码","sys-notify":"消息推送","save-records":"转存日志"},p=y(()=>{const o=f.name;return o==="admin-cloud-configs"?"cloud-configs-toggle":o==="admin-cleanup"?"cloud-configs-cleanup":o==="admin-system"?f.query.section||"sys-site":o==="admin-save-records"?"save-records":"dashboard"}),x=y(()=>b[p.value]||"仪表盘");function w(o){o==="dashboard"?d.push("/admin/dashboard"):o==="cloud-configs-toggle"?d.push("/admin/cloud-configs"):o==="cloud-configs-cleanup"?d.push("/admin/cleanup"):o.startsWith("sys-")?d.push({path:"/admin/system",query:{section:o}}):o==="save-records"?d.push("/admin/save-records"):o==="logout"&&(localStorage.removeItem("admin_token"),d.push("/admin/login"))}function h(){d.push("/")}return N(async()=>{try{const o=await L();m.value=o.site_name||""}catch{}try{const s=await(await fetch("/health")).json();_.value=s.version}catch{}}),(o,s)=>{const u=i("el-icon"),a=i("el-menu-item"),v=i("el-sub-menu"),C=i("el-menu"),k=i("el-button"),S=i("router-view");return V(),I("div",E,[n("aside",G,[n("div",J,[s[1]||(s[1]=n("div",{class:"sidebar-logo"},"☁️",-1)),n("div",W,[n("h2",null,c(m.value||"CloudSearch"),1),s[0]||(s[0]=n("p",null,"管理控制台",-1))])]),e(C,{"default-active":p.value,class:"sidebar-menu",onSelect:w},{default:t(()=>[e(a,{index:"dashboard"},{default:t(()=>[e(u,null,{default:t(()=>[e(r(M))]),_:1}),s[2]||(s[2]=n("span",null,"仪表盘",-1))]),_:1}),e(v,{index:"cloud-configs"},{title:t(()=>[e(u,null,{default:t(()=>[e(r(T))]),_:1}),s[3]||(s[3]=n("span",null,"网盘管理",-1))]),default:t(()=>[e(a,{index:"cloud-configs-toggle"},{default:t(()=>[...s[4]||(s[4]=[l("📋 设置及授权",-1)])]),_:1}),e(a,{index:"cloud-configs-cleanup"},{default:t(()=>[...s[5]||(s[5]=[l("🧹 存储清理",-1)])]),_:1})]),_:1}),e(v,{index:"system"},{title:t(()=>[e(u,null,{default:t(()=>[e(r(j))]),_:1}),s[6]||(s[6]=n("span",null,"系统设置",-1))]),default:t(()=>[e(a,{index:"sys-site"},{default:t(()=>[...s[7]||(s[7]=[l("🌐 网站设置",-1)])]),_:1}),e(a,{index:"sys-services"},{default:t(()=>[...s[8]||(s[8]=[l("🔗 外部服务 & 缓存",-1)])]),_:1}),e(a,{index:"sys-strategy"},{default:t(()=>[...s[9]||(s[9]=[l("⚡ 性能配置",-1)])]),_:1}),e(a,{index:"sys-password"},{default:t(()=>[...s[10]||(s[10]=[l("🔑 修改密码",-1)])]),_:1}),e(a,{index:"sys-notify"},{default:t(()=>[...s[11]||(s[11]=[l("📬 消息推送",-1)])]),_:1})]),_:1}),e(a,{index:"save-records"},{default:t(()=>[e(u,null,{default:t(()=>[e(r(q))]),_:1}),s[12]||(s[12]=n("span",null,"转存日志",-1))]),_:1}),s[14]||(s[14]=n("div",{class:"sidebar-spacer"},null,-1)),n("div",F,"v"+c(_.value),1),e(a,{index:"logout"},{default:t(()=>[e(u,null,{default:t(()=>[e(r(z))]),_:1}),s[13]||(s[13]=n("span",null,"退出登录",-1))]),_:1})]),_:1},8,["default-active"])]),n("div",K,[n("header",O,[n("div",P,[n("span",Q,c(x.value),1)]),n("div",U,[e(k,{text:"",size:"small",onClick:h},{default:t(()=>[e(u,null,{default:t(()=>[e(r(A))]),_:1}),s[15]||(s[15]=l(" 返回前台 ",-1))]),_:1})])]),n("main",X,[e(S)])])])}}}),es=R(Y,[["__scopeId","data-v-29c9acc0"]]);export{es as default};
@@ -0,0 +1 @@
.admin-layout[data-v-29c9acc0]{display:flex;height:100vh;background:var(--bg-page)}.admin-sidebar[data-v-29c9acc0]{width:var(--sidebar-w);flex-shrink:0;display:flex;flex-direction:column;background:linear-gradient(180deg,#111827,#1e293b);position:relative;z-index:10}.sidebar-brand[data-v-29c9acc0]{display:flex;align-items:center;gap:10px;padding:20px 20px 16px;border-bottom:1px solid rgba(255,255,255,.06)}.sidebar-logo[data-v-29c9acc0]{font-size:28px;line-height:1;flex-shrink:0}.sidebar-brand-text h2[data-v-29c9acc0]{font-size:16px;font-weight:700;margin:0;color:#fff;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sidebar-brand-text p[data-v-29c9acc0]{font-size:11px;margin:2px 0 0;color:#ffffff73;letter-spacing:1px}.sidebar-menu[data-v-29c9acc0]{flex:1;display:flex;flex-direction:column;background:transparent!important;border-right:none!important;padding:4px 0}.sidebar-menu[data-v-29c9acc0] .el-menu-item,.sidebar-menu[data-v-29c9acc0] .el-sub-menu__title{color:#ffffffa6;height:44px;line-height:44px;transition:all .2s ease;margin:0 6px;border-radius:var(--radius-sm)}.sidebar-menu[data-v-29c9acc0] .el-menu-item:hover,.sidebar-menu[data-v-29c9acc0] .el-sub-menu__title:hover{background:#ffffff14;color:#ffffffe6}.sidebar-menu[data-v-29c9acc0] .el-menu-item.is-active{color:#fff;background:linear-gradient(90deg,#409eff40,#6366f126);font-weight:500}.sidebar-menu[data-v-29c9acc0] .el-menu-item:after{display:none}.sidebar-menu[data-v-29c9acc0] .el-sub-menu .el-menu{background:#0003!important}.sidebar-menu[data-v-29c9acc0] .el-sub-menu .el-menu .el-menu-item{padding-left:52px!important;font-size:13px;height:38px;line-height:38px}.sidebar-menu[data-v-29c9acc0] .el-icon{font-size:16px}.sidebar-spacer[data-v-29c9acc0]{flex:1}.sidebar-version[data-v-29c9acc0]{text-align:center;font-size:11px;color:#ffffff40;padding:8px 0;letter-spacing:.5px}.admin-content[data-v-29c9acc0]{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0}.content-header[data-v-29c9acc0]{display:flex;align-items:center;justify-content:space-between;padding:16px 28px;background:var(--bg-card);border-bottom:1px solid var(--border-light);flex-shrink:0}.content-breadcrumb[data-v-29c9acc0]{display:flex;align-items:center;gap:8px}.breadcrumb-current[data-v-29c9acc0]{font-size:18px;font-weight:700;color:var(--text)}.content-actions[data-v-29c9acc0] .el-button{color:var(--text-secondary);gap:4px}.content-body[data-v-29c9acc0]{flex:1;overflow-y:auto;padding:24px 28px}.content-body[data-v-29c9acc0] .save-bar{position:fixed;bottom:32px;right:32px;z-index:100;background:var(--bg-card);padding:12px 16px;border-radius:var(--radius-lg);border:1px solid var(--border);box-shadow:0 4px 16px #0000001f;display:flex;gap:10px;transition:box-shadow .2s,transform .2s}.content-body[data-v-29c9acc0] .save-bar:hover{box-shadow:0 6px 24px #0000002e;transform:translateY(-2px)}
@@ -0,0 +1 @@
.admin-login-page[data-v-bd0b6672]{min-height:100vh;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#0f0c29,#302b63,#24243e);position:relative;overflow:hidden}.login-bg-pattern[data-v-bd0b6672]{position:absolute;top:0;right:0;bottom:0;left:0;background:radial-gradient(circle at 20% 50%,rgba(102,126,234,.12) 0%,transparent 50%),radial-gradient(circle at 80% 30%,rgba(118,75,162,.12) 0%,transparent 50%),radial-gradient(circle at 50% 80%,rgba(64,158,255,.08) 0%,transparent 50%)}.login-card[data-v-bd0b6672]{position:relative;width:400px;padding:48px 40px 36px;background:#fffffff2;-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);border-radius:var(--radius-xl);box-shadow:0 24px 64px #00000040}.login-brand[data-v-bd0b6672]{text-align:center;margin-bottom:36px}.login-logo[data-v-bd0b6672]{font-size:48px;margin-bottom:8px;line-height:1}.login-title[data-v-bd0b6672]{font-size:26px;font-weight:800;color:#1d2129;margin:0 0 4px;letter-spacing:1px}.login-subtitle[data-v-bd0b6672]{font-size:14px;color:#86909c;margin:0;letter-spacing:2px}.login-btn[data-v-bd0b6672]{width:100%;height:44px;font-size:15px;letter-spacing:4px;border-radius:var(--radius-md)}.error-msg[data-v-bd0b6672]{text-align:center;color:#f56c6c;font-size:13px;margin-top:12px;padding:8px 12px;background:#fef0f0;border-radius:var(--radius-sm);line-height:1.4}.login-footer[data-v-bd0b6672]{text-align:center;color:#c9cdd4;font-size:11px;margin-top:20px;margin-bottom:0}[data-theme=dark] .login-card[data-v-bd0b6672]{background:#1d1d1df2}[data-theme=dark] .login-title[data-v-bd0b6672]{color:#e5e5e5}[data-theme=dark] .login-subtitle[data-v-bd0b6672]{color:#666}[data-theme=dark] .error-msg[data-v-bd0b6672]{background:#f56c6c1f}
@@ -0,0 +1 @@
import{d as k,o as C,a as w,c as y,b as a,t as m,f as t,w as i,g as x,e as L,h as d,j as p,l as N,i as S,E as B}from"./index-D279dJND.js";import{a as E,d as M,_ as U}from"./_plugin-vue_export-helper-1Z-znrfZ.js";const j={class:"admin-login-page"},q={class:"login-card"},A={class:"login-brand"},I={class:"login-title"},K={key:0,class:"error-msg"},R={class:"login-footer"},z=k({__name:"AdminLogin",setup(D){const f=d(),u=d(!1),c=d(""),g=d(""),v=d("");E().then(l=>{l.site_name&&(g.value=l.site_name)}).catch(()=>{});const s=S({username:"",password:""}),b={username:[{required:!0,message:"请输入用户名",trigger:"blur"}],password:[{required:!0,message:"请输入密码",trigger:"blur"}]};async function h(){var e,r,n;if(await((e=f.value)==null?void 0:e.validate().catch(()=>!1))){u.value=!0,c.value="";try{const o=await M(s.username,s.password);localStorage.setItem("admin_token",o.token),B.success("登录成功"),window.location.href="/admin"}catch(o){c.value=((n=(r=o==null?void 0:o.response)==null?void 0:r.data)==null?void 0:n.message)||(o==null?void 0:o.message)||"登录失败"}finally{u.value=!1}}}return C(async()=>{try{const e=await(await fetch("/health")).json();v.value=e.version||""}catch{}}),(l,e)=>{const r=p("el-input"),n=p("el-form-item"),o=p("el-button"),V=p("el-form");return w(),y("div",j,[e[4]||(e[4]=a("div",{class:"login-bg-pattern"},null,-1)),a("div",q,[a("div",A,[e[2]||(e[2]=a("div",{class:"login-logo"},"☁️",-1)),a("h1",I,m(g.value||"CloudSearch"),1),e[3]||(e[3]=a("p",{class:"login-subtitle"},"管理后台",-1))]),t(V,{ref_key:"formRef",ref:f,model:s,rules:b,"label-width":"0",size:"large",onKeyup:x(h,["enter"])},{default:i(()=>[t(n,{prop:"username"},{default:i(()=>[t(r,{modelValue:s.username,"onUpdate:modelValue":e[0]||(e[0]=_=>s.username=_),placeholder:"用户名","prefix-icon":"User"},null,8,["modelValue"])]),_:1}),t(n,{prop:"password"},{default:i(()=>[t(r,{modelValue:s.password,"onUpdate:modelValue":e[1]||(e[1]=_=>s.password=_),type:"password",placeholder:"密码","prefix-icon":"Lock","show-password":""},null,8,["modelValue"])]),_:1}),t(n,null,{default:i(()=>[t(o,{type:"primary",loading:u.value,class:"login-btn",onClick:h},{default:i(()=>[N(m(u.value?"登录中...":"登 录"),1)]),_:1},8,["loading"])]),_:1})]),_:1},8,["model"]),c.value?(w(),y("p",K,m(c.value),1)):L("",!0),a("p",R,"CloudSearch v"+m(v.value),1)])])}}}),G=U(z,[["__scopeId","data-v-bd0b6672"]]);export{G as default};
@@ -0,0 +1 @@
.cleanup-section[data-v-dfd5abc7] .el-card__header{font-size:16px;font-weight:600;letter-spacing:.5px;border-bottom:1px solid var(--border)}.cleanup-grid[data-v-dfd5abc7]{display:grid;grid-template-columns:1fr 1fr;gap:16px}.cleanup-group[data-v-dfd5abc7]{border:1px solid var(--border);border-radius:var(--radius-md);padding:16px 18px;background:var(--bg-card);transition:box-shadow .2s}.cleanup-group[data-v-dfd5abc7]:hover{box-shadow:var(--shadow-sm)}.cleanup-group-label[data-v-dfd5abc7]{font-size:14px;font-weight:600;color:var(--primary);margin-bottom:12px;padding-bottom:8px;border-bottom:1px dashed var(--border-light)}.cleanup-hint[data-v-dfd5abc7]{color:var(--text-tertiary);font-size:12px;line-height:1.5}.cleanup-actions[data-v-dfd5abc7]{margin-top:20px;padding:16px 18px;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px}.cleanup-actions-buttons[data-v-dfd5abc7]{display:flex;gap:10px;flex-wrap:wrap}.cleanup-info[data-v-dfd5abc7]{font-size:13px;color:var(--text-tertiary);display:flex;flex-wrap:wrap;gap:4px}@media (max-width: 900px){.cleanup-grid[data-v-dfd5abc7]{grid-template-columns:1fr}}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.cloud-badge[data-v-9106805f]{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:4px;color:#fff;font-size:12px;line-height:1.5;white-space:nowrap}.badge-icon[data-v-9106805f]{width:14px;height:14px;object-fit:contain}
@@ -0,0 +1 @@
import{C as s,b as a,a as n}from"./index-Bn7NwETH.js";import{d as l,a as t,c,p as d,k as o,e as r,l as u,t as m}from"./index-D279dJND.js";import{_}from"./_plugin-vue_export-helper-1Z-znrfZ.js";const p=["src"],i=l({__name:"CloudBadge",props:{cloud_type:{},showIcon:{type:Boolean}},setup(e){return(C,y)=>(t(),c("span",{class:"cloud-badge",style:d({background:o(s)[e.cloud_type]})},[e.showIcon&&o(a)[e.cloud_type]?(t(),c("img",{key:0,src:o(a)[e.cloud_type],class:"badge-icon"},null,8,p)):r("",!0),u(" "+m(o(n)[e.cloud_type]),1)],4))}}),L=_(i,[["__scopeId","data-v-9106805f"]]);export{L as C};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.table-card[data-v-f03a9373]{border-radius:var(--radius-lg);border:1px solid var(--border)!important;margin-bottom:20px}.table-card[data-v-f03a9373] .el-card__header{font-size:15px;font-weight:600;background:var(--bg-card-header);border-bottom:1px solid var(--border);padding:12px 18px}.table-card[data-v-f03a9373] .el-card__body{padding:0}.toolbar[data-v-f03a9373]{margin-bottom:16px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}.nickname-text[data-v-f03a9373]{font-weight:600;color:var(--text)}.promotion-text[data-v-f03a9373]{font-size:12px;color:var(--text-secondary)}.uid-cell[data-v-f03a9373]{font-family:SF Mono,Fira Code,Cascadia Code,monospace;font-size:11px;color:var(--text-tertiary);letter-spacing:.3px}.save-count[data-v-f03a9373]{font-size:12px;color:var(--text-tertiary)}.verifying[data-v-f03a9373]{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--text-tertiary)}.storage-cell[data-v-f03a9373]{display:flex;flex-direction:column;gap:3px;padding:2px 0}.storage-bar-wrap[data-v-f03a9373]{height:4px;background:var(--border-light);border-radius:2px;overflow:hidden}.storage-bar-fill[data-v-f03a9373]{height:100%;border-radius:2px;transition:width .3s}.storage-bar-fill.bar-normal[data-v-f03a9373]{background:#67c23a}.storage-bar-fill.bar-warning[data-v-f03a9373]{background:#e6a23c}.storage-bar-fill.bar-danger[data-v-f03a9373]{background:#f56c6c}.storage-text[data-v-f03a9373]{font-size:11px;color:var(--text-tertiary);display:flex;align-items:center;gap:3px}.storage-used[data-v-f03a9373]{color:var(--text-secondary);font-weight:600}.storage-total[data-v-f03a9373]{color:var(--text);font-weight:600}.storage-free[data-v-f03a9373]{color:var(--text-tertiary)}.cookie-tips-item[data-v-f03a9373] .el-form-item__content{margin-left:0!important}.cookie-tips[data-v-f03a9373]{background:#f8faff;border:1px solid #e8f0fe;border-radius:var(--radius-sm);padding:14px 16px;font-size:12px;line-height:1.8;color:var(--text-secondary);width:100%;box-sizing:border-box}.cookie-tips-header[data-v-f03a9373]{margin-bottom:10px}.cookie-tips-title[data-v-f03a9373]{font-weight:700;color:var(--primary);font-size:13px}.cookie-tips-steps[data-v-f03a9373]{margin:0;padding-left:20px}.cookie-tips-steps li[data-v-f03a9373]{margin-bottom:4px}.cookie-tips-steps code[data-v-f03a9373]{background:var(--primary-soft);padding:1px 5px;border-radius:3px;font-size:11px;font-family:SF Mono,Fira Code,Cascadia Code,monospace}.cookie-tips-note[data-v-f03a9373]{margin-top:8px;padding:6px 10px;background:#fffbe6;border:1px solid #fff3c4;border-radius:4px;color:#8a6d3b;font-size:11px;line-height:1.5}.cookie-tips-note code[data-v-f03a9373]{background:#f5f0e0;font-size:11px}[data-theme=dark] .cookie-tips[data-v-f03a9373]{background:#409eff0f;border-color:#409eff26}[data-theme=dark] .cookie-tips-title[data-v-f03a9373]{color:#66b1ff}[data-theme=dark] .cookie-tips-steps code[data-v-f03a9373]{background:#409eff1f}[data-theme=dark] .cookie-tips-note[data-v-f03a9373]{background:#ffc1071a;border-color:#ffc10733;color:#d4a84b}[data-theme=dark] .cookie-tips-note code[data-v-f03a9373]{background:#ffc1071f}.cloud-toggle-grid[data-v-f03a9373]{display:flex;flex-wrap:wrap;gap:8px}.cloud-toggle-chip[data-v-f03a9373]{display:flex;align-items:center;gap:6px;padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg)}.cloud-toggle-chip[data-v-f03a9373]:hover{border-color:var(--primary)}.cloud-icon-img[data-v-f03a9373]{width:20px;height:20px;object-fit:contain;flex-shrink:0}.cloud-label[data-v-f03a9373]{font-size:13px;font-weight:500}[data-v-f03a9373] .el-dialog__header{font-weight:700;font-size:16px}[data-v-f03a9373] .el-dialog__body{padding:20px 24px}[data-v-f03a9373] .el-dialog__wrapper .el-dialog{border-radius:var(--radius-lg)}
@@ -0,0 +1 @@
import{d as K,o as G,a as n,c as a,b as o,F as w,t as c,e as h,f as v,w as f,g as Q,r as z,h as l,i as B,j as x,u as W,k as Y,s as J,l as D,n as I}from"./index-D279dJND.js";import{g as X,a as Z,_ as ee}from"./_plugin-vue_export-helper-1Z-znrfZ.js";const te={class:"home-page"},se={class:"hero-section"},oe=["src","alt"],ne={key:1,class:"logo-text"},ae={class:"search-box"},ie={key:1,class:"quote-section"},ce={class:"quote-text"},le={class:"quote-author"},re={class:"content-section"},ue={key:0,class:"rankings-grid"},de={class:"panel-header"},_e={class:"panel-title"},he={class:"panel-tabs"},ve=["onClick"],pe=["onClick"],ge={class:"panel-body"},fe=["onClick"],me={class:"rank-name"},ye={class:"rank-cnt"},ke=["onClick"],Ce={class:"panel-footer"},be={key:0},we={key:1},xe={key:2},Ie={key:3},qe={class:"footer-time"},Ae={key:0,class:"site-footer"},Se={class:"footer-inner"},Ne={class:"footer-actions"},R=8,Te=K({__name:"HomePage",setup(Ve){const q=W(),m=l(""),u=l([]),d=B({}),_=B({}),p=l(""),y=l(""),k=l(""),A=l(!1),g=l(""),C=l(""),S=l(""),F={movie:"🎬",western_movie:"🎥",western_tv:"🌍",donghua:"🐉",global_anime:"🌐",tv:"📺",niche:"💎",hotsite:"🏆"};function L(e){return F[e]||"📋"}function j(e){const t=[];if(e.rating&&t.push(`⭐${e.rating}`),e.searchCount>0){const i=e.searchCount;i>=1e8?t.push(`${(i/1e8).toFixed(1)}亿`):i>=1e4?t.push(`${(i/1e4).toFixed(0)}万`):t.push(String(i))}return t.join(" ")||""}function N(e){return(_[e.category]||"hot")==="hot"?e.hot||[]:e.newest||[]}function E(e){const t=N(e);return d[e.category]?t:t.slice(0,R)}function H(e){return N(e).length>R&&!d[e.category]}function M(e){d[e]=!0}function T(e,t){_[e]=t,d[e]=!1}function O(){window.open("/disclaimer/","_blank")}G(async()=>{try{const t=await(await fetch("https://v1.hitokoto.cn/")).json();g.value=t.hitokoto||"",C.value=t.from_who||t.from||""}catch{g.value="学而时习之,不亦说乎。",C.value="孔子"}try{const[e,t]=await Promise.all([X(),Z()]);e.fetchedAt?(S.value=e.fetchedAt,u.value=e.categories||[]):u.value=Array.isArray(e)?e:[];for(const i of u.value)_[i.category]="hot",d[i.category]=!1;t.site_logo&&(p.value=t.site_logo),t.site_name&&(y.value=t.site_name),t.site_disclaimer&&(k.value=t.site_disclaimer),A.value=!0}catch(e){console.error("加载首页数据失败",e)}});function V(){const e=m.value.trim();e&&q.push("/search?q="+encodeURIComponent(e))}function P(e){q.push("/search?q="+encodeURIComponent(e))}return(e,t)=>{const i=x("el-icon"),U=x("el-input"),$=x("el-button");return n(),a("div",te,[o("div",se,[A.value?(n(),a(w,{key:0},[p.value?(n(),a("img",{key:0,src:p.value,alt:y.value||"CloudSearch",class:"logo-img",onError:t[0]||(t[0]=s=>{s.target.style.display="none",p.value=""})},null,40,oe)):(n(),a("div",ne,c(y.value||"CloudSearch"),1))],64)):h("",!0),o("div",ae,[v(U,{modelValue:m.value,"onUpdate:modelValue":t[1]||(t[1]=s=>m.value=s),placeholder:"搜索网盘资源,或粘贴视频/网盘链接...",size:"large",clearable:"",onKeyup:Q(V,["enter"])},{prefix:f(()=>[v(i,null,{default:f(()=>[v(Y(J))]),_:1})]),_:1},8,["modelValue"]),v($,{type:"primary",size:"large",onClick:V,class:"search-btn"},{default:f(()=>[...t[2]||(t[2]=[D(" 搜 索 ",-1)])]),_:1})]),g.value?(n(),a("div",ie,[o("span",ce,"「 "+c(g.value)+" 」",1),o("span",le,"---"+c(C.value),1)])):h("",!0)]),o("div",re,[u.value.length>0?(n(),a("div",ue,[(n(!0),a(w,null,z(u.value,s=>(n(),a("div",{key:s.category,class:"rank-panel"},[o("div",de,[o("span",_e,c(L(s.category))+" "+c(s.label),1),o("div",he,[o("span",{class:I(["panel-tab",{active:_[s.category]==="hot"}]),onClick:r=>T(s.category,"hot")},"热榜",10,ve),o("span",{class:I(["panel-tab",{active:_[s.category]==="newest"}]),onClick:r=>T(s.category,"newest")},"最新",10,pe)])]),o("div",ge,[(n(!0),a(w,null,z(E(s),(r,b)=>(n(),a("div",{key:s.category+"-"+b,class:"rank-item",onClick:$e=>P(r.keyword)},[o("span",{class:I(["rank-idx",{"top-three":b<3}])},c(b+1),3),o("span",me,c(r.keyword),1),o("span",ye,c(j(r)),1)],8,fe))),128)),H(s)?(n(),a("div",{key:0,class:"rank-expand",onClick:r=>M(s.category)}," 展开全部 ▼ ",8,ke)):h("",!0)]),o("div",Ce,[s.category==="hotsite"?(n(),a("span",be,"基于本站搜索数据")):s.category==="donghua"||s.category==="global_anime"?(n(),a("span",we,"数据来源:Bilibili")):s.category==="movie"||s.category==="tv"?(n(),a("span",xe,"数据来源:百度")):(n(),a("span",Ie,"数据来源:TMDB")),o("span",qe,c(S.value),1)])]))),128))])):h("",!0)]),k.value?(n(),a("div",Ae,[o("div",Se,c(k.value),1),o("div",Ne,[v($,{class:"footer-disclaimer-btn",size:"small",onClick:O},{default:f(()=>[...t[3]||(t[3]=[D("📜 免责声明",-1)])]),_:1})])])):h("",!0)])}}}),De=ee(Te,[["__scopeId","data-v-1f536d99"]]);export{De as default};
@@ -0,0 +1 @@
.home-page[data-v-1f536d99]{min-height:100vh;display:flex;flex-direction:column}.hero-section[data-v-1f536d99]{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:60px 24px 40px}.logo-text[data-v-1f536d99]{font-size:64px;font-weight:700;color:var(--primary-color);margin-bottom:32px;letter-spacing:-2px}.logo-img[data-v-1f536d99]{max-width:500px;max-height:120px;width:auto;height:auto;object-fit:contain;margin-bottom:32px}.search-box[data-v-1f536d99]{display:flex;align-items:center;width:100%;max-width:640px;border:1px solid #dfe1e5;border-radius:24px;background:#fff;box-shadow:none;transition:box-shadow .2s,border-color .2s;overflow:hidden}.search-box[data-v-1f536d99]:focus-within{box-shadow:0 1px 6px #20212447;border-color:#dfe1e500}.search-box[data-v-1f536d99] .el-input__wrapper{border:none;box-shadow:none;background:transparent;padding:4px 20px;border-radius:0}.search-box[data-v-1f536d99] .el-input__inner{font-size:15px}.search-btn[data-v-1f536d99]{flex-shrink:0;border:none;border-radius:999px;padding:0 24px;height:38px;line-height:38px;margin:4px;font-size:14px;font-weight:600;background:var(--primary-color);color:#fff;cursor:pointer;transition:all .2s;letter-spacing:1px}.search-btn[data-v-1f536d99]:hover{background:#3a7be0}.search-btn[data-v-1f536d99]:active{background:#2d6ccf}.quote-section[data-v-1f536d99]{margin-top:18px;max-width:640px;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.quote-text[data-v-1f536d99]{font-size:14px;color:#aab0b8;font-style:italic;letter-spacing:.5px}.quote-author[data-v-1f536d99]{font-size:12px;color:#c0c4cc;display:inline-block;margin-left:4px}.content-section[data-v-1f536d99]{max-width:1500px;width:100%;margin:0 auto;padding:0 16px 60px}.rankings-grid[data-v-1f536d99]{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px;margin-top:8px}.rank-panel[data-v-1f536d99]{background:var(--bg-white,#fff);border-radius:12px;padding:14px;border:1px solid #ebeef5;box-shadow:0 1px 4px #0000000a;display:flex;flex-direction:column}.panel-header[data-v-1f536d99]{display:flex;align-items:center;justify-content:space-between;padding-bottom:10px;border-bottom:2px solid #f0f0f0;margin-bottom:4px}.panel-title[data-v-1f536d99]{font-size:15px;font-weight:700;color:#303133;white-space:nowrap}.panel-tabs[data-v-1f536d99]{display:flex;gap:2px;background:#f0f2f5;border-radius:6px;padding:2px}.panel-tab[data-v-1f536d99]{font-size:11px;padding:3px 10px;border-radius:5px;cursor:pointer;color:#909399;font-weight:500;transition:all .2s;-webkit-user-select:none;user-select:none}.panel-tab.active[data-v-1f536d99]{background:#fff;color:var(--primary-color);font-weight:600;box-shadow:0 1px 3px #0000000f}.panel-body[data-v-1f536d99]{flex:1;display:flex;flex-direction:column;gap:1px}.rank-item[data-v-1f536d99]{display:flex;align-items:center;gap:8px;padding:5px 6px;border-radius:6px;cursor:pointer;transition:background .15s}.rank-item[data-v-1f536d99]:hover{background:#f0f5ff}.rank-item[data-v-1f536d99]:active{background:#e6f0ff}.rank-idx[data-v-1f536d99]{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#909399;background:#f0f0f0;flex-shrink:0}.rank-idx.top-three[data-v-1f536d99]{background:var(--primary-color);color:#fff}.rank-name[data-v-1f536d99]{flex:1;min-width:0;font-size:13px;font-weight:500;color:#303133;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rank-cnt[data-v-1f536d99]{font-size:11px;color:#c0c4cc;white-space:nowrap;flex-shrink:0}.rank-expand[data-v-1f536d99]{text-align:center;padding:6px;margin-top:2px;font-size:12px;color:var(--primary-color);cursor:pointer;border-radius:6px;transition:background .15s;-webkit-user-select:none;user-select:none}.rank-expand[data-v-1f536d99]:hover{background:#ecf5ff}.panel-footer[data-v-1f536d99]{margin-top:8px;padding-top:8px;border-top:1px solid #f0f0f0;display:flex;align-items:center;justify-content:space-between;font-size:11px;color:#c0c4cc}.footer-time[data-v-1f536d99]{font-family:monospace;font-size:10px}@media (max-width: 900px){.hero-section[data-v-1f536d99]{padding:36px 16px 24px}.logo-text[data-v-1f536d99]{font-size:36px;margin-bottom:20px}.logo-img[data-v-1f536d99]{max-width:360px;max-height:100px;margin-bottom:20px}.rankings-scroll[data-v-1f536d99]{gap:12px}}.site-footer[data-v-1f536d99]{margin-top:auto;padding:20px 16px 32px;background:#f9fafb;border-top:1px solid #ebeef5}.footer-inner[data-v-1f536d99]{max-width:800px;margin:0 auto;font-size:12px;line-height:1.8;color:#909399;text-align:center;white-space:pre-line}.footer-actions[data-v-1f536d99]{display:flex;justify-content:center;align-items:center;gap:12px;margin-top:12px}.footer-disclaimer-btn[data-v-1f536d99]{font-size:12px!important;color:#909399!important}.footer-disclaimer-btn[data-v-1f536d99]:hover{color:#409eff!important}
@@ -0,0 +1 @@
.cloud-select[data-v-098423df]{width:100%}.result-detail-page[data-v-755e2105]{min-height:100vh;background:#f5f7fa;padding:40px 24px}.detail-container[data-v-755e2105]{max-width:1080px;margin:0 auto}.detail-card[data-v-755e2105]{background:var(--bg-white);border-radius:var(--radius-card);box-shadow:var(--shadow-card);padding:32px}.detail-header[data-v-755e2105]{display:flex;gap:24px;margin-bottom:24px}.detail-cover[data-v-755e2105]{position:relative;flex-shrink:0;width:240px;height:180px;border-radius:12px;overflow:hidden}.detail-cover img[data-v-755e2105]{width:100%;height:100%;object-fit:cover}.detail-info[data-v-755e2105]{flex:1}.detail-info h1[data-v-755e2105]{font-size:24px;font-weight:700;color:#303133;margin-bottom:12px;line-height:1.4}.detail-meta[data-v-755e2105]{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}.detail-desc[data-v-755e2105]{font-size:15px;color:#606266;line-height:1.6}.detail-actions[data-v-755e2105]{border-top:1px solid var(--border-color);padding-top:20px}.detail-video[data-v-755e2105]{margin-bottom:24px}.video-preview[data-v-755e2105]{position:relative;width:100%;border-radius:12px;overflow:hidden;margin-bottom:20px}.video-preview img[data-v-755e2105]{width:100%;max-height:400px;object-fit:cover;display:block}.play-overlay[data-v-755e2105]{position:absolute;top:0;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;background:#0000004d;cursor:pointer;transition:background .2s}.play-overlay[data-v-755e2105]:hover{background:#0006}.play-btn[data-v-755e2105]{padding:12px 32px;background:#ffffffe6;border-radius:24px;font-size:18px;font-weight:600;color:#303133}.video-info h1[data-v-755e2105]{font-size:24px;font-weight:700;color:#303133;margin-bottom:12px}.video-author[data-v-755e2105],.video-platform[data-v-755e2105]{font-size:15px;color:var(--text-secondary);margin-bottom:6px}.video-player-wrapper[data-v-755e2105]{margin-top:24px;border-top:1px solid var(--border-color);padding-top:24px}.video-player[data-v-755e2105]{width:100%;border-radius:8px}.loading-state[data-v-755e2105]{padding:40px 0}.save-dialog-content[data-v-755e2105]{display:flex;flex-direction:column;gap:16px}.save-file-name[data-v-755e2105]{font-size:15px;font-weight:500;color:#303133}.result-dialog-content[data-v-755e2105]{display:flex;flex-direction:column;gap:16px}.share-link-box[data-v-755e2105]{display:flex;flex-direction:column;gap:8px}.share-label[data-v-755e2105]{font-size:14px;color:#606266}.share-link-row[data-v-755e2105]{display:flex;gap:8px}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.el-card[data-v-641a8223]{margin-bottom:20px}.el-card[data-v-641a8223] .el-card__header{font-weight:600;font-size:15px}[data-v-641a8223] .el-divider__text.is-left{left:0;padding-left:0}.form-tip[data-v-641a8223]{font-size:12px;color:#909399;margin-top:4px}.fallback-upload-wrap[data-v-641a8223]{display:flex;flex-direction:column;gap:12px}.fallback-upload-row[data-v-641a8223]{display:flex;align-items:center;flex-wrap:wrap}.fallback-preview[data-v-641a8223]{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.fallback-preview img[data-v-641a8223]{max-width:100%;height:auto;max-height:120px;border-radius:8px;border:1px solid var(--border-color);background:#f0f0f0;object-fit:contain}.strategy-grid[data-v-641a8223]{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px 16px}.grid-cell[data-v-641a8223]{display:flex;flex-direction:column;gap:4px}.strategy-section[data-v-641a8223]{padding:0 4px}.field-block[data-v-641a8223]{margin:12px 0}.field-label-row[data-v-641a8223]{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.field-label[data-v-641a8223]{font-size:14px;font-weight:500;color:#303133;white-space:nowrap}.field-desc[data-v-641a8223]{font-size:12px;color:#909399;margin:3px 0 0;line-height:1.5}.keyword-input-row[data-v-641a8223]{display:flex;gap:8px;flex:1;min-width:200px}.tag-list[data-v-641a8223]{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}.tag-empty[data-v-641a8223]{font-size:13px;color:#c0c4cc;margin-top:8px}.filter-rule-help[data-v-641a8223]{margin-top:8px;padding:10px 12px;background:#f8f9fa;border-radius:8px;border:1px solid #e8e8e8}.filter-rule-help .help-title[data-v-641a8223]{font-weight:600;font-size:13px;margin:8px 0 4px;color:#333}.filter-rule-help .help-title[data-v-641a8223]:first-child{margin-top:0}.filter-rule-help .help-row[data-v-641a8223]{font-size:12px;color:#555;margin:3px 0;line-height:1.6}.filter-rule-help .help-row code[data-v-641a8223]{background:#eef1f5;padding:1px 5px;border-radius:3px;font-size:11px;font-family:monospace}.filter-rules-help[data-v-641a8223]{margin-top:8px;padding:12px;background:#f8f9fa;border-radius:8px;border:1px solid #e8e8e8}.help-title[data-v-641a8223]{font-weight:600;font-size:13px;margin:10px 0 6px;color:#333}.help-title[data-v-641a8223]:first-child{margin-top:0}.help-row[data-v-641a8223]{font-size:12px;color:#555;margin:3px 0;line-height:1.6}.help-row code[data-v-641a8223]{background:#eef1f5;padding:1px 5px;border-radius:3px;font-size:11px;font-family:monospace}.help-sample[data-v-641a8223]{background:#1e1e1e;color:#d4d4d4;padding:10px 14px;border-radius:6px;font-size:12px;line-height:1.6;overflow-x:auto;white-space:pre;margin:6px 0 0;font-family:monospace}.help-preview-row[data-v-641a8223]{font-size:13px;margin:4px 0;display:flex;align-items:center;gap:6px}.help-preview-label[data-v-641a8223]{color:#888;min-width:70px;font-size:12px}.help-preview-original[data-v-641a8223]{color:#e74c3c}.help-preview-filtered[data-v-641a8223]{color:#27ae60;font-weight:500}.filter-input-row[data-v-641a8223]{display:flex;gap:8px;width:100%;margin-bottom:8px}.filter-tag-list[data-v-641a8223]{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}.filter-empty[data-v-641a8223]{font-size:13px;color:#c0c4cc;padding:8px 0;margin-bottom:8px}.db-status-grid[data-v-641a8223]{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;margin-top:8px}.db-stat-item[data-v-641a8223]{background:#f8f9fa;border-radius:10px;padding:16px 12px;text-align:center;border:1px solid #eee;transition:transform .15s,box-shadow .15s}.db-stat-item[data-v-641a8223]:hover{transform:translateY(-2px);box-shadow:0 4px 12px #0000000f}.db-stat-value[data-v-641a8223]{white-space:nowrap;font-size:24px;font-weight:700;color:#303133;margin-bottom:4px}.db-stat-value.text-success[data-v-641a8223]{color:#67c23a}.db-stat-value.text-warning[data-v-641a8223]{color:#e6a23c}.db-stat-label[data-v-641a8223]{font-size:12px;color:#909399}@media (max-width: 900px){.strategy-grid[data-v-641a8223]{grid-template-columns:1fr 1fr}}@media (max-width: 600px){.strategy-grid[data-v-641a8223]{grid-template-columns:1fr}}.pansou-status-grid[data-v-641a8223]{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;margin-top:8px}.status-dot[data-v-641a8223]{width:8px;height:8px;border-radius:50%;display:inline-block}.dot-ok[data-v-641a8223]{background:#67c23a}.dot-err[data-v-641a8223]{background:#f56c6c}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
const n={quark:"夸克网盘",baidu:"百度网盘",aliyun:"阿里云盘",115:"115网盘",tianyi:"天翼云盘","123pan":"123云盘",uc:"UC网盘",xunlei:"迅雷云盘",pikpak:"PikPak",magnet:"磁力链接",ed2k:"电驴链接",others:"其他"},a={quark:"#07c160",baidu:"#4e6ef2",aliyun:"#ff6a00",115:"#9b59b6",tianyi:"#00a1d6","123pan":"#e74c3c",uc:"#f39c12",xunlei:"#2ecc71",pikpak:"#8e44ad",magnet:"#95a5a6",ed2k:"#7f8c8d",others:"#95a5a6"},i={baidu:"/icons/baidu.png",aliyun:"/icons/aliyun.png",quark:"/icons/quark.png",115:"/icons/115.png",tianyi:"/icons/tianyi.png","123pan":"/icons/123pan.png",uc:"/icons/uc.png",xunlei:"/icons/xunlei.png",pikpak:"/icons/pikpak.png",magnet:"/icons/magnet.png",ed2k:"/icons/ed2k.png",others:"/icons/others.png"};export{a as C,n as a,i as b};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+72
View File
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>免责声明 - 资源分享</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: #f5f7fa;
color: #333;
line-height: 1.8;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
padding: 40px;
}
h1 {
font-size: 22px;
text-align: center;
margin-bottom: 30px;
color: #1a1a2e;
border-bottom: 2px solid #e8e8e8;
padding-bottom: 16px;
}
h2 { font-size: 16px; margin: 24px 0 10px; color: #1a1a2e; }
h3 { font-size: 15px; margin: 18px 0 8px; color: #303133; }
p { margin: 8px 0; text-indent: 2em; font-size: 14px; }
.highlight { background: #fff3cd; padding: 1px 4px; border-radius: 3px; }
.footer { margin-top: 30px; padding-top: 16px; border-top: 1px solid #e8e8e8; text-align: center; font-size: 12px; color: #999; }
.back-link { display: inline-block; margin-top: 20px; color: #409eff; text-decoration: none; font-size: 14px; }
.back-link:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<h1>📜 网站免责声明</h1>
<h2>一、版权与资源声明</h2>
<p>本网站(<a href="https://hk-zy.timaa.cn" target="_blank">hk-zy.timaa.cn</a>)是一个基于开源项目搭建的<strong>非盈利性个人站点</strong>,旨在分享与交流技术资源。本网站所有资源均收集整理自互联网,其版权、著作权均归原作者或发行公司所有。本网站不对资源的版权归属进行实质审查,对于任何由资源本身引发的版权争议概不负责。</p>
<h2>二、使用限制与法律责任</h2>
<p>用户在本网站下载的所有软件、资料等资源,仅供<strong>个人学习、研究、技术交流</strong>,严禁用于任何商业或非法用途。用户必须在下载后的<strong class="highlight">24小时内</strong>,从个人电脑及存储设备中彻底删除相关内容。如用户喜欢该程序或内容,请支持正版,到官方网站购买注册。</p>
<p>因用户不当使用(包括但不限于商业使用、非法传播、破解侵权)而引发的一切法律纠纷及后果,由用户<strong>自行承担</strong>,本网站及网站管理者不承担任何连带责任。</p>
<h2>三、"避风港原则"与侵权处理</h2>
<p>依据《信息网络传播权保护条例》,本网站仅提供信息存储空间服务或资源索引服务。若用户上传或分享的内容侵犯了您的合法权益,请您立即通过以下联系方式与我们交涉。</p>
<p><strong>联系方式:</strong> 3337598077@qq.com</p>
<p><strong>处理措施:</strong> 我们在收到权利人发出的合格通知(包括权属证明和侵权链接)后,将在合理期限内对涉嫌侵权内容进行核实、断开链接或直接删除。</p>
<p><strong>唯一目的:</strong> 本网站为纯公益、非盈利性分享,绝无意侵害任何第三方权益。若内容涉及侵权,实属无意,请版权方及时通知以便我们处理。</p>
<h2>四、用户行为与网站免责</h2>
<p>访问者在本网站进行下载、浏览时,须自行承担风险。本网站不保证资源完全无毒、无缺陷或绝对安全,对于因使用本站资源而造成的硬件损坏、数据丢失等损失,本网站不负任何责任。</p>
<p>本站内容仅代表资源提供者的个人观点,不代表本站立场。对于任何站点外部链接的真实性、合法性,本站不承担担保责任。</p>
<p>凡以任何方式登陆本网站或直接、间接使用本网站资源者,视为自愿接受本网站免责声明的约束。</p>
<h2>五、法律适用</h2>
<p>本声明未涉及的问题参见国家有关法律法规。当本声明与国家法律法规冲突时,以国家法律法规为准。本网站保留对本声明的最终解释权。</p>
<div class="footer">
<p style="text-indent:0">更新日期:2026年5月 · 版本 V1.0</p>
<a class="back-link" href="/" onclick="history.length > 1 ? history.back() : (location.href='/'); return false;">← 返回首页</a>
</div>
</div>
</body>
</html>
+923
View File
@@ -0,0 +1,923 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>CloudSearch - 搜索</title>
<script>
// 替换标题为网站名称
fetch('/api/site-config').then(function(r){return r.json()}).then(function(cfg){
if(cfg.site_name) document.title = cfg.site_name + ' - 搜索';
}).catch(function(){});
</script>
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<style>
/* ===== Reset & Base ===== */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{font-size:16px;-webkit-text-size-adjust:100%}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;background:#f5f5f5;color:#303133;min-height:100vh;overflow-x:hidden}
:root{--primary:#409eff;--primary-dark:#337ecc;--primary-light:rgba(64,158,255,0.08);--text:#303133;--text2:#909399;--border:#ebeef5;--bg:#f5f5f5;--white:#fff;--radius:10px;--shadow:0 1px 4px rgba(0,0,0,0.04);--safe-bottom:env(safe-area-inset-bottom,0px)}
a{color:var(--primary);text-decoration:none}
img{display:block;max-width:100%}
/* ===== Home Page ===== */
.home-page{padding-bottom:calc(30px + var(--safe-bottom))}
.home-hero{display:flex;flex-direction:column;align-items:center;padding:36px 16px 20px}
.home-logo{font-size:32px;font-weight:700;color:var(--primary);margin-bottom:20px;text-align:center}
.home-logo-img{max-width:360px;max-height:80px;width:auto;height:auto;object-fit:contain}
.home-search-box{display:flex;width:100%;max-width:500px;border:1px solid var(--border);border-radius:20px;overflow:hidden;background:var(--bg);transition:border-color .2s}
.home-search-box:focus-within{border-color:var(--primary);background:var(--white);box-shadow:0 0 0 3px rgba(64,158,255,.1)}
.home-search-box input{flex:1;height:40px;border:none;padding:0 14px;font-size:14px;outline:none;background:transparent}
.home-search-box button{flex-shrink:0;height:32px;margin:4px;padding:0 22px;border:none;border-radius:999px;background:var(--primary);color:var(--white);font-size:13px;font-weight:600;cursor:pointer}
.home-search-box button:active{background:var(--primary-dark)}
.home-quote{margin-top:12px;font-size:12px;color:#b0b8c4;font-style:italic;text-align:center;max-width:500px;line-height:1.5}
.home-quote-author{font-size:11px;color:#c0c4cc;display:inline-block;margin-top:2px}
/* ===== Home Rankings ===== */
.home-rankings{padding:8px 12px;display:flex;flex-direction:column;gap:10px}
.rank-block{background:var(--white);border-radius:var(--radius);padding:12px;border:1px solid var(--border);box-shadow:var(--shadow)}
.rank-block-hdr{display:flex;align-items:center;justify-content:space-between;padding-bottom:8px;border-bottom:2px solid #f0f0f0;margin-bottom:4px}
.rank-block-title{font-size:14px;font-weight:700;color:var(--text);white-space:nowrap}
.rank-block-tabs{display:flex;gap:2px;background:#f0f2f5;border-radius:5px;padding:2px}
.rank-tab{font-size:11px;padding:2px 9px;border-radius:4px;cursor:pointer;color:#909399;font-weight:500;transition:all .2s;user-select:none}
.rank-tab.active{background:var(--white);color:var(--primary);font-weight:600;box-shadow:0 1px 2px rgba(0,0,0,.06)}
.rank-item{display:flex;align-items:center;gap:7px;padding:5px 6px;border-radius:6px;cursor:pointer;transition:background .15s}
.rank-item:active{background:#f0f5ff}
.rank-idx{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#909399;background:#f0f0f0;flex-shrink:0}
.rank-idx.top3{background:var(--primary);color:var(--white);font-size:12px}
.rank-name{flex:1;min-width:0;font-size:13px;font-weight:500;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rank-cnt{font-size:11px;color:#c0c4cc;white-space:nowrap;flex-shrink:0}
.rank-expand{text-align:center;padding:5px;margin-top:2px;font-size:12px;color:var(--primary);cursor:pointer;border-radius:5px;user-select:none}
.rank-expand:active{background:#ecf5ff}
.rank-block-ftr{margin-top:6px;padding-top:6px;border-top:1px solid #f0f0f0;display:flex;align-items:center;justify-content:space-between;font-size:10px;color:#c0c4cc}
.ftr-time{font-family:monospace;font-size:9px}
/* ===== Layout ===== */
.app{max-width:100%;margin:0 auto;padding-bottom:calc(20px + var(--safe-bottom))}
.header{position:sticky;top:0;z-index:50;background:var(--white);border-bottom:1px solid var(--border);padding:8px 12px}
.header-row{display:flex;align-items:center;gap:10px}
.header-title{font-size:18px;font-weight:700;color:var(--primary);flex-shrink:0}
.header-title-link{text-decoration:none;flex-shrink:0;display:flex;align-items:center}
.header-logo-img{max-width:160px;max-height:36px;width:auto;height:auto;object-fit:contain;display:block}
.header-actions{margin-left:auto;display:flex;gap:6px;align-items:center}
/* ===== Search Bar ===== */
.search-wrap{flex:1;display:flex;border:1px solid var(--border);border-radius:18px;overflow:hidden;background:var(--bg);transition:border-color .2s}
.search-wrap:focus-within{border-color:var(--primary);background:var(--white)}
.search-wrap input{flex:1;height:36px;border:none;padding:0 14px;font-size:14px;outline:none;background:transparent}
.search-wrap button{flex-shrink:0;height:28px;margin:4px;padding:0 18px;border:none;border-radius:999px;background:var(--primary);color:var(--white);font-size:13px;font-weight:600;cursor:pointer;transition:background .2s}
.search-wrap button:active{background:var(--primary-dark)}
.search-wrap button:disabled{opacity:.5}
/* ===== Footer ===== */
.site-footer{margin-top:30px;padding:16px 12px 24px;background:#f9fafb;border-top:1px solid var(--border)}
.footer-inner{max-width:500px;margin:0 auto;font-size:11px;line-height:1.8;color:#909399;text-align:center;white-space:pre-line}
.footer-actions{display:flex;justify-content:center;gap:10px;margin-top:12px;flex-wrap:wrap}
.footer-btn{padding:8px 20px;border:1px solid var(--border);border-radius:8px;background:var(--white);color:var(--text);font-size:13px;cursor:pointer;transition:all .2s}
.footer-btn:active{background:var(--primary-light);border-color:var(--primary);color:var(--primary)}
/* ===== Info Bar ===== */
.info-bar{display:flex;align-items:center;gap:8px;padding:10px 12px 0;font-size:12px;color:var(--text2);flex-wrap:wrap}
.info-bar .count{font-weight:600;color:var(--text)}
.info-bar .time{font-family:monospace;background:#f4f4f5;padding:1px 6px;border-radius:4px}
.info-bar .badge-err{background:#fef0f0;color:#f56c6c;padding:1px 6px;border-radius:4px}
/* ===== Loading ===== */
.loading{padding:24px 12px;text-align:center;font-size:13px;color:var(--text2)}
.loading-bar{width:100%;height:3px;background:#e8e8e8;border-radius:2px;overflow:hidden;margin-top:8px}
.loading-bar-inner{height:100%;background:linear-gradient(90deg,var(--primary),#67c23a);border-radius:2px;transition:width .3s ease;width:0%}
/* ===== Tabs ===== */
.tabs{display:flex;gap:4px;padding:8px 12px;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none}
.tabs::-webkit-scrollbar{display:none}
.tab{flex-shrink:0;padding:5px 12px;border-radius:16px;font-size:12px;color:#606266;background:#f0f2f5;cursor:pointer;white-space:nowrap;transition:all .2s;user-select:none}
.tab:active{transform:scale(.95)}
.tab.active{background:var(--primary-light);color:var(--primary);font-weight:600}
/* ===== Results ===== */
.results{display:flex;flex-direction:column;gap:10px;padding:8px 12px}
.empty{padding:40px 12px;text-align:center;color:var(--text2);font-size:14px}
/* ===== Card ===== */
.card{display:flex;gap:10px;background:var(--white);border-radius:var(--radius);padding:10px;border:1px solid var(--border);transition:border-color .2s}
.card:active{border-color:#c0c4cc}
.card-cover{flex-shrink:0;width:90px;height:120px;border-radius:8px;overflow:hidden;background:var(--bg);position:relative}
.card-cover img{width:100%;height:100%;object-fit:cover}
.card-cover .placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:28px;background:linear-gradient(135deg,#667eea,#764ba2);color:rgba(255,255,255,.6)}
.card-cover .tag{position:absolute;bottom:3px;left:3px;padding:1px 5px;border-radius:3px;color:#fff;font-size:10px;font-weight:600;backdrop-filter:blur(2px)}
.card-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}
.card-title{font-size:14px;font-weight:700;color:var(--text);line-height:1.3;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}
.card-meta{font-size:11px;color:var(--text2);display:flex;align-items:center;gap:8px}
.card-meta .size{color:#67c23a}
.card-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:2px}
.card-tags span{font-size:10px;padding:1px 6px;border-radius:4px;background:#ecf5ff;color:#409eff;white-space:nowrap}
.card-tags .quality{background:#fef0f0;color:#e74c3c}
.card-actions{margin-top:auto;display:flex;align-items:center;justify-content:space-between;gap:6px}
.card-source{font-size:10px;color:var(--text2);background:#f4f4f5;padding:1px 6px;border-radius:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px}
.card-btn{padding:4px 10px;border:none;border-radius:6px;background:var(--primary);color:var(--white);font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap;transition:background .2s}
.card-btn:active{background:var(--primary-dark)}
.card-btn:disabled{opacity:.5}
/* ===== Toast ===== */
.toast{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,.75);color:#fff;padding:10px 20px;border-radius:8px;font-size:14px;z-index:200;pointer-events:none;opacity:0;transition:opacity .3s}
.toast.show{opacity:1}
.toast.error{background:rgba(245,108,108,.9)}
/* ===== Overlay / Modal ===== */
.overlay{position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;padding:16px;animation:fadeIn .2s}
.modal{background:var(--white);border-radius:14px;width:100%;max-width:420px;max-height:90vh;overflow-y:auto;padding:0;animation:slideUp .25s}
.modal-hdr{padding:14px 16px;border-bottom:1px solid var(--border);font-size:15px;font-weight:700;color:var(--text)}
.modal-body{padding:14px 16px}
.modal-ftr{padding:10px 16px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end}
.modal-ftr button{height:36px;padding:0 16px;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer}
.modal-ftr .btn-close{background:#f4f4f5;color:#606266}
.modal-ftr .btn-disclaimer{background:#fdf6ec;color:#d46b08;margin-right:auto}
.modal-ftr .btn-primary{background:var(--primary);color:var(--white)}
.modal-ftr .btn-primary:active{background:var(--primary-dark)}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
@keyframes slideUp{from{transform:translateY(40px);opacity:0}to{transform:translateY(0);opacity:1}}
/* ===== Share Modal ===== */
.share-section{display:flex;flex-direction:column;gap:10px}
.share-row{display:flex;gap:8px}
.share-row input{flex:1;height:36px;border:1px solid var(--border);border-radius:6px;padding:0 10px;font-size:13px;outline:none;background:var(--bg);color:var(--text)}
.share-row input:focus{border-color:var(--primary)}
.share-row .copy-btn{height:36px;padding:0 12px;border:none;border-radius:6px;background:var(--primary);color:var(--white);font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap}
.share-pwd{display:flex;align-items:center;gap:6px;font-size:13px}
.share-pwd .pwd-tag{padding:2px 8px;background:#fdf6ec;color:#e6a23c;border-radius:4px;font-weight:700}
.share-pwd .pwd-hint{font-size:11px;color:var(--text2)}
.share-tip{padding:8px 10px;background:#fdf6ec;border-radius:6px;font-size:12px;line-height:1.5;color:#d46b08;display:flex;gap:6px;align-items:flex-start}
.share-tip .warn-icon{font-size:18px;line-height:1.5;flex-shrink:0}
.share-tip .tip-text{flex:1;min-width:0}
.share-tip strong{font-weight:700}
.warning-box{background:#fff2f0;border:1px solid #ffccc7;border-radius:8px;padding:8px 10px;overflow-x:auto;-webkit-overflow-scrolling:touch}
.warning-item{margin:0;font-size:12px;line-height:1.8;font-weight:700;white-space:nowrap}
.warning-item:nth-child(odd){color:#cf1322}
.warning-item:nth-child(even){color:#d46b08}
.warning-item:last-child{color:#b71c1c;font-size:13px}
.share-qr{display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px}
.share-qr canvas{border-radius:8px}
.share-qr .qr-label{font-size:12px;font-weight:600;color:var(--primary)}
.share-qr .qr-sub{font-size:11px;color:var(--text2)}
.share-disclaimer{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:10px;padding:8px 10px;background:#fdf6ec;border-radius:6px;font-size:12px;color:#d46b08;flex-wrap:wrap}
/* ===== Login Modal ===== */
.login-form{display:flex;flex-direction:column;gap:12px}
.login-form input{height:40px;border:1px solid var(--border);border-radius:8px;padding:0 12px;font-size:14px;outline:none}
.login-form input:focus{border-color:var(--primary)}
.login-form .login-btn{height:40px;border:none;border-radius:8px;background:var(--primary);color:var(--white);font-size:14px;font-weight:600;cursor:pointer;transition:background .2s}
.login-form .login-btn:active{background:var(--primary-dark)}
.login-form .login-btn:disabled{opacity:.5}
.login-form .login-err{font-size:12px;color:#f56c6c;text-align:center}
/* ===== Progress Steps ===== */
.steps{display:flex;flex-direction:column;gap:10px;padding:8px 0}
.step{display:flex;align-items:flex-start;gap:10px;opacity:.4;transition:opacity .3s}
.step.active{opacity:1}
.step.done{opacity:.7}
.step-dot{flex-shrink:0;width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;background:#e4e7ed;color:#909399}
.step.active .step-dot{background:var(--primary);color:#fff;box-shadow:0 0 0 3px rgba(64,158,255,.2)}
.step.done .step-dot{background:#67c23a;color:#fff}
.step-body{flex:1;padding-top:3px;display:flex;align-items:center;gap:8px}
.step-title{font-size:13px;color:var(--text);font-weight:500}
.step-status{font-size:11px;padding:1px 7px;border-radius:10px;white-space:nowrap}
.step-status.doing{background:#ecf5ff;color:var(--primary)}
.step-status.done{background:#f0f9eb;color:#67c23a}
.step-status.wait{background:#f4f4f5;color:#c0c4cc}
.error-alert{padding:12px 16px;background:#fef0f0;border:1px solid #fde2e2;border-radius:8px;display:flex;align-items:center;gap:8px;font-size:14px;color:#f56c6c}
/* ===== User Badge ===== */
.user-badge{font-size:12px;color:var(--primary);font-weight:600;white-space:nowrap}
.login-btn-small{height:30px;padding:0 10px;border:none;border-radius:6px;background:var(--primary-light);color:var(--primary);font-size:12px;font-weight:600;cursor:pointer}
.logout-btn-small{height:30px;padding:0 8px;border:none;border-radius:6px;background:transparent;color:var(--text2);font-size:12px;cursor:pointer}
</style>
</head>
<body>
<div class="app" id="app">
<!-- ===== Home Page (shown when no search) ===== -->
<div id="homePage" class="home-page">
<div class="home-hero">
<div class="home-logo" id="homeLogo" style="display:none"></div>
<div class="home-search-box">
<input id="homeSearchInput" type="text" placeholder="搜索网盘资源..." />
<button id="homeSearchBtn" onclick="homeSearch()">搜 索</button>
</div>
<div class="home-quote" id="homeQuote"></div>
<div class="home-quote-author" id="homeQuoteAuthor"></div>
</div>
<div class="home-rankings" id="homeRankings"></div>
</div>
<!-- ===== Search Results View ===== -->
<div id="searchView" style="display:none">
<!-- Header -->
<div class="header">
<div class="header-row">
<a href="/h5" class="header-title-link"><div class="header-title" id="headerTitle" style="display:none">CloudSearch</div></a>
<div class="search-wrap">
<input id="searchInput" type="text" placeholder="搜索网盘资源..." @keydown="handleKeydown" />
<button id="searchBtn" onclick="doSearch()">搜 索</button>
</div>
<div class="header-actions" id="userArea">
<template id="userLoggedIn">
<span class="user-badge" id="usernameDisplay"></span>
<button class="logout-btn-small" onclick="logout()">退出</button>
</template>
<template id="userLoggedOut">
<button class="login-btn-small" onclick="showLogin()">登录</button>
</template>
</div>
</div>
</div>
<!-- Info Bar -->
<div id="infoBar" class="info-bar" style="display:none">
<span id="infoCount" class="count"></span>
<span id="infoTime" class="time"></span>
<span id="infoFiltered" class="badge-err"></span>
</div>
<!-- Loading -->
<div id="loading" class="loading" style="display:none">
<div id="loadingText">🔍 正在搜索中...</div>
<div class="loading-bar"><div class="loading-bar-inner" id="loadingBar"></div></div>
</div>
<!-- Tabs -->
<div id="tabs" class="tabs" style="display:none"></div>
<!-- Results -->
<div id="results" class="results"></div>
<!-- Overlay -->
<div class="overlay" id="overlay" style="display:none" onclick="closeModal()"></div>
<!-- Share Modal -->
<div class="modal" id="shareModal" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:101">
<div class="modal-hdr" id="shareTitle">分享链接</div>
<div class="modal-body">
<div id="progressSteps" class="steps" style="display:none">
<div class="step" id="step1"><div class="step-dot"><span>1</span></div><div class="step-body"><span class="step-title">正在转存...</span><span class="step-status doing">进行中</span></div></div>
<div class="step" id="step2"><div class="step-dot"><span>2</span></div><div class="step-body"><span class="step-title">重命名文件(防和谐)...</span><span class="step-status wait">等待中</span></div></div>
<div class="step" id="step3"><div class="step-dot"><span>3</span></div><div class="step-body"><span class="step-title">生成分享链接...</span><span class="step-status wait">等待中</span></div></div>
</div>
<div id="saveError" class="error-alert" style="display:none"></div>
<div id="shareContent" style="display:none">
<div class="share-qr">
<div id="qrContainer"></div>
<div class="qr-label" id="qrLabel"></div>
<div class="qr-sub">保存到你自己的网盘</div>
</div>
<div class="share-section">
<div class="share-row">
<input id="shareLinkInput" type="text" readonly />
</div>
<div id="sharePwdRow" class="share-pwd" style="display:none">
<span>🔑 提取密码:</span>
<span class="pwd-tag" id="sharePwdTag"></span>
<span class="pwd-hint">打开链接后需输入密码</span>
</div>
<div class="share-tip">
<span class="warn-icon">⚠️</span>
<div class="tip-text">
<strong>请尽快复制链接到浏览器打开</strong> 或 <strong>用夸克APP扫码</strong><br>
<strong>转存至您的网盘,以免资源被官方和谐</strong>
</div>
</div>
<div class="warning-box">
<p class="warning-item">郑重警告一:网盘内除您所需资源外,不要打开任何不相关内容。</p>
<p class="warning-item">郑重警告二:网盘内除您所需资源外,不要打开任何不相关内容。</p>
<p class="warning-item">郑重警告三:网盘内除您所需资源外,不要打开任何不相关内容。</p>
<p class="warning-item">郑重警告四:以上警告说三遍,你还要明知故犯吗?</p>
</div>
<div class="share-disclaimer">
<span>⚠️ 本站资源仅供学习交流,请于24h内删除</span>
</div>
</div>
</div>
</div>
<div class="modal-ftr">
<button class="btn-disclaimer" onclick="openDisclaimer()">📜 免责声明</button>
<button class="btn-close" onclick="closeModal()">关闭</button>
<button class="btn-primary" id="copyBtn2" onclick="copyShareLink()" style="display:none">一键复制链接</button>
</div>
</div>
<!-- Login Modal -->
<div class="modal" id="loginModal" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:101">
<div class="modal-hdr">登录</div>
<div class="modal-body">
<div class="login-form">
<input id="loginUser" type="text" placeholder="用户名" />
<input id="loginPass" type="password" placeholder="密码" />
<button class="login-btn" id="loginBtn" onclick="handleLogin()">登录</button>
<div class="login-err" id="loginErr"></div>
</div>
</div>
<div class="modal-ftr">
<button class="btn-close" onclick="closeLogin()">取消</button>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast"></div>
<script>
// ===== Anime keywords for categorization =====
const ANIME_KWS=['仙逆','凡人修仙传','斗破苍穹','斗破','盘龙','完美世界','一念永恒','妖神记','星辰变','遮天','神墓','吞噬星空','武动乾坤','大主宰','全职高手','鬼灭之刃','海贼王','火影忍者','死神','龙珠','进击的巨人','咒术回战','一人之下','狐妖小红娘','魔道祖师','天官赐福','时光代理人','大王饶命','斗罗大陆','绝世唐门','不良人','秦时明月','全职法师','牧神记','三体','灵笼','雾山五行','凡人','仙王的日常生活','百妖谱','眷思量','镖人','伍六七','刺客伍六七','葬送的芙莉莲','间谍过家家']
// ===== Quotes =====
const QUOTES=['学而时习之,不亦说乎。','温故而知新,可以为师矣。','三人行,必有我师焉。','学而不思则罔,思而不学则殆。','博学之,审问之,慎思之,明辨之,笃行之。','千里之行,始于足下。','不积跬步,无以至千里。','知之为知之,不知为不知,是知也。','工欲善其事,必先利其器。','玉不琢,不成器;人不学,不知道。','学以致用,知行合一。','学海无涯,勤作舟。','书山有路,勤为径。','宝剑锋从磨砺出,梅花香自苦寒来。','锲而不舍,金石可镂。','业精于勤,荒于嬉。','读书破万卷,下笔如有神。','路漫漫其修远兮,吾将上下而求索。','采菊东篱下,悠然见南山。','海内存知己,天涯若比邻。','长风破浪会有时,直挂云帆济沧海。','会当凌绝顶,一览众山小。','山重水复疑无路,柳暗花明又一村。']
// ===== Home Page =====
function homeSearch(){
const q=document.getElementById('homeSearchInput').value.trim()
if(q)doSearchFromHome(q)
}
function doSearchFromHome(q){
document.getElementById('homePage').style.display='none'
document.getElementById('searchView').style.display='block'
document.getElementById('searchInput').value=q
window.history.replaceState({},'','/h5?q='+encodeURIComponent(q))
doSearch()
}
function renderHomePage(data){
fetch("/api/site-config").then(r=>r.json()).then(cfg=>{
// 显示 Logo(优先图片,其次文字)
var logoEl=document.getElementById("homeLogo");
var headerEl=document.getElementById("headerTitle");
if(cfg.site_logo){
logoEl.innerHTML='<img src="'+cfg.site_logo+'" class="home-logo-img" alt="logo" />';
logoEl.style.display="";
headerEl.innerHTML='<img src="'+cfg.site_logo+'" class="header-logo-img" alt="logo" />';
headerEl.style.display="";
}else if(cfg.site_name){
logoEl.textContent=cfg.site_name;
logoEl.style.display="";
headerEl.textContent=cfg.site_name;
headerEl.style.display="";
}else{
logoEl.textContent="CloudSearch";
logoEl.style.display="";
headerEl.textContent="CloudSearch";
headerEl.style.display="";
}
if(cfg.site_disclaimer){
document.getElementById("footerContent").innerHTML=cfg.site_disclaimer.replace(/\n/g,'<br>');
document.getElementById("siteFooter").style.display="block";
}
}).catch(()=>{})
const categories=data.categories||[]
const fetchedAt=data.fetchedAt||''
// Quote
fetch('https://v1.hitokoto.cn/').then(r=>r.json()).then(d=>{
document.getElementById('homeQuote').textContent='「 '+d.hitokoto+' 」'
document.getElementById('homeQuoteAuthor').textContent='---'+(d.from_who||d.from||'')
}).catch(()=>{
document.getElementById('homeQuote').textContent='「 学而时习之,不亦说乎。 」'
document.getElementById('homeQuoteAuthor').textContent='---孔子'
})
// Store expanded state per category
window.__expanded=window.__expanded||{}
window.__activeTab=window.__activeTab||{}
const el=document.getElementById('homeRankings')
let html=''
for(const cat of categories){
const icons={movie:'🎬',tv:'📺',western_movie:'🎥',western:'🌍',donghua:'🐉',global_anime:'🌐',variety:'🎤',niche:'💎',hotsite:'🏆'}
const icon=icons[cat.category]||'📋'
const key=cat.category
if(!window.__activeTab[key])window.__activeTab[key]='hot'
html+='<div class="rank-block">'
html+='<div class="rank-block-hdr">'+
'<span class="rank-block-title">'+icon+' '+cat.label+'</span>'+
'<div class="rank-block-tabs" id="rtabs-'+key+'">'+
'<span class="rank-tab'+(window.__activeTab[key]==='hot'?' active':'')+'" onclick="switchRankTab(\''+key+'\',\'hot\')">热榜</span>'+
'<span class="rank-tab'+(window.__activeTab[key]==='newest'?' active':'')+'" onclick="switchRankTab(\''+key+'\',\'newest\')">最新</span>'+
'</div>'+
'</div>'
html+='<div class="rank-block-items" id="ritems-'+key+'" data-hot=\''+JSON.stringify({items:cat.hot||[]}).replace(/'/g,"&#39;")+'\' data-newest=\''+JSON.stringify({items:cat.newest||[]}).replace(/'/g,"&#39;")+'\'>'
const items=window.__activeTab[key]==='hot'?(cat.hot||[]):(cat.newest||[])
html+=renderRankItems(items,key,false)
html+='</div>'
// 数据来源
html+='<div class="rank-block-ftr">'+
'<span>'+(cat.category!=='hotsite'?'数据来源:TMDB':'本站搜索数据')+'</span>'+
'<span class="ftr-time">'+fetchedAt+'</span>'+
'</div></div>'
}
el.innerHTML=html
}
function renderRankItems(items,key,expanded){
if(!items||items.length===0)return'<div style="padding:10px;text-align:center;color:#c0c4cc;font-size:12px">暂无数据</div>'
const limit=3
const show=expanded?items.length:Math.min(limit,items.length)
let html=items.slice(0,show).map((item,i)=>{
const c=i<3?' rank-idx top3':' rank-idx'
return '<div class="rank-item" onclick="doSearchFromHome(\''+item.keyword.replace(/'/g,"\\'")+'\')">'+
'<span class="'+c+'">'+(i+1)+'</span>'+
'<span class="rank-name">'+item.keyword+'</span>'+
'<span class="rank-cnt">'+(item.rating?'⭐'+item.rating:item.searchCount)+'</span>'+
'</div>'
}).join('')
if(items.length>limit&&!expanded){
html+='<div class="rank-expand" onclick="expandRank(\''+key+'\')">展开全部 ▼</div>'
}
return html
}
function expandRank(key){
const container=document.getElementById('ritems-'+key)
if(!container)return
const tab=window.__activeTab[key]||'hot'
const data=JSON.parse(tab==='hot'?container.dataset.hot:container.dataset.newest)
container.innerHTML=renderRankItems(data.items,key,true)
}
function switchRankTab(category,tab){
window.__activeTab[category]=tab
const tabsContainer=document.getElementById('rtabs-'+category)
if(tabsContainer){
tabsContainer.querySelectorAll('.rank-tab').forEach(t=>t.className='rank-tab')
tabsContainer.querySelector(tab==='hot'?'.rank-tab:first-child':'.rank-tab:last-child').className='rank-tab active'
}
const container=document.getElementById('ritems-'+category)
if(container){
const data=JSON.parse(tab==='hot'?container.dataset.hot:container.dataset.newest)
container.innerHTML=renderRankItems(data.items,category,false)
}
}
let userInfo = null
let allResults = []
let allChannels = []
let activeTab = ''
let currentSaveItem = null
const CLOUD_ICONS = {quark:'☁️',baidu:'🔵',aliyun:'🟠','115':'🟣',tianyi:'🔷','123pan':'🔴',uc:'🟡',xunlei:'🟢',pikpak:'🟤',magnet:'🧲',ed2k:'🔗',others:'📁'}
const CLOUD_LABELS = {quark:'夸克网盘',baidu:'百度网盘',aliyun:'阿里云盘','115':'115网盘',tianyi:'天翼云盘','123pan':'123云盘',uc:'UC网盘',xunlei:'迅雷云盘',pikpak:'PikPak',magnet:'磁力链接',ed2k:'电驴链接',others:'其他'}
const CLOUD_COLORS = {quark:'#07c160',baidu:'#4e6ef2',aliyun:'#ff6a00','115':'#9b59b6',tianyi:'#00a1d6','123pan':'#e74c3c',uc:'#f39c12',xunlei:'#2ecc71',pikpak:'#8e44ad',magnet:'#95a5a6',ed2k:'#7f8c8d',others:'#95a5a6'}
const CLOUD_ORDER = {quark:1,baidu:2,aliyun:3,'115':4,tianyi:5,'123pan':6,uc:7,xunlei:8,pikpak:9,magnet:10,ed2k:11,others:12}
// ===== Fetch helpers =====
function getToken(){return localStorage.getItem('h5_admin_token')}
function apiHeaders(){const h={'Content-Type':'application/json'};const t=getToken();if(t)h['Authorization']='Bearer '+t;return h}
// ===== Toast =====
let toastTimer
function showToast(msg,isError){
const el=document.getElementById('toast')
el.textContent=msg
el.className='toast show'+(isError?' error':'')
clearTimeout(toastTimer)
toastTimer=setTimeout(()=>el.className='toast',2000)
}
// ===== User =====
async function checkLogin(){
try{
const res=await fetch('/api/me',{headers:apiHeaders()})
if(res.ok){
const data=await res.json()
if(data.loggedIn){
userInfo=data
document.getElementById('userArea').innerHTML='<span class="user-badge">'+data.username+'</span><button class="logout-btn-small" onclick="logout()">退出</button>'
}
}
}catch(e){}
}
function logout(){
localStorage.removeItem('h5_admin_token')
userInfo=null
document.getElementById('userArea').innerHTML='<button class="login-btn-small" onclick="showLogin()">登录</button>'
showToast('已退出')
}
function showLogin(){
document.getElementById('loginErr').textContent=''
document.getElementById('loginUser').value=''
document.getElementById('loginPass').value=''
document.getElementById('loginModal').style.display='block'
document.getElementById('overlay').style.display='block'
}
function closeLogin(){
document.getElementById('loginModal').style.display='none'
document.getElementById('overlay').style.display='none'
}
async function handleLogin(){
const user=document.getElementById('loginUser').value.trim()
const pass=document.getElementById('loginPass').value
if(!user||!pass){showToast('请输入用户名和密码',true);return}
const btn=document.getElementById('loginBtn')
btn.disabled=true;btn.textContent='登录中...'
try{
const res=await fetch('/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:user,password:pass})})
if(res.ok){
const data=await res.json()
localStorage.setItem('h5_admin_token',data.token)
userInfo={username:user}
document.getElementById('userArea').innerHTML='<span class="user-badge">'+user+'</span><button class="logout-btn-small" onclick="logout()">退出</button>'
closeLogin()
showToast('登录成功')
}else{
const err=await res.json().catch(()=>({}))
document.getElementById('loginErr').textContent=err.error||'登录失败'
}
}catch(e){
document.getElementById('loginErr').textContent='网络错误'
}finally{
btn.disabled=false;btn.textContent='登录'
}
}
// ===== Search =====
function handleKeydown(e){if(e.key==='Enter')doSearch()}
let searchTimer
function doSearch(){
const q=document.getElementById('searchInput').value.trim()
if(!q)return
// Update URL
window.history.replaceState({},'','/h5?q='+encodeURIComponent(q))
// Show loading
document.getElementById('results').innerHTML=''
document.getElementById('tabs').style.display='none'
document.getElementById('infoBar').style.display='none'
document.getElementById('loading').style.display='block'
document.getElementById('loadingText').textContent='🔍 正在搜索中...'
document.getElementById('searchBtn').disabled=true
let progress=0
const bar=document.getElementById('loadingBar')
const progressTimer=setInterval(()=>{
if(progress<60)progress+=1+Math.random()*3
else if(progress<85)progress+=0.5+Math.random()
bar.style.width=progress+'%'
},200)
// Use streaming search for live updates
streamSearch(q,progressTimer,bar)
}
async function streamSearch(q,progressTimer,bar){
const startTime=Date.now()
try{
const response=await fetch('/api/query',{method:'POST',headers:apiHeaders(),body:JSON.stringify({q})})
if(!response.ok)throw new Error('搜索失败 ('+response.status+')')
const reader=response.body.getReader()
const decoder=new TextDecoder()
let buffer=''
let allItems=[]
let channels=[]
let totalCount=0
let filteredCount=0
while(true){
const {done,value}=await reader.read()
if(done)break
buffer+=decoder.decode(value,{stream:true})
const lines=buffer.split('\n')
buffer=lines.pop()||''
for(const line of lines){
if(!line.trim())continue
try{
const msg=JSON.parse(line)
if(msg.type==='stats'){
totalCount=msg.total||0
filteredCount=msg.filtered||0
document.getElementById('loadingText').textContent='🔍 搜索到 '+totalCount+' 条,正在验证...'
}else if(msg.type==='result'){
if(msg.valid&&msg.id){
allItems.push(msg.id)
}
}else if(msg.type==='complete'){
const results=msg.results||[]
channels=msg.channels||[]
clearInterval(progressTimer)
bar.style.width='100%'
setTimeout(()=>renderResults(results,channels,totalCount,filteredCount,Date.now()-startTime),300)
return
}
}catch(e){}
}
}
}catch(e){
clearInterval(progressTimer)
document.getElementById('loading').style.display='none'
document.getElementById('searchBtn').disabled=false
document.getElementById('results').innerHTML='<div class="empty">搜索失败:'+e.message+'</div>'
}
}
// ===== Render =====
function renderResults(results,channels,totalCount,filteredCount,time){
document.getElementById('loading').style.display='none'
document.getElementById('searchBtn').disabled=false
allResults=results
allChannels=channels||[]
// Info bar
if(totalCount>0){
document.getElementById('infoBar').style.display='flex'
document.getElementById('infoCount').textContent='已为您挑选到最符合 '+totalCount+' 条结果'
document.getElementById('infoTime').textContent='⏱ '+time+'ms'
if(filteredCount>0)document.getElementById('infoFiltered').textContent='❌ 失效 '+filteredCount
else document.getElementById('infoFiltered').textContent=''
}else{
document.getElementById('infoBar').style.display='none'
}
// Build tabs
const tabsEl=document.getElementById('tabs')
tabsEl.innerHTML=''
const typeCounts={}
for(const r of results){const ct=r.cloud_type||'others';typeCounts[ct]=(typeCounts[ct]||0)+1}
const sorted=Object.keys(typeCounts).sort((a,b)=>(CLOUD_ORDER[a]||99)-(CLOUD_ORDER[b]||99))
// "全部" tab
const allTab=document.createElement('div')
allTab.className='tab active'
allTab.textContent='📋 全部 ('+results.length+')'
allTab.onclick=()=>{setActiveTab('');renderCardList(results)}
tabsEl.appendChild(allTab)
for(const ct of sorted){
const tab=document.createElement('div')
tab.className='tab'
tab.textContent=(CLOUD_ICONS[ct]||'📁')+' '+(CLOUD_LABELS[ct]||ct)+' ('+typeCounts[ct]+')'
tab.onclick=()=>{setActiveTab(ct);renderCardList(results.filter(r=>(r.cloud_type||'others')===ct))}
tabsEl.appendChild(tab)
}
tabsEl.style.display=results.length>0?'flex':'none'
activeTab=''
// Render cards
renderCardList(results)
}
function setActiveTab(ct){
activeTab=ct
document.querySelectorAll('.tab').forEach((t,i)=>{
const isAll=i===0&&!ct
const active=i>0&&ct&&t.textContent.includes(CLOUD_LABELS[ct])
t.className='tab'+(active||isAll?' active':'')
})
}
function renderCardList(items){
const el=document.getElementById('results')
if(items.length===0){
el.innerHTML='<div class="empty">暂无结果</div>'
return
}
el.innerHTML=items.map((item,idx)=>{
const coverHtml=item.cover
? '<img src="'+escapeHtml(item.cover)+'" alt="" onerror="this.parentElement.innerHTML=\'<div class=placeholder>'+escapeHtml(CLOUD_ICONS[item.cloud_type||'others'])+'</div>\'" loading="lazy" />'
: '<div class="placeholder">'+escapeHtml(CLOUD_ICONS[item.cloud_type||'others'])+'</div>'
const cloudLabel=CLOUD_LABELS[item.cloud_type]||item.cloud_type||''
const cloudColor=CLOUD_COLORS[item.cloud_type]||'#95a5a6'
const tags=extractTags(item.title||'')
const cleanTitle=(item.title||'').replace(/【[^】]+】/g,'').trim()
const relativeTime=formatTime(item.update_time||item.datetime||'')
return '<div class="card" onclick="saveItem('+idx+')">'+
'<div class="card-cover">'+coverHtml+'<span class="tag" style="background:'+cloudColor+'">'+cloudLabel+'</span></div>'+
'<div class="card-body">'+
'<div class="card-title">'+escapeHtml(cleanTitle)+'</div>'+
'<div class="card-meta"><span>🕐 '+relativeTime+'</span>'+(item.file_size?'<span class="size">📦 '+escapeHtml(item.file_size)+'</span>':'')+'</div>'+
(tags.length>0?'<div class="card-tags">'+tags.map(t=>'<span'+(isQualityTag(t)?' class="quality"':'')+'>'+escapeHtml(t)+'</span>').join('')+'</div>':'')+
'<div class="card-actions">'+
'<span class="card-source">'+(item.source?escapeHtml(item.source):'网盘')+'</span>'+
'<button class="card-btn" onclick="event.stopPropagation();saveItem('+idx+')">🔗 获取分享链接</button>'+
'</div>'+
'</div>'+
'</div>'
}).join('')
// Store items for save reference
window.__h5Results=items
}
function escapeHtml(s){if(!s)return '';return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
function extractTags(title){
const tags=[]
// Quality tags
const quality=['4K','1080P','2160P','720P','480P','HDR','HDR10','BluRay','REMUX','HEVC','x264','x265','WEB-DL','WEBRip']
for(const q of quality){if(title.includes(q)&&!tags.includes(q))tags.push(q)}
const kw=['杜比视界','杜比全景声','高码率','内封简繁英字幕','内嵌字幕','中文字幕','中英字幕']
for(const k of kw){if(title.includes(k)&&!tags.includes(k))tags.push(k)}
return tags.slice(0,6)
}
function isQualityTag(t){const q=['4K','1080P','2160P','720P','480P','HDR','HDR10','BluRay','REMUX','HEVC','x264','x265','臻彩','高清','WEB-DL','WEBRip'];return q.includes(t)}
function formatTime(s){
if(!s)return ''
const d=new Date(s)
if(isNaN(d.getTime()))return s.slice(0,10)
const diff=Date.now()-d.getTime()
if(diff<0)return s.slice(0,10)
const mins=Math.floor(diff/60000)
if(mins<60)return mins<=1?'刚刚':mins+' 分钟前'
const hours=Math.floor(mins/60)
if(hours<24)return hours+' 小时前'
const days=Math.floor(hours/24)
if(days<30)return days+' 天前'
return Math.floor(days/30)+' 个月前'
}
// ===== Save / Share =====
function saveItem(idx){
const items=window.__h5Results||[]
currentSaveItem=items[idx]
if(!currentSaveItem)return
document.getElementById('progressSteps').style.display='block'
document.getElementById('shareContent').style.display='none'
document.getElementById('saveError').style.display='none'
document.getElementById('copyBtn2').style.display='none'
const title=(currentSaveItem.title||'').replace(/【[^】]+】/g,'').trim()||'资源'
document.getElementById('shareTitle').textContent=title
// Show modal
document.getElementById('overlay').style.display='block'
document.getElementById('shareModal').style.display='block'
// Reset steps
resetSteps()
advanceStep(1)
// Call save API
doSave()
}
async function doSave(){
try{
const res=await fetch('/api/save',{method:'POST',headers:apiHeaders(),body:JSON.stringify({type:'search',source:currentSaveItem,target_cloud:currentSaveItem.cloud_type||'quark'})})
const data=await res.json()
if(!data.success){
document.getElementById('progressSteps').style.display='none'
document.getElementById('saveError').style.display='flex'
document.getElementById('saveError').textContent=data.message||data.error||'保存失败'
return
}
// Step 2
advanceStep(2)
await sleep(500)
// Step 3
advanceStep(3)
await sleep(300)
if(data.share_url){
advanceStep(4)
await sleep(200)
showShareResult(data)
}else{
advanceStep(4)
document.getElementById('progressSteps').style.display='none'
document.getElementById('saveError').style.display='flex'
document.getElementById('saveError').textContent='生成分享链接失败'
}
}catch(e){
document.getElementById('progressSteps').style.display='none'
document.getElementById('saveError').style.display='flex'
document.getElementById('saveError').textContent=e.message||'保存请求失败'
}
}
function showShareResult(data){
document.getElementById('progressSteps').style.display='none'
document.getElementById('shareContent').style.display='block'
const link=data.share_url
document.getElementById('shareLinkInput').value=link
const diskLabel=CLOUD_LABELS[currentSaveItem.cloud_type]||'夸克网盘'
document.getElementById('qrLabel').textContent=diskLabel+' APP扫码转存'
// Generate QR
const qrContainer=document.getElementById('qrContainer')
qrContainer.innerHTML=''
new QRCode(qrContainer,{text:link,width:140,height:140})
// Password
const pwd=data.share_pwd||data.sharePwd||''
if(pwd){
document.getElementById('sharePwdRow').style.display='flex'
document.getElementById('sharePwdTag').textContent=pwd
}else{
document.getElementById('sharePwdRow').style.display='none'
}
document.getElementById('copyBtn2').style.display='inline-block'
}
function resetSteps(){
for(let i=1;i<=3;i++){
const el=document.getElementById('step'+i)
el.className='step'
el.querySelector('.step-dot').innerHTML='<span>'+i+'</span>'
el.querySelector('.step-status').textContent='等待中'
el.querySelector('.step-status').className='step-status wait'
}
}
function advanceStep(n){
for(let i=1;i<=3;i++){
const el=document.getElementById('step'+i)
if(i<n){
el.className='step done'
el.querySelector('.step-dot').innerHTML='<span class="step-check">✓</span>'
el.querySelector('.step-status').textContent='已完成'
el.querySelector('.step-status').className='step-status done'
}else if(i===n){
el.className='step active'
el.querySelector('.step-dot').innerHTML='<span>'+i+'</span>'
const titles=['正在转存到','正在重命名文件(防和谐)','正在生成分享链接']
el.querySelector('.step-title').textContent=titles[i-1]+'...'
el.querySelector('.step-status').textContent='进行中'
el.querySelector('.step-status').className='step-status doing'
}
}
}
function sleep(ms){return new Promise(r=>setTimeout(r,ms))}
function copyShareLink(){
const input=document.getElementById('shareLinkInput')
if(!input.value)return
if(navigator.clipboard&&navigator.clipboard.writeText){
navigator.clipboard.writeText(input.value).then(()=>showToast('链接已复制')).catch(()=>fallbackCopy(input.value))
}else{
fallbackCopy(input.value)
}
}
function fallbackCopy(text){
const ta=document.createElement('textarea')
ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';document.body.appendChild(ta)
ta.select()
try{document.execCommand('copy');showToast('链接已复制')}catch{showToast('复制失败',true)}
document.body.removeChild(ta)
}
function openDisclaimer(){
window.open('/disclaimer/','_blank')
}
function closeModal(){
document.getElementById('overlay').style.display='none'
document.getElementById('shareModal').style.display='none'
document.getElementById('loginModal').style.display='none'
}
// ===== Init =====
checkLogin()
// Add Enter key handler for home search
document.getElementById('homeSearchInput').addEventListener('keydown',function(e){if(e.key==='Enter')homeSearch()})
// Also add for search view input
document.getElementById('searchInput').addEventListener('keydown',function(e){if(e.key==='Enter')doSearch()})
// Fetch home page data
fetch('/api/rankings/categorized').then(r=>r.json()).then(data=>{
renderHomePage(data)
}).catch(()=>{
document.getElementById('homeQuote').textContent='「 学而时习之,不亦说乎。 」'
document.getElementById('homeQuoteAuthor').textContent='---孔子'
})
// Check URL for query
const params=new URLSearchParams(window.location.search)
const q=params.get('q')
if(q){
document.getElementById('homePage').style.display='none'
document.getElementById('searchView').style.display='block'
document.getElementById('searchInput').value=q
doSearch()
}
</script>
</body>
<!-- Footer -->
<div id="siteFooter" class="site-footer" style="display:none">
<div id="footerContent" class="footer-inner"></div>
<div class="footer-actions" id="footerActions">
<button class="footer-btn" onclick="openDisclaimer()">📜 免责声明</button>
</div>
</div>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CloudSearch - 网盘资源搜索</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script>
(function() {
// 替换标题为网站名称
fetch('/api/site-config').then(r=>r.json()).then(cfg=>{
if(cfg.site_name) document.title = cfg.site_name + ' - 网盘资源搜索';
}).catch(function(){});
// 跳过:参数 ?desktop=1 强制使用桌面版
if (window.location.search.includes('desktop=1')) return;
var ua = navigator.userAgent;
var isMobile = /Android|webOS|iPhone|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(ua);
var isTablet = /iPad|Android(?!.*Mobile)/i.test(ua);
if (isMobile || isTablet) {
window.location.replace(window.location.origin + '/h5');
}
})();
</script>
<script type="module" crossorigin src="/assets/index-D279dJND.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ekbe64zQ.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+601
View File
@@ -0,0 +1,601 @@
// ===== Anime keywords for categorization =====
const ANIME_KWS=['仙逆','凡人修仙传','斗破苍穹','斗破','盘龙','完美世界','一念永恒','妖神记','星辰变','遮天','神墓','吞噬星空','武动乾坤','大主宰','全职高手','鬼灭之刃','海贼王','火影忍者','死神','龙珠','进击的巨人','咒术回战','一人之下','狐妖小红娘','魔道祖师','天官赐福','时光代理人','大王饶命','斗罗大陆','绝世唐门','不良人','秦时明月','全职法师','牧神记','三体','灵笼','雾山五行','凡人','仙王的日常生活','百妖谱','眷思量','镖人','伍六七','刺客伍六七','葬送的芙莉莲','间谍过家家']
// ===== Quotes =====
const QUOTES=['学而时习之,不亦说乎。','温故而知新,可以为师矣。','三人行,必有我师焉。','学而不思则罔,思而不学则殆。','博学之,审问之,慎思之,明辨之,笃行之。','千里之行,始于足下。','不积跬步,无以至千里。','知之为知之,不知为不知,是知也。','工欲善其事,必先利其器。','玉不琢,不成器;人不学,不知道。','学以致用,知行合一。','学海无涯,勤作舟。','书山有路,勤为径。','宝剑锋从磨砺出,梅花香自苦寒来。','锲而不舍,金石可镂。','业精于勤,荒于嬉。','读书破万卷,下笔如有神。','路漫漫其修远兮,吾将上下而求索。','采菊东篱下,悠然见南山。','海内存知己,天涯若比邻。','长风破浪会有时,直挂云帆济沧海。','会当凌绝顶,一览众山小。','山重水复疑无路,柳暗花明又一村。']
// ===== Home Page =====
function homeSearch(){
const q=document.getElementById('homeSearchInput').value.trim()
if(q)doSearchFromHome(q)
}
function doSearchFromHome(q){
document.getElementById('homePage').style.display='none'
document.getElementById('searchView').style.display='block'
document.getElementById('searchInput').value=q
window.history.replaceState({},'','/h5?q='+encodeURIComponent(q))
doSearch()
}
function renderHomePage(data){
fetch("/api/site-config").then(r=>r.json()).then(cfg=>{
// 显示 Logo(优先图片,其次文字)
var logoEl=document.getElementById("homeLogo");
var headerEl=document.getElementById("headerTitle");
if(cfg.site_logo){
logoEl.innerHTML='<img src="'+cfg.site_logo+'" class="home-logo-img" alt="logo" />';
logoEl.style.display="";
headerEl.innerHTML='<img src="'+cfg.site_logo+'" class="header-logo-img" alt="logo" />';
headerEl.style.display="";
}else if(cfg.site_name){
logoEl.textContent=cfg.site_name;
logoEl.style.display="";
headerEl.textContent=cfg.site_name;
headerEl.style.display="";
}else{
logoEl.textContent="CloudSearch";
logoEl.style.display="";
headerEl.textContent="CloudSearch";
headerEl.style.display="";
}
if(cfg.site_disclaimer){
document.getElementById("footerContent").innerHTML=cfg.site_disclaimer.replace(/\n/g,'<br>');
document.getElementById("siteFooter").style.display="block";
}
}).catch(()=>{})
const categories=data.categories||[]
const fetchedAt=data.fetchedAt||''
// Quote
fetch('https://v1.hitokoto.cn/').then(r=>r.json()).then(d=>{
document.getElementById('homeQuote').textContent='「 '+d.hitokoto+' 」'
document.getElementById('homeQuoteAuthor').textContent='---'+(d.from_who||d.from||'')
}).catch(()=>{
document.getElementById('homeQuote').textContent='「 学而时习之,不亦说乎。 」'
document.getElementById('homeQuoteAuthor').textContent='---孔子'
})
// Store expanded state per category
window.__expanded=window.__expanded||{}
window.__activeTab=window.__activeTab||{}
const el=document.getElementById('homeRankings')
let html=''
for(const cat of categories){
const icons={movie:'🎬',tv:'📺',western_movie:'🎥',western:'🌍',donghua:'🐉',global_anime:'🌐',variety:'🎤',niche:'💎',hotsite:'🏆'}
const icon=icons[cat.category]||'📋'
const key=cat.category
if(!window.__activeTab[key])window.__activeTab[key]='hot'
html+='<div class="rank-block">'
html+='<div class="rank-block-hdr">'+
'<span class="rank-block-title">'+icon+' '+cat.label+'</span>'+
'<div class="rank-block-tabs" id="rtabs-'+key+'">'+
'<span class="rank-tab'+(window.__activeTab[key]==='hot'?' active':'')+'" onclick="switchRankTab(\''+key+'\',\'hot\')">热榜</span>'+
'<span class="rank-tab'+(window.__activeTab[key]==='newest'?' active':'')+'" onclick="switchRankTab(\''+key+'\',\'newest\')">最新</span>'+
'</div>'+
'</div>'
html+='<div class="rank-block-items" id="ritems-'+key+'" data-hot=\''+JSON.stringify({items:cat.hot||[]}).replace(/'/g,"&#39;")+'\' data-newest=\''+JSON.stringify({items:cat.newest||[]}).replace(/'/g,"&#39;")+'\'>'
const items=window.__activeTab[key]==='hot'?(cat.hot||[]):(cat.newest||[])
html+=renderRankItems(items,key,false)
html+='</div>'
// 数据来源
html+='<div class="rank-block-ftr">'+
'<span>'+(cat.category!=='hotsite'?'数据来源:TMDB':'本站搜索数据')+'</span>'+
'<span class="ftr-time">'+fetchedAt+'</span>'+
'</div></div>'
}
el.innerHTML=html
}
function renderRankItems(items,key,expanded){
if(!items||items.length===0)return'<div style="padding:10px;text-align:center;color:#c0c4cc;font-size:12px">暂无数据</div>'
const limit=3
const show=expanded?items.length:Math.min(limit,items.length)
let html=items.slice(0,show).map((item,i)=>{
const c=i<3?' rank-idx top3':' rank-idx'
return '<div class="rank-item" onclick="doSearchFromHome(\''+item.keyword.replace(/'/g,"\\'")+'\')">'+
'<span class="'+c+'">'+(i+1)+'</span>'+
'<span class="rank-name">'+item.keyword+'</span>'+
'<span class="rank-cnt">'+(item.rating?'⭐'+item.rating:item.searchCount)+'</span>'+
'</div>'
}).join('')
if(items.length>limit&&!expanded){
html+='<div class="rank-expand" onclick="expandRank(\''+key+'\')">展开全部 ▼</div>'
}
return html
}
function expandRank(key){
const container=document.getElementById('ritems-'+key)
if(!container)return
const tab=window.__activeTab[key]||'hot'
const data=JSON.parse(tab==='hot'?container.dataset.hot:container.dataset.newest)
container.innerHTML=renderRankItems(data.items,key,true)
}
function switchRankTab(category,tab){
window.__activeTab[category]=tab
const tabsContainer=document.getElementById('rtabs-'+category)
if(tabsContainer){
tabsContainer.querySelectorAll('.rank-tab').forEach(t=>t.className='rank-tab')
tabsContainer.querySelector(tab==='hot'?'.rank-tab:first-child':'.rank-tab:last-child').className='rank-tab active'
}
const container=document.getElementById('ritems-'+category)
if(container){
const data=JSON.parse(tab==='hot'?container.dataset.hot:container.dataset.newest)
container.innerHTML=renderRankItems(data.items,category,false)
}
}
let userInfo = null
let allResults = []
let allChannels = []
let activeTab = ''
let currentSaveItem = null
const CLOUD_ICONS = {quark:'☁️',baidu:'🔵',aliyun:'🟠','115':'🟣',tianyi:'🔷','123pan':'🔴',uc:'🟡',xunlei:'🟢',pikpak:'🟤',magnet:'🧲',ed2k:'🔗',others:'📁'}
const CLOUD_LABELS = {quark:'夸克网盘',baidu:'百度网盘',aliyun:'阿里云盘','115':'115网盘',tianyi:'天翼云盘','123pan':'123云盘',uc:'UC网盘',xunlei:'迅雷云盘',pikpak:'PikPak',magnet:'磁力链接',ed2k:'电驴链接',others:'其他'}
const CLOUD_COLORS = {quark:'#07c160',baidu:'#4e6ef2',aliyun:'#ff6a00','115':'#9b59b6',tianyi:'#00a1d6','123pan':'#e74c3c',uc:'#f39c12',xunlei:'#2ecc71',pikpak:'#8e44ad',magnet:'#95a5a6',ed2k:'#7f8c8d',others:'#95a5a6'}
const CLOUD_ORDER = {quark:1,baidu:2,aliyun:3,'115':4,tianyi:5,'123pan':6,uc:7,xunlei:8,pikpak:9,magnet:10,ed2k:11,others:12}
// ===== Fetch helpers =====
function getToken(){return localStorage.getItem('h5_admin_token')}
function apiHeaders(){const h={'Content-Type':'application/json'};const t=getToken();if(t)h['Authorization']='Bearer '+t;return h}
// ===== Toast =====
let toastTimer
function showToast(msg,isError){
const el=document.getElementById('toast')
el.textContent=msg
el.className='toast show'+(isError?' error':'')
clearTimeout(toastTimer)
toastTimer=setTimeout(()=>el.className='toast',2000)
}
// ===== User =====
async function checkLogin(){
try{
const res=await fetch('/api/me',{headers:apiHeaders()})
if(res.ok){
const data=await res.json()
if(data.loggedIn){
userInfo=data
document.getElementById('userArea').innerHTML='<span class="user-badge">'+data.username+'</span><button class="logout-btn-small" onclick="logout()">退出</button>'
}
}
}catch(e){}
}
function logout(){
localStorage.removeItem('h5_admin_token')
userInfo=null
document.getElementById('userArea').innerHTML='<button class="login-btn-small" onclick="showLogin()">登录</button>'
showToast('已退出')
}
function showLogin(){
document.getElementById('loginErr').textContent=''
document.getElementById('loginUser').value=''
document.getElementById('loginPass').value=''
document.getElementById('loginModal').style.display='block'
document.getElementById('overlay').style.display='block'
}
function closeLogin(){
document.getElementById('loginModal').style.display='none'
document.getElementById('overlay').style.display='none'
}
async function handleLogin(){
const user=document.getElementById('loginUser').value.trim()
const pass=document.getElementById('loginPass').value
if(!user||!pass){showToast('请输入用户名和密码',true);return}
const btn=document.getElementById('loginBtn')
btn.disabled=true;btn.textContent='登录中...'
try{
const res=await fetch('/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:user,password:pass})})
if(res.ok){
const data=await res.json()
localStorage.setItem('h5_admin_token',data.token)
userInfo={username:user}
document.getElementById('userArea').innerHTML='<span class="user-badge">'+user+'</span><button class="logout-btn-small" onclick="logout()">退出</button>'
closeLogin()
showToast('登录成功')
}else{
const err=await res.json().catch(()=>({}))
document.getElementById('loginErr').textContent=err.error||'登录失败'
}
}catch(e){
document.getElementById('loginErr').textContent='网络错误'
}finally{
btn.disabled=false;btn.textContent='登录'
}
}
// ===== Search =====
function handleKeydown(e){if(e.key==='Enter')doSearch()}
let searchTimer
function doSearch(){
const q=document.getElementById('searchInput').value.trim()
if(!q)return
// Update URL
window.history.replaceState({},'','/h5?q='+encodeURIComponent(q))
// Show loading
document.getElementById('results').innerHTML=''
document.getElementById('tabs').style.display='none'
document.getElementById('infoBar').style.display='none'
document.getElementById('loading').style.display='block'
document.getElementById('loadingText').textContent='🔍 正在搜索中...'
document.getElementById('searchBtn').disabled=true
let progress=0
const bar=document.getElementById('loadingBar')
const progressTimer=setInterval(()=>{
if(progress<60)progress+=1+Math.random()*3
else if(progress<85)progress+=0.5+Math.random()
bar.style.width=progress+'%'
},200)
// Use streaming search for live updates
streamSearch(q,progressTimer,bar)
}
async function streamSearch(q,progressTimer,bar){
const startTime=Date.now()
try{
const response=await fetch('/api/query',{method:'POST',headers:apiHeaders(),body:JSON.stringify({q})})
if(!response.ok)throw new Error('搜索失败 ('+response.status+')')
const reader=response.body.getReader()
const decoder=new TextDecoder()
let buffer=''
let allItems=[]
let channels=[]
let totalCount=0
let filteredCount=0
while(true){
const {done,value}=await reader.read()
if(done)break
buffer+=decoder.decode(value,{stream:true})
const lines=buffer.split('\n')
buffer=lines.pop()||''
for(const line of lines){
if(!line.trim())continue
try{
const msg=JSON.parse(line)
if(msg.type==='stats'){
totalCount=msg.total||0
filteredCount=msg.filtered||0
document.getElementById('loadingText').textContent='🔍 搜索到 '+totalCount+' 条,正在验证...'
}else if(msg.type==='result'){
if(msg.valid&&msg.id){
allItems.push(msg.id)
}
}else if(msg.type==='complete'){
const results=msg.results||[]
channels=msg.channels||[]
clearInterval(progressTimer)
bar.style.width='100%'
setTimeout(()=>renderResults(results,channels,totalCount,filteredCount,Date.now()-startTime),300)
return
}
}catch(e){}
}
}
}catch(e){
clearInterval(progressTimer)
document.getElementById('loading').style.display='none'
document.getElementById('searchBtn').disabled=false
document.getElementById('results').innerHTML='<div class="empty">搜索失败:'+e.message+'</div>'
}
}
// ===== Render =====
function renderResults(results,channels,totalCount,filteredCount,time){
document.getElementById('loading').style.display='none'
document.getElementById('searchBtn').disabled=false
allResults=results
allChannels=channels||[]
// Info bar
if(totalCount>0){
document.getElementById('infoBar').style.display='flex'
document.getElementById('infoCount').textContent='已为您挑选到最符合 '+totalCount+' 条结果'
document.getElementById('infoTime').textContent='⏱ '+time+'ms'
if(filteredCount>0)document.getElementById('infoFiltered').textContent='❌ 失效 '+filteredCount
else document.getElementById('infoFiltered').textContent=''
}else{
document.getElementById('infoBar').style.display='none'
}
// Build tabs
const tabsEl=document.getElementById('tabs')
tabsEl.innerHTML=''
const typeCounts={}
for(const r of results){const ct=r.cloud_type||'others';typeCounts[ct]=(typeCounts[ct]||0)+1}
const sorted=Object.keys(typeCounts).sort((a,b)=>(CLOUD_ORDER[a]||99)-(CLOUD_ORDER[b]||99))
// "全部" tab
const allTab=document.createElement('div')
allTab.className='tab active'
allTab.textContent='📋 全部 ('+results.length+')'
allTab.onclick=()=>{setActiveTab('');renderCardList(results)}
tabsEl.appendChild(allTab)
for(const ct of sorted){
const tab=document.createElement('div')
tab.className='tab'
tab.textContent=(CLOUD_ICONS[ct]||'📁')+' '+(CLOUD_LABELS[ct]||ct)+' ('+typeCounts[ct]+')'
tab.onclick=()=>{setActiveTab(ct);renderCardList(results.filter(r=>(r.cloud_type||'others')===ct))}
tabsEl.appendChild(tab)
}
tabsEl.style.display=results.length>0?'flex':'none'
activeTab=''
// Render cards
renderCardList(results)
}
function setActiveTab(ct){
activeTab=ct
document.querySelectorAll('.tab').forEach((t,i)=>{
const isAll=i===0&&!ct
const active=i>0&&ct&&t.textContent.includes(CLOUD_LABELS[ct])
t.className='tab'+(active||isAll?' active':'')
})
}
function renderCardList(items){
const el=document.getElementById('results')
if(items.length===0){
el.innerHTML='<div class="empty">暂无结果</div>'
return
}
el.innerHTML=items.map((item,idx)=>{
const coverHtml=item.cover
? '<img src="'+escapeHtml(item.cover)+'" alt="" onerror="this.parentElement.innerHTML=\'<div class=placeholder>'+escapeHtml(CLOUD_ICONS[item.cloud_type||'others'])+'</div>\'" loading="lazy" />'
: '<div class="placeholder">'+escapeHtml(CLOUD_ICONS[item.cloud_type||'others'])+'</div>'
const cloudLabel=CLOUD_LABELS[item.cloud_type]||item.cloud_type||''
const cloudColor=CLOUD_COLORS[item.cloud_type]||'#95a5a6'
const tags=extractTags(item.title||'')
const cleanTitle=(item.title||'').replace(/【[^】]+】/g,'').trim()
const relativeTime=formatTime(item.update_time||item.datetime||'')
return '<div class="card" onclick="saveItem('+idx+')">'+
'<div class="card-cover">'+coverHtml+'<span class="tag" style="background:'+cloudColor+'">'+cloudLabel+'</span></div>'+
'<div class="card-body">'+
'<div class="card-title">'+escapeHtml(cleanTitle)+'</div>'+
'<div class="card-meta"><span>🕐 '+relativeTime+'</span>'+(item.file_size?'<span class="size">📦 '+escapeHtml(item.file_size)+'</span>':'')+'</div>'+
(tags.length>0?'<div class="card-tags">'+tags.map(t=>'<span'+(isQualityTag(t)?' class="quality"':'')+'>'+escapeHtml(t)+'</span>').join('')+'</div>':'')+
'<div class="card-actions">'+
'<span class="card-source">'+(item.source?escapeHtml(item.source):'网盘')+'</span>'+
'<button class="card-btn" onclick="event.stopPropagation();saveItem('+idx+')">🔗 获取分享链接</button>'+
'</div>'+
'</div>'+
'</div>'
}).join('')
// Store items for save reference
window.__h5Results=items
}
function escapeHtml(s){if(!s)return '';return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
function extractTags(title){
const tags=[]
// Quality tags
const quality=['4K','1080P','2160P','720P','480P','HDR','HDR10','BluRay','REMUX','HEVC','x264','x265','WEB-DL','WEBRip']
for(const q of quality){if(title.includes(q)&&!tags.includes(q))tags.push(q)}
const kw=['杜比视界','杜比全景声','高码率','内封简繁英字幕','内嵌字幕','中文字幕','中英字幕']
for(const k of kw){if(title.includes(k)&&!tags.includes(k))tags.push(k)}
return tags.slice(0,6)
}
function isQualityTag(t){const q=['4K','1080P','2160P','720P','480P','HDR','HDR10','BluRay','REMUX','HEVC','x264','x265','臻彩','高清','WEB-DL','WEBRip'];return q.includes(t)}
function formatTime(s){
if(!s)return ''
const d=new Date(s)
if(isNaN(d.getTime()))return s.slice(0,10)
const diff=Date.now()-d.getTime()
if(diff<0)return s.slice(0,10)
const mins=Math.floor(diff/60000)
if(mins<60)return mins<=1?'刚刚':mins+' 分钟前'
const hours=Math.floor(mins/60)
if(hours<24)return hours+' 小时前'
const days=Math.floor(hours/24)
if(days<30)return days+' 天前'
return Math.floor(days/30)+' 个月前'
}
// ===== Save / Share =====
function saveItem(idx){
const items=window.__h5Results||[]
currentSaveItem=items[idx]
if(!currentSaveItem)return
document.getElementById('progressSteps').style.display='block'
document.getElementById('shareContent').style.display='none'
document.getElementById('saveError').style.display='none'
document.getElementById('copyBtn2').style.display='none'
const title=(currentSaveItem.title||'').replace(/【[^】]+】/g,'').trim()||'资源'
document.getElementById('shareTitle').textContent=title
// Show modal
document.getElementById('overlay').style.display='block'
document.getElementById('shareModal').style.display='block'
// Reset steps
resetSteps()
advanceStep(1)
// Call save API
doSave()
}
async function doSave(){
try{
const res=await fetch('/api/save',{method:'POST',headers:apiHeaders(),body:JSON.stringify({type:'search',source:currentSaveItem,target_cloud:currentSaveItem.cloud_type||'quark'})})
const data=await res.json()
if(!data.success){
document.getElementById('progressSteps').style.display='none'
document.getElementById('saveError').style.display='flex'
document.getElementById('saveError').textContent=data.message||data.error||'保存失败'
return
}
// Step 2
advanceStep(2)
await sleep(500)
// Step 3
advanceStep(3)
await sleep(300)
if(data.share_url){
advanceStep(4)
await sleep(200)
showShareResult(data)
}else{
advanceStep(4)
document.getElementById('progressSteps').style.display='none'
document.getElementById('saveError').style.display='flex'
document.getElementById('saveError').textContent='生成分享链接失败'
}
}catch(e){
document.getElementById('progressSteps').style.display='none'
document.getElementById('saveError').style.display='flex'
document.getElementById('saveError').textContent=e.message||'保存请求失败'
}
}
function showShareResult(data){
document.getElementById('progressSteps').style.display='none'
document.getElementById('shareContent').style.display='block'
const link=data.share_url
document.getElementById('shareLinkInput').value=link
const diskLabel=CLOUD_LABELS[currentSaveItem.cloud_type]||'夸克网盘'
document.getElementById('qrLabel').textContent=diskLabel+' APP扫码转存'
// Generate QR
const qrContainer=document.getElementById('qrContainer')
qrContainer.innerHTML=''
new QRCode(qrContainer,{text:link,width:140,height:140})
// Password
const pwd=data.share_pwd||data.sharePwd||''
if(pwd){
document.getElementById('sharePwdRow').style.display='flex'
document.getElementById('sharePwdTag').textContent=pwd
}else{
document.getElementById('sharePwdRow').style.display='none'
}
document.getElementById('copyBtn2').style.display='inline-block'
}
function resetSteps(){
for(let i=1;i<=3;i++){
const el=document.getElementById('step'+i)
el.className='step'
el.querySelector('.step-dot').innerHTML='<span>'+i+'</span>'
el.querySelector('.step-status').textContent='等待中'
el.querySelector('.step-status').className='step-status wait'
}
}
function advanceStep(n){
for(let i=1;i<=3;i++){
const el=document.getElementById('step'+i)
if(i<n){
el.className='step done'
el.querySelector('.step-dot').innerHTML='<span class="step-check">✓</span>'
el.querySelector('.step-status').textContent='已完成'
el.querySelector('.step-status').className='step-status done'
}else if(i===n){
el.className='step active'
el.querySelector('.step-dot').innerHTML='<span>'+i+'</span>'
const titles=['正在转存到','正在重命名文件(防和谐)','正在生成分享链接']
el.querySelector('.step-title').textContent=titles[i-1]+'...'
el.querySelector('.step-status').textContent='进行中'
el.querySelector('.step-status').className='step-status doing'
}
}
}
function sleep(ms){return new Promise(r=>setTimeout(r,ms))}
function copyShareLink(){
const input=document.getElementById('shareLinkInput')
if(!input.value)return
if(navigator.clipboard&&navigator.clipboard.writeText){
navigator.clipboard.writeText(input.value).then(()=>showToast('链接已复制')).catch(()=>fallbackCopy(input.value))
}else{
fallbackCopy(input.value)
}
}
function fallbackCopy(text){
const ta=document.createElement('textarea')
ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';document.body.appendChild(ta)
ta.select()
try{document.execCommand('copy');showToast('链接已复制')}catch{showToast('复制失败',true)}
document.body.removeChild(ta)
}
function openDisclaimer(){
window.open('/disclaimer/','_blank')
}
function closeModal(){
document.getElementById('overlay').style.display='none'
document.getElementById('shareModal').style.display='none'
document.getElementById('loginModal').style.display='none'
}
// ===== Init =====
checkLogin()
// Add Enter key handler for home search
document.getElementById('homeSearchInput').addEventListener('keydown',function(e){if(e.key==='Enter')homeSearch()})
// Also add for search view input
document.getElementById('searchInput').addEventListener('keydown',function(e){if(e.key==='Enter')doSearch()})
// Fetch home page data
fetch('/api/rankings/categorized').then(r=>r.json()).then(data=>{
renderHomePage(data)
}).catch(()=>{
document.getElementById('homeQuote').textContent='「 学而时习之,不亦说乎。 」'
document.getElementById('homeQuoteAuthor').textContent='---孔子'
})
// Check URL for query
const params=new URLSearchParams(window.location.search)
const q=params.get('q')
if(q){
document.getElementById('homePage').style.display='none'
document.getElementById('searchView').style.display='block'
document.getElementById('searchInput').value=q
doSearch()
}
// ===== Dark Mode Toggle =====
(function() {
var btn = document.createElement('button');
btn.className = 'theme-btn';
btn.title = '切换暗色模式';
var isDark = localStorage.getItem('h5_theme') === 'dark';
if (!isDark && window.matchMedia('(prefers-color-scheme: dark)').matches) isDark = true;
btn.textContent = isDark ? '☀️' : '🌙';
if (isDark) document.documentElement.setAttribute('data-theme', 'dark');
btn.onclick = function() {
var dark = document.documentElement.getAttribute('data-theme') !== 'dark';
document.documentElement.setAttribute('data-theme', dark ? 'dark' : '');
localStorage.setItem('h5_theme', dark ? 'dark' : 'light');
btn.textContent = dark ? '☀️' : '🌙';
};
document.body.appendChild(btn);
})();
+160
View File
@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>CloudSearch - 搜索</title>
<script>
// 替换标题为网站名称
fetch('/api/site-config').then(function(r){return r.json()}).then(function(cfg){
if(cfg.site_name) document.title = cfg.site_name + ' - 搜索';
}).catch(function(){});
</script>
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<link rel="stylesheet" href="/h5/style.css" />
</head>
<body>
<div class="app" id="app">
<!-- ===== Home Page (shown when no search) ===== -->
<div id="homePage" class="home-page">
<div class="home-hero">
<div class="home-logo" id="homeLogo" style="display:none"></div>
<div class="home-search-box">
<input id="homeSearchInput" type="text" placeholder="搜索网盘资源..." />
<button id="homeSearchBtn" onclick="homeSearch()">搜 索</button>
</div>
<div class="home-quote" id="homeQuote"></div>
<div class="home-quote-author" id="homeQuoteAuthor"></div>
</div>
<div class="home-rankings" id="homeRankings"></div>
</div>
<!-- ===== Search Results View ===== -->
<div id="searchView" style="display:none">
<!-- Header -->
<div class="header">
<div class="header-row">
<a href="/h5" class="header-title-link"><div class="header-title" id="headerTitle" style="display:none">CloudSearch</div></a>
<div class="search-wrap">
<input id="searchInput" type="text" placeholder="搜索网盘资源..." @keydown="handleKeydown" />
<button id="searchBtn" onclick="doSearch()">搜 索</button>
</div>
<div class="header-actions" id="userArea">
<template id="userLoggedIn">
<span class="user-badge" id="usernameDisplay"></span>
<button class="logout-btn-small" onclick="logout()">退出</button>
</template>
<template id="userLoggedOut">
<button class="login-btn-small" onclick="showLogin()">登录</button>
</template>
</div>
</div>
</div>
<!-- Info Bar -->
<div id="infoBar" class="info-bar" style="display:none">
<span id="infoCount" class="count"></span>
<span id="infoTime" class="time"></span>
<span id="infoFiltered" class="badge-err"></span>
</div>
<!-- Loading -->
<div id="loading" class="loading" style="display:none">
<div id="loadingText">🔍 正在搜索中...</div>
<div class="loading-bar"><div class="loading-bar-inner" id="loadingBar"></div></div>
</div>
<!-- Tabs -->
<div id="tabs" class="tabs" style="display:none"></div>
<!-- Results -->
<div id="results" class="results"></div>
<!-- Overlay -->
<div class="overlay" id="overlay" style="display:none" onclick="closeModal()"></div>
<!-- Share Modal -->
<div class="modal" id="shareModal" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:101">
<div class="modal-hdr" id="shareTitle">分享链接</div>
<div class="modal-body">
<div id="progressSteps" class="steps" style="display:none">
<div class="step" id="step1"><div class="step-dot"><span>1</span></div><div class="step-body"><span class="step-title">正在转存...</span><span class="step-status doing">进行中</span></div></div>
<div class="step" id="step2"><div class="step-dot"><span>2</span></div><div class="step-body"><span class="step-title">重命名文件(防和谐)...</span><span class="step-status wait">等待中</span></div></div>
<div class="step" id="step3"><div class="step-dot"><span>3</span></div><div class="step-body"><span class="step-title">生成分享链接...</span><span class="step-status wait">等待中</span></div></div>
</div>
<div id="saveError" class="error-alert" style="display:none"></div>
<div id="shareContent" style="display:none">
<div class="share-qr">
<div id="qrContainer"></div>
<div class="qr-label" id="qrLabel"></div>
<div class="qr-sub">保存到你自己的网盘</div>
</div>
<div class="share-section">
<div class="share-row">
<input id="shareLinkInput" type="text" readonly />
</div>
<div id="sharePwdRow" class="share-pwd" style="display:none">
<span>🔑 提取密码:</span>
<span class="pwd-tag" id="sharePwdTag"></span>
<span class="pwd-hint">打开链接后需输入密码</span>
</div>
<div class="share-tip">
<span class="warn-icon">⚠️</span>
<div class="tip-text">
<strong>请尽快复制链接到浏览器打开</strong> 或 <strong>用夸克APP扫码</strong><br>
<strong>转存至您的网盘,以免资源被官方和谐</strong>
</div>
</div>
<div class="warning-box">
<p class="warning-item">郑重警告一:网盘内除您所需资源外,不要打开任何不相关内容。</p>
<p class="warning-item">郑重警告二:网盘内除您所需资源外,不要打开任何不相关内容。</p>
<p class="warning-item">郑重警告三:网盘内除您所需资源外,不要打开任何不相关内容。</p>
<p class="warning-item">郑重警告四:以上警告说三遍,你还要明知故犯吗?</p>
</div>
<div class="share-disclaimer">
<span>⚠️ 本站资源仅供学习交流,请于24h内删除</span>
</div>
</div>
</div>
</div>
<div class="modal-ftr">
<button class="btn-disclaimer" onclick="openDisclaimer()">📜 免责声明</button>
<button class="btn-close" onclick="closeModal()">关闭</button>
<button class="btn-primary" id="copyBtn2" onclick="copyShareLink()" style="display:none">一键复制链接</button>
</div>
</div>
<!-- Login Modal -->
<div class="modal" id="loginModal" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:101">
<div class="modal-hdr">登录</div>
<div class="modal-body">
<div class="login-form">
<input id="loginUser" type="text" placeholder="用户名" />
<input id="loginPass" type="password" placeholder="密码" />
<button class="login-btn" id="loginBtn" onclick="handleLogin()">登录</button>
<div class="login-err" id="loginErr"></div>
</div>
</div>
<div class="modal-ftr">
<button class="btn-close" onclick="closeLogin()">取消</button>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast"></div>
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<script src="/h5/app.js"></script>
<!-- Footer -->
<div id="siteFooter" class="site-footer" style="display:none">
<div id="footerContent" class="footer-inner"></div>
<div class="footer-actions" id="footerActions">
<button class="footer-btn" onclick="openDisclaimer()">📜 免责声明</button>
</div>
</div>
</html>
+179
View File
@@ -0,0 +1,179 @@
/* ===== Dark Mode ===== */[data-theme="dark"] body { background:#1a1a1a;color:#e5e5e5 }[data-theme="dark"] .rank-block,[data-theme="dark"] .card,[data-theme="dark"] .modal,[data-theme="dark"] .header { background:#1f1f1f;border-color:#333 }[data-theme="dark"] input,[data-theme="dark"] .home-search-box,[data-theme="dark"] .search-wrap { background:#2a2a2a;color:#e5e5e5;border-color:#333 }[data-theme="dark"] .rank-block-title,[data-theme="dark"] .card-title,[data-theme="dark"] .rank-name,[data-theme="dark"] .modal-hdr { color:#e5e5e5 }[data-theme="dark"] .card-meta,[data-theme="dark"] .rank-cnt,[data-theme="dark"] .info-bar { color:#999 }[data-theme="dark"] .rank-tab,[data-theme="dark"] .tab { background:#333;color:#999 }[data-theme="dark"] .rank-tab.active,[data-theme="dark"] .tab.active { background:#409eff;color:#fff }[data-theme="dark"] .site-footer { background:#1a1a1a;border-color:#333;color:#999 }[data-theme="dark"] .rank-block-ftr { border-color:#333;color:#666 }[data-theme="dark"] .rank-idx { background:#333;color:#999 }[data-theme="dark"] .theme-btn { position:fixed;bottom:20px;right:20px;z-index:99;width:40px;height:40px;border-radius:50%;border:1px solid #555;background:#1f1f1f;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.3) }
/* ===== Reset & Base ===== */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{font-size:16px;-webkit-text-size-adjust:100%}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;background:#f5f5f5;color:#303133;min-height:100vh;overflow-x:hidden}
:root{--primary:#409eff;--primary-dark:#337ecc;--primary-light:rgba(64,158,255,0.08);--text:#303133;--text2:#909399;--border:#ebeef5;--bg:#f5f5f5;--white:#fff;--radius:10px;--shadow:0 1px 4px rgba(0,0,0,0.04);--safe-bottom:env(safe-area-inset-bottom,0px)}
a{color:var(--primary);text-decoration:none}
img{display:block;max-width:100%}
/* ===== Home Page ===== */
.home-page{padding-bottom:calc(30px + var(--safe-bottom))}
.home-hero{display:flex;flex-direction:column;align-items:center;padding:36px 16px 20px}
.home-logo{font-size:32px;font-weight:700;color:var(--primary);margin-bottom:20px;text-align:center}
.home-logo-img{max-width:360px;max-height:80px;width:auto;height:auto;object-fit:contain}
.home-search-box{display:flex;width:100%;max-width:500px;border:1px solid var(--border);border-radius:20px;overflow:hidden;background:var(--bg);transition:border-color .2s}
.home-search-box:focus-within{border-color:var(--primary);background:var(--white);box-shadow:0 0 0 3px rgba(64,158,255,.1)}
.home-search-box input{flex:1;height:40px;border:none;padding:0 14px;font-size:14px;outline:none;background:transparent}
.home-search-box button{flex-shrink:0;height:32px;margin:4px;padding:0 22px;border:none;border-radius:999px;background:var(--primary);color:var(--white);font-size:13px;font-weight:600;cursor:pointer}
.home-search-box button:active{background:var(--primary-dark)}
.home-quote{margin-top:12px;font-size:12px;color:#b0b8c4;font-style:italic;text-align:center;max-width:500px;line-height:1.5}
.home-quote-author{font-size:11px;color:#c0c4cc;display:inline-block;margin-top:2px}
/* ===== Home Rankings ===== */
.home-rankings{padding:8px 12px;display:flex;flex-direction:column;gap:10px}
.rank-block{background:var(--white);border-radius:var(--radius);padding:12px;border:1px solid var(--border);box-shadow:var(--shadow)}
.rank-block-hdr{display:flex;align-items:center;justify-content:space-between;padding-bottom:8px;border-bottom:2px solid #f0f0f0;margin-bottom:4px}
.rank-block-title{font-size:14px;font-weight:700;color:var(--text);white-space:nowrap}
.rank-block-tabs{display:flex;gap:2px;background:#f0f2f5;border-radius:5px;padding:2px}
.rank-tab{font-size:11px;padding:2px 9px;border-radius:4px;cursor:pointer;color:#909399;font-weight:500;transition:all .2s;user-select:none}
.rank-tab.active{background:var(--white);color:var(--primary);font-weight:600;box-shadow:0 1px 2px rgba(0,0,0,.06)}
.rank-item{display:flex;align-items:center;gap:7px;padding:5px 6px;border-radius:6px;cursor:pointer;transition:background .15s}
.rank-item:active{background:#f0f5ff}
.rank-idx{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#909399;background:#f0f0f0;flex-shrink:0}
.rank-idx.top3{background:var(--primary);color:var(--white);font-size:12px}
.rank-name{flex:1;min-width:0;font-size:13px;font-weight:500;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rank-cnt{font-size:11px;color:#c0c4cc;white-space:nowrap;flex-shrink:0}
.rank-expand{text-align:center;padding:5px;margin-top:2px;font-size:12px;color:var(--primary);cursor:pointer;border-radius:5px;user-select:none}
.rank-expand:active{background:#ecf5ff}
.rank-block-ftr{margin-top:6px;padding-top:6px;border-top:1px solid #f0f0f0;display:flex;align-items:center;justify-content:space-between;font-size:10px;color:#c0c4cc}
.ftr-time{font-family:monospace;font-size:9px}
/* ===== Layout ===== */
.app{max-width:100%;margin:0 auto;padding-bottom:calc(20px + var(--safe-bottom))}
.header{position:sticky;top:0;z-index:50;background:var(--white);border-bottom:1px solid var(--border);padding:8px 12px}
.header-row{display:flex;align-items:center;gap:10px}
.header-title{font-size:18px;font-weight:700;color:var(--primary);flex-shrink:0}
.header-title-link{text-decoration:none;flex-shrink:0;display:flex;align-items:center}
.header-logo-img{max-width:120px;max-height:28px;width:auto;height:auto;object-fit:contain;display:block}
.header-actions{margin-left:auto;display:flex;gap:6px;align-items:center}
/* ===== Search Bar ===== */
.search-wrap{flex:1;display:flex;border:1px solid var(--border);border-radius:18px;overflow:hidden;background:var(--bg);transition:border-color .2s}
.search-wrap:focus-within{border-color:var(--primary);background:var(--white)}
.search-wrap input{flex:1;height:36px;border:none;padding:0 14px;font-size:14px;outline:none;background:transparent}
.search-wrap button{flex-shrink:0;height:28px;margin:4px;padding:0 18px;border:none;border-radius:999px;background:var(--primary);color:var(--white);font-size:13px;font-weight:600;cursor:pointer;transition:background .2s}
.search-wrap button:active{background:var(--primary-dark)}
.search-wrap button:disabled{opacity:.5}
/* ===== Footer ===== */
.site-footer{margin-top:30px;padding:16px 12px 24px;background:#f9fafb;border-top:1px solid var(--border)}
.footer-inner{max-width:500px;margin:0 auto;font-size:11px;line-height:1.8;color:#909399;text-align:center;white-space:pre-line}
.footer-actions{display:flex;justify-content:center;gap:10px;margin-top:12px;flex-wrap:wrap}
.footer-btn{padding:8px 20px;border:1px solid var(--border);border-radius:8px;background:var(--white);color:var(--text);font-size:13px;cursor:pointer;transition:all .2s}
.footer-btn:active{background:var(--primary-light);border-color:var(--primary);color:var(--primary)}
/* ===== Info Bar ===== */
.info-bar{display:flex;align-items:center;gap:8px;padding:10px 12px 0;font-size:12px;color:var(--text2);flex-wrap:wrap}
.info-bar .count{font-weight:600;color:var(--text)}
.info-bar .time{font-family:monospace;background:#f4f4f5;padding:1px 6px;border-radius:4px}
.info-bar .badge-err{background:#fef0f0;color:#f56c6c;padding:1px 6px;border-radius:4px}
/* ===== Loading ===== */
.loading{padding:24px 12px;text-align:center;font-size:13px;color:var(--text2)}
.loading-bar{width:100%;height:3px;background:#e8e8e8;border-radius:2px;overflow:hidden;margin-top:8px}
.loading-bar-inner{height:100%;background:linear-gradient(90deg,var(--primary),#67c23a);border-radius:2px;transition:width .3s ease;width:0%}
/* ===== Tabs ===== */
.tabs{display:flex;gap:4px;padding:8px 12px;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none}
.tabs::-webkit-scrollbar{display:none}
.tab{flex-shrink:0;padding:5px 12px;border-radius:16px;font-size:12px;color:#606266;background:#f0f2f5;cursor:pointer;white-space:nowrap;transition:all .2s;user-select:none}
.tab:active{transform:scale(.95)}
.tab.active{background:var(--primary-light);color:var(--primary);font-weight:600}
/* ===== Results ===== */
.results{display:flex;flex-direction:column;gap:10px;padding:8px 12px}
.empty{padding:40px 12px;text-align:center;color:var(--text2);font-size:14px}
/* ===== Card ===== */
.card{display:flex;gap:10px;background:var(--white);border-radius:var(--radius);padding:10px;border:1px solid var(--border);transition:border-color .2s}
.card:active{border-color:#c0c4cc}
.card-cover{flex-shrink:0;width:90px;height:120px;border-radius:8px;overflow:hidden;background:var(--bg);position:relative}
.card-cover img{width:100%;height:100%;object-fit:cover}
.card-cover .placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:28px;background:linear-gradient(135deg,#667eea,#764ba2);color:rgba(255,255,255,.6)}
.card-cover .tag{position:absolute;bottom:3px;left:3px;padding:1px 5px;border-radius:3px;color:#fff;font-size:10px;font-weight:600;backdrop-filter:blur(2px)}
.card-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}
.card-title{font-size:14px;font-weight:700;color:var(--text);line-height:1.3;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}
.card-meta{font-size:11px;color:var(--text2);display:flex;align-items:center;gap:8px}
.card-meta .size{color:#67c23a}
.card-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:2px}
.card-tags span{font-size:10px;padding:1px 6px;border-radius:4px;background:#ecf5ff;color:#409eff;white-space:nowrap}
.card-tags .quality{background:#fef0f0;color:#e74c3c}
.card-actions{margin-top:auto;display:flex;align-items:center;justify-content:space-between;gap:6px}
.card-source{font-size:10px;color:var(--text2);background:#f4f4f5;padding:1px 6px;border-radius:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px}
.card-btn{padding:4px 10px;border:none;border-radius:6px;background:var(--primary);color:var(--white);font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap;transition:background .2s}
.card-btn:active{background:var(--primary-dark)}
.card-btn:disabled{opacity:.5}
/* ===== Toast ===== */
.toast{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,.75);color:#fff;padding:10px 20px;border-radius:8px;font-size:14px;z-index:200;pointer-events:none;opacity:0;transition:opacity .3s}
.toast.show{opacity:1}
.toast.error{background:rgba(245,108,108,.9)}
/* ===== Overlay / Modal ===== */
.overlay{position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;padding:16px;animation:fadeIn .2s}
.modal{background:var(--white);border-radius:14px;width:100%;max-width:420px;max-height:90vh;overflow-y:auto;padding:0;animation:slideUp .25s}
.modal-hdr{padding:14px 16px;border-bottom:1px solid var(--border);font-size:15px;font-weight:700;color:var(--text)}
.modal-body{padding:14px 16px}
.modal-ftr{padding:10px 16px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end}
.modal-ftr button{height:36px;padding:0 16px;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer}
.modal-ftr .btn-close{background:#f4f4f5;color:#606266}
.modal-ftr .btn-disclaimer{background:#fdf6ec;color:#d46b08;margin-right:auto}
.modal-ftr .btn-primary{background:var(--primary);color:var(--white)}
.modal-ftr .btn-primary:active{background:var(--primary-dark)}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
@keyframes slideUp{from{transform:translateY(40px);opacity:0}to{transform:translateY(0);opacity:1}}
/* ===== Share Modal ===== */
.share-section{display:flex;flex-direction:column;gap:10px}
.share-row{display:flex;gap:8px}
.share-row input{flex:1;height:36px;border:1px solid var(--border);border-radius:6px;padding:0 10px;font-size:13px;outline:none;background:var(--bg);color:var(--text)}
.share-row input:focus{border-color:var(--primary)}
.share-row .copy-btn{height:36px;padding:0 12px;border:none;border-radius:6px;background:var(--primary);color:var(--white);font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap}
.share-pwd{display:flex;align-items:center;gap:6px;font-size:13px}
.share-pwd .pwd-tag{padding:2px 8px;background:#fdf6ec;color:#e6a23c;border-radius:4px;font-weight:700}
.share-pwd .pwd-hint{font-size:11px;color:var(--text2)}
.share-tip{padding:8px 10px;background:#fdf6ec;border-radius:6px;font-size:12px;line-height:1.5;color:#d46b08;display:flex;gap:6px;align-items:flex-start}
.share-tip .warn-icon{font-size:18px;line-height:1.5;flex-shrink:0}
.share-tip .tip-text{flex:1;min-width:0}
.share-tip strong{font-weight:700}
.warning-box{background:#fff2f0;border:1px solid #ffccc7;border-radius:8px;padding:8px 10px;overflow-x:auto;-webkit-overflow-scrolling:touch}
.warning-item{margin:0;font-size:12px;line-height:1.8;font-weight:700;white-space:nowrap}
.warning-item:nth-child(odd){color:#cf1322}
.warning-item:nth-child(even){color:#d46b08}
.warning-item:last-child{color:#b71c1c;font-size:13px}
.share-qr{display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px}
.share-qr canvas{border-radius:8px}
.share-qr .qr-label{font-size:12px;font-weight:600;color:var(--primary)}
.share-qr .qr-sub{font-size:11px;color:var(--text2)}
.share-disclaimer{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:10px;padding:8px 10px;background:#fdf6ec;border-radius:6px;font-size:12px;color:#d46b08;flex-wrap:wrap}
/* ===== Login Modal ===== */
.login-form{display:flex;flex-direction:column;gap:12px}
.login-form input{height:40px;border:1px solid var(--border);border-radius:8px;padding:0 12px;font-size:14px;outline:none}
.login-form input:focus{border-color:var(--primary)}
.login-form .login-btn{height:40px;border:none;border-radius:8px;background:var(--primary);color:var(--white);font-size:14px;font-weight:600;cursor:pointer;transition:background .2s}
.login-form .login-btn:active{background:var(--primary-dark)}
.login-form .login-btn:disabled{opacity:.5}
.login-form .login-err{font-size:12px;color:#f56c6c;text-align:center}
/* ===== Progress Steps ===== */
.steps{display:flex;flex-direction:column;gap:10px;padding:8px 0}
.step{display:flex;align-items:flex-start;gap:10px;opacity:.4;transition:opacity .3s}
.step.active{opacity:1}
.step.done{opacity:.7}
.step-dot{flex-shrink:0;width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;background:#e4e7ed;color:#909399}
.step.active .step-dot{background:var(--primary);color:#fff;box-shadow:0 0 0 3px rgba(64,158,255,.2)}
.step.done .step-dot{background:#67c23a;color:#fff}
.step-body{flex:1;padding-top:3px;display:flex;align-items:center;gap:8px}
.step-title{font-size:13px;color:var(--text);font-weight:500}
.step-status{font-size:11px;padding:1px 7px;border-radius:10px;white-space:nowrap}
.step-status.doing{background:#ecf5ff;color:var(--primary)}
.step-status.done{background:#f0f9eb;color:#67c23a}
.step-status.wait{background:#f4f4f5;color:#c0c4cc}
.error-alert{padding:12px 16px;background:#fef0f0;border:1px solid #fde2e2;border-radius:8px;display:flex;align-items:center;gap:8px;font-size:14px;color:#f56c6c}
/* ===== User Badge ===== */
.user-badge{font-size:12px;color:var(--primary);font-weight:600;white-space:nowrap}
.login-btn-small{height:30px;padding:0 10px;border:none;border-radius:6px;background:var(--primary-light);color:var(--primary);font-size:12px;font-weight:600;cursor:pointer}
.logout-btn-small{height:30px;padding:0 8px;border:none;border-radius:6px;background:transparent;color:var(--text2);font-size:12px;cursor:pointer}
+29
View File
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CloudSearch - 网盘资源搜索</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script>
(function() {
// 替换标题为网站名称
fetch('/api/site-config').then(r=>r.json()).then(cfg=>{
if(cfg.site_name) document.title = cfg.site_name + ' - 网盘资源搜索';
}).catch(function(){});
// 跳过:参数 ?desktop=1 强制使用桌面版
if (window.location.search.includes('desktop=1')) return;
var ua = navigator.userAgent;
var isMobile = /Android|webOS|iPhone|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(ua);
var isTablet = /iPad|Android(?!.*Mobile)/i.test(ua);
if (isMobile || isTablet) {
window.location.replace(window.location.origin + '/h5');
}
})();
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# 前端路由(SPA)
location / {
try_files $uri $uri/ /index.html;
}
# H5 手机版
location /h5 {
alias /usr/share/nginx/html/h5;
try_files $uri $uri/ /h5/index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# API 代理到后端
location /api/ {
proxy_pass http://backend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 120s;
}
# 静态资源缓存
location /assets {
expires 7d;
add_header Cache-Control "public, immutable";
}
}
+2123
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "cloudsearch-frontend",
"version": "0.2.2",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.0",
"echarts": "^6.0.0",
"element-plus": "^2.8.0",
"pinia": "^2.2.0",
"qrcode": "^1.5.4",
"vue": "^3.5.0",
"vue-echarts": "^8.0.1",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^5.1.0",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vue-tsc": "^2.1.0"
}
}
+72
View File
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>免责声明 - 资源分享</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: #f5f7fa;
color: #333;
line-height: 1.8;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
padding: 40px;
}
h1 {
font-size: 22px;
text-align: center;
margin-bottom: 30px;
color: #1a1a2e;
border-bottom: 2px solid #e8e8e8;
padding-bottom: 16px;
}
h2 { font-size: 16px; margin: 24px 0 10px; color: #1a1a2e; }
h3 { font-size: 15px; margin: 18px 0 8px; color: #303133; }
p { margin: 8px 0; text-indent: 2em; font-size: 14px; }
.highlight { background: #fff3cd; padding: 1px 4px; border-radius: 3px; }
.footer { margin-top: 30px; padding-top: 16px; border-top: 1px solid #e8e8e8; text-align: center; font-size: 12px; color: #999; }
.back-link { display: inline-block; margin-top: 20px; color: #409eff; text-decoration: none; font-size: 14px; }
.back-link:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<h1>📜 网站免责声明</h1>
<h2>一、版权与资源声明</h2>
<p>本网站(<a href="https://hk-zy.timaa.cn" target="_blank">hk-zy.timaa.cn</a>)是一个基于开源项目搭建的<strong>非盈利性个人站点</strong>,旨在分享与交流技术资源。本网站所有资源均收集整理自互联网,其版权、著作权均归原作者或发行公司所有。本网站不对资源的版权归属进行实质审查,对于任何由资源本身引发的版权争议概不负责。</p>
<h2>二、使用限制与法律责任</h2>
<p>用户在本网站下载的所有软件、资料等资源,仅供<strong>个人学习、研究、技术交流</strong>,严禁用于任何商业或非法用途。用户必须在下载后的<strong class="highlight">24小时内</strong>,从个人电脑及存储设备中彻底删除相关内容。如用户喜欢该程序或内容,请支持正版,到官方网站购买注册。</p>
<p>因用户不当使用(包括但不限于商业使用、非法传播、破解侵权)而引发的一切法律纠纷及后果,由用户<strong>自行承担</strong>,本网站及网站管理者不承担任何连带责任。</p>
<h2>三、"避风港原则"与侵权处理</h2>
<p>依据《信息网络传播权保护条例》,本网站仅提供信息存储空间服务或资源索引服务。若用户上传或分享的内容侵犯了您的合法权益,请您立即通过以下联系方式与我们交涉。</p>
<p><strong>联系方式:</strong> 3337598077@qq.com</p>
<p><strong>处理措施:</strong> 我们在收到权利人发出的合格通知(包括权属证明和侵权链接)后,将在合理期限内对涉嫌侵权内容进行核实、断开链接或直接删除。</p>
<p><strong>唯一目的:</strong> 本网站为纯公益、非盈利性分享,绝无意侵害任何第三方权益。若内容涉及侵权,实属无意,请版权方及时通知以便我们处理。</p>
<h2>四、用户行为与网站免责</h2>
<p>访问者在本网站进行下载、浏览时,须自行承担风险。本网站不保证资源完全无毒、无缺陷或绝对安全,对于因使用本站资源而造成的硬件损坏、数据丢失等损失,本网站不负任何责任。</p>
<p>本站内容仅代表资源提供者的个人观点,不代表本站立场。对于任何站点外部链接的真实性、合法性,本站不承担担保责任。</p>
<p>凡以任何方式登陆本网站或直接、间接使用本网站资源者,视为自愿接受本网站免责声明的约束。</p>
<h2>五、法律适用</h2>
<p>本声明未涉及的问题参见国家有关法律法规。当本声明与国家法律法规冲突时,以国家法律法规为准。本网站保留对本声明的最终解释权。</p>
<div class="footer">
<p style="text-indent:0">更新日期:2026年5月 · 版本 V1.0</p>
<a class="back-link" href="/" onclick="history.length > 1 ? history.back() : (location.href='/'); return false;">← 返回首页</a>
</div>
</div>
</body>
</html>
+923
View File
@@ -0,0 +1,923 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>CloudSearch - 搜索</title>
<script>
// 替换标题为网站名称
fetch('/api/site-config').then(function(r){return r.json()}).then(function(cfg){
if(cfg.site_name) document.title = cfg.site_name + ' - 搜索';
}).catch(function(){});
</script>
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<style>
/* ===== Reset & Base ===== */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{font-size:16px;-webkit-text-size-adjust:100%}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;background:#f5f5f5;color:#303133;min-height:100vh;overflow-x:hidden}
:root{--primary:#409eff;--primary-dark:#337ecc;--primary-light:rgba(64,158,255,0.08);--text:#303133;--text2:#909399;--border:#ebeef5;--bg:#f5f5f5;--white:#fff;--radius:10px;--shadow:0 1px 4px rgba(0,0,0,0.04);--safe-bottom:env(safe-area-inset-bottom,0px)}
a{color:var(--primary);text-decoration:none}
img{display:block;max-width:100%}
/* ===== Home Page ===== */
.home-page{padding-bottom:calc(30px + var(--safe-bottom))}
.home-hero{display:flex;flex-direction:column;align-items:center;padding:36px 16px 20px}
.home-logo{font-size:32px;font-weight:700;color:var(--primary);margin-bottom:20px;text-align:center}
.home-logo-img{max-width:360px;max-height:80px;width:auto;height:auto;object-fit:contain}
.home-search-box{display:flex;width:100%;max-width:500px;border:1px solid var(--border);border-radius:20px;overflow:hidden;background:var(--bg);transition:border-color .2s}
.home-search-box:focus-within{border-color:var(--primary);background:var(--white);box-shadow:0 0 0 3px rgba(64,158,255,.1)}
.home-search-box input{flex:1;height:40px;border:none;padding:0 14px;font-size:14px;outline:none;background:transparent}
.home-search-box button{flex-shrink:0;height:32px;margin:4px;padding:0 22px;border:none;border-radius:999px;background:var(--primary);color:var(--white);font-size:13px;font-weight:600;cursor:pointer}
.home-search-box button:active{background:var(--primary-dark)}
.home-quote{margin-top:12px;font-size:12px;color:#b0b8c4;font-style:italic;text-align:center;max-width:500px;line-height:1.5}
.home-quote-author{font-size:11px;color:#c0c4cc;display:inline-block;margin-top:2px}
/* ===== Home Rankings ===== */
.home-rankings{padding:8px 12px;display:flex;flex-direction:column;gap:10px}
.rank-block{background:var(--white);border-radius:var(--radius);padding:12px;border:1px solid var(--border);box-shadow:var(--shadow)}
.rank-block-hdr{display:flex;align-items:center;justify-content:space-between;padding-bottom:8px;border-bottom:2px solid #f0f0f0;margin-bottom:4px}
.rank-block-title{font-size:14px;font-weight:700;color:var(--text);white-space:nowrap}
.rank-block-tabs{display:flex;gap:2px;background:#f0f2f5;border-radius:5px;padding:2px}
.rank-tab{font-size:11px;padding:2px 9px;border-radius:4px;cursor:pointer;color:#909399;font-weight:500;transition:all .2s;user-select:none}
.rank-tab.active{background:var(--white);color:var(--primary);font-weight:600;box-shadow:0 1px 2px rgba(0,0,0,.06)}
.rank-item{display:flex;align-items:center;gap:7px;padding:5px 6px;border-radius:6px;cursor:pointer;transition:background .15s}
.rank-item:active{background:#f0f5ff}
.rank-idx{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#909399;background:#f0f0f0;flex-shrink:0}
.rank-idx.top3{background:var(--primary);color:var(--white);font-size:12px}
.rank-name{flex:1;min-width:0;font-size:13px;font-weight:500;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rank-cnt{font-size:11px;color:#c0c4cc;white-space:nowrap;flex-shrink:0}
.rank-expand{text-align:center;padding:5px;margin-top:2px;font-size:12px;color:var(--primary);cursor:pointer;border-radius:5px;user-select:none}
.rank-expand:active{background:#ecf5ff}
.rank-block-ftr{margin-top:6px;padding-top:6px;border-top:1px solid #f0f0f0;display:flex;align-items:center;justify-content:space-between;font-size:10px;color:#c0c4cc}
.ftr-time{font-family:monospace;font-size:9px}
/* ===== Layout ===== */
.app{max-width:100%;margin:0 auto;padding-bottom:calc(20px + var(--safe-bottom))}
.header{position:sticky;top:0;z-index:50;background:var(--white);border-bottom:1px solid var(--border);padding:8px 12px}
.header-row{display:flex;align-items:center;gap:10px}
.header-title{font-size:18px;font-weight:700;color:var(--primary);flex-shrink:0}
.header-title-link{text-decoration:none;flex-shrink:0;display:flex;align-items:center}
.header-logo-img{max-width:160px;max-height:36px;width:auto;height:auto;object-fit:contain;display:block}
.header-actions{margin-left:auto;display:flex;gap:6px;align-items:center}
/* ===== Search Bar ===== */
.search-wrap{flex:1;display:flex;border:1px solid var(--border);border-radius:18px;overflow:hidden;background:var(--bg);transition:border-color .2s}
.search-wrap:focus-within{border-color:var(--primary);background:var(--white)}
.search-wrap input{flex:1;height:36px;border:none;padding:0 14px;font-size:14px;outline:none;background:transparent}
.search-wrap button{flex-shrink:0;height:28px;margin:4px;padding:0 18px;border:none;border-radius:999px;background:var(--primary);color:var(--white);font-size:13px;font-weight:600;cursor:pointer;transition:background .2s}
.search-wrap button:active{background:var(--primary-dark)}
.search-wrap button:disabled{opacity:.5}
/* ===== Footer ===== */
.site-footer{margin-top:30px;padding:16px 12px 24px;background:#f9fafb;border-top:1px solid var(--border)}
.footer-inner{max-width:500px;margin:0 auto;font-size:11px;line-height:1.8;color:#909399;text-align:center;white-space:pre-line}
.footer-actions{display:flex;justify-content:center;gap:10px;margin-top:12px;flex-wrap:wrap}
.footer-btn{padding:8px 20px;border:1px solid var(--border);border-radius:8px;background:var(--white);color:var(--text);font-size:13px;cursor:pointer;transition:all .2s}
.footer-btn:active{background:var(--primary-light);border-color:var(--primary);color:var(--primary)}
/* ===== Info Bar ===== */
.info-bar{display:flex;align-items:center;gap:8px;padding:10px 12px 0;font-size:12px;color:var(--text2);flex-wrap:wrap}
.info-bar .count{font-weight:600;color:var(--text)}
.info-bar .time{font-family:monospace;background:#f4f4f5;padding:1px 6px;border-radius:4px}
.info-bar .badge-err{background:#fef0f0;color:#f56c6c;padding:1px 6px;border-radius:4px}
/* ===== Loading ===== */
.loading{padding:24px 12px;text-align:center;font-size:13px;color:var(--text2)}
.loading-bar{width:100%;height:3px;background:#e8e8e8;border-radius:2px;overflow:hidden;margin-top:8px}
.loading-bar-inner{height:100%;background:linear-gradient(90deg,var(--primary),#67c23a);border-radius:2px;transition:width .3s ease;width:0%}
/* ===== Tabs ===== */
.tabs{display:flex;gap:4px;padding:8px 12px;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none}
.tabs::-webkit-scrollbar{display:none}
.tab{flex-shrink:0;padding:5px 12px;border-radius:16px;font-size:12px;color:#606266;background:#f0f2f5;cursor:pointer;white-space:nowrap;transition:all .2s;user-select:none}
.tab:active{transform:scale(.95)}
.tab.active{background:var(--primary-light);color:var(--primary);font-weight:600}
/* ===== Results ===== */
.results{display:flex;flex-direction:column;gap:10px;padding:8px 12px}
.empty{padding:40px 12px;text-align:center;color:var(--text2);font-size:14px}
/* ===== Card ===== */
.card{display:flex;gap:10px;background:var(--white);border-radius:var(--radius);padding:10px;border:1px solid var(--border);transition:border-color .2s}
.card:active{border-color:#c0c4cc}
.card-cover{flex-shrink:0;width:90px;height:120px;border-radius:8px;overflow:hidden;background:var(--bg);position:relative}
.card-cover img{width:100%;height:100%;object-fit:cover}
.card-cover .placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:28px;background:linear-gradient(135deg,#667eea,#764ba2);color:rgba(255,255,255,.6)}
.card-cover .tag{position:absolute;bottom:3px;left:3px;padding:1px 5px;border-radius:3px;color:#fff;font-size:10px;font-weight:600;backdrop-filter:blur(2px)}
.card-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}
.card-title{font-size:14px;font-weight:700;color:var(--text);line-height:1.3;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}
.card-meta{font-size:11px;color:var(--text2);display:flex;align-items:center;gap:8px}
.card-meta .size{color:#67c23a}
.card-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:2px}
.card-tags span{font-size:10px;padding:1px 6px;border-radius:4px;background:#ecf5ff;color:#409eff;white-space:nowrap}
.card-tags .quality{background:#fef0f0;color:#e74c3c}
.card-actions{margin-top:auto;display:flex;align-items:center;justify-content:space-between;gap:6px}
.card-source{font-size:10px;color:var(--text2);background:#f4f4f5;padding:1px 6px;border-radius:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px}
.card-btn{padding:4px 10px;border:none;border-radius:6px;background:var(--primary);color:var(--white);font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap;transition:background .2s}
.card-btn:active{background:var(--primary-dark)}
.card-btn:disabled{opacity:.5}
/* ===== Toast ===== */
.toast{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,.75);color:#fff;padding:10px 20px;border-radius:8px;font-size:14px;z-index:200;pointer-events:none;opacity:0;transition:opacity .3s}
.toast.show{opacity:1}
.toast.error{background:rgba(245,108,108,.9)}
/* ===== Overlay / Modal ===== */
.overlay{position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;padding:16px;animation:fadeIn .2s}
.modal{background:var(--white);border-radius:14px;width:100%;max-width:420px;max-height:90vh;overflow-y:auto;padding:0;animation:slideUp .25s}
.modal-hdr{padding:14px 16px;border-bottom:1px solid var(--border);font-size:15px;font-weight:700;color:var(--text)}
.modal-body{padding:14px 16px}
.modal-ftr{padding:10px 16px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end}
.modal-ftr button{height:36px;padding:0 16px;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer}
.modal-ftr .btn-close{background:#f4f4f5;color:#606266}
.modal-ftr .btn-disclaimer{background:#fdf6ec;color:#d46b08;margin-right:auto}
.modal-ftr .btn-primary{background:var(--primary);color:var(--white)}
.modal-ftr .btn-primary:active{background:var(--primary-dark)}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
@keyframes slideUp{from{transform:translateY(40px);opacity:0}to{transform:translateY(0);opacity:1}}
/* ===== Share Modal ===== */
.share-section{display:flex;flex-direction:column;gap:10px}
.share-row{display:flex;gap:8px}
.share-row input{flex:1;height:36px;border:1px solid var(--border);border-radius:6px;padding:0 10px;font-size:13px;outline:none;background:var(--bg);color:var(--text)}
.share-row input:focus{border-color:var(--primary)}
.share-row .copy-btn{height:36px;padding:0 12px;border:none;border-radius:6px;background:var(--primary);color:var(--white);font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap}
.share-pwd{display:flex;align-items:center;gap:6px;font-size:13px}
.share-pwd .pwd-tag{padding:2px 8px;background:#fdf6ec;color:#e6a23c;border-radius:4px;font-weight:700}
.share-pwd .pwd-hint{font-size:11px;color:var(--text2)}
.share-tip{padding:8px 10px;background:#fdf6ec;border-radius:6px;font-size:12px;line-height:1.5;color:#d46b08;display:flex;gap:6px;align-items:flex-start}
.share-tip .warn-icon{font-size:18px;line-height:1.5;flex-shrink:0}
.share-tip .tip-text{flex:1;min-width:0}
.share-tip strong{font-weight:700}
.warning-box{background:#fff2f0;border:1px solid #ffccc7;border-radius:8px;padding:8px 10px;overflow-x:auto;-webkit-overflow-scrolling:touch}
.warning-item{margin:0;font-size:12px;line-height:1.8;font-weight:700;white-space:nowrap}
.warning-item:nth-child(odd){color:#cf1322}
.warning-item:nth-child(even){color:#d46b08}
.warning-item:last-child{color:#b71c1c;font-size:13px}
.share-qr{display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px}
.share-qr canvas{border-radius:8px}
.share-qr .qr-label{font-size:12px;font-weight:600;color:var(--primary)}
.share-qr .qr-sub{font-size:11px;color:var(--text2)}
.share-disclaimer{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:10px;padding:8px 10px;background:#fdf6ec;border-radius:6px;font-size:12px;color:#d46b08;flex-wrap:wrap}
/* ===== Login Modal ===== */
.login-form{display:flex;flex-direction:column;gap:12px}
.login-form input{height:40px;border:1px solid var(--border);border-radius:8px;padding:0 12px;font-size:14px;outline:none}
.login-form input:focus{border-color:var(--primary)}
.login-form .login-btn{height:40px;border:none;border-radius:8px;background:var(--primary);color:var(--white);font-size:14px;font-weight:600;cursor:pointer;transition:background .2s}
.login-form .login-btn:active{background:var(--primary-dark)}
.login-form .login-btn:disabled{opacity:.5}
.login-form .login-err{font-size:12px;color:#f56c6c;text-align:center}
/* ===== Progress Steps ===== */
.steps{display:flex;flex-direction:column;gap:10px;padding:8px 0}
.step{display:flex;align-items:flex-start;gap:10px;opacity:.4;transition:opacity .3s}
.step.active{opacity:1}
.step.done{opacity:.7}
.step-dot{flex-shrink:0;width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;background:#e4e7ed;color:#909399}
.step.active .step-dot{background:var(--primary);color:#fff;box-shadow:0 0 0 3px rgba(64,158,255,.2)}
.step.done .step-dot{background:#67c23a;color:#fff}
.step-body{flex:1;padding-top:3px;display:flex;align-items:center;gap:8px}
.step-title{font-size:13px;color:var(--text);font-weight:500}
.step-status{font-size:11px;padding:1px 7px;border-radius:10px;white-space:nowrap}
.step-status.doing{background:#ecf5ff;color:var(--primary)}
.step-status.done{background:#f0f9eb;color:#67c23a}
.step-status.wait{background:#f4f4f5;color:#c0c4cc}
.error-alert{padding:12px 16px;background:#fef0f0;border:1px solid #fde2e2;border-radius:8px;display:flex;align-items:center;gap:8px;font-size:14px;color:#f56c6c}
/* ===== User Badge ===== */
.user-badge{font-size:12px;color:var(--primary);font-weight:600;white-space:nowrap}
.login-btn-small{height:30px;padding:0 10px;border:none;border-radius:6px;background:var(--primary-light);color:var(--primary);font-size:12px;font-weight:600;cursor:pointer}
.logout-btn-small{height:30px;padding:0 8px;border:none;border-radius:6px;background:transparent;color:var(--text2);font-size:12px;cursor:pointer}
</style>
</head>
<body>
<div class="app" id="app">
<!-- ===== Home Page (shown when no search) ===== -->
<div id="homePage" class="home-page">
<div class="home-hero">
<div class="home-logo" id="homeLogo" style="display:none"></div>
<div class="home-search-box">
<input id="homeSearchInput" type="text" placeholder="搜索网盘资源..." />
<button id="homeSearchBtn" onclick="homeSearch()">搜 索</button>
</div>
<div class="home-quote" id="homeQuote"></div>
<div class="home-quote-author" id="homeQuoteAuthor"></div>
</div>
<div class="home-rankings" id="homeRankings"></div>
</div>
<!-- ===== Search Results View ===== -->
<div id="searchView" style="display:none">
<!-- Header -->
<div class="header">
<div class="header-row">
<a href="/h5" class="header-title-link"><div class="header-title" id="headerTitle" style="display:none">CloudSearch</div></a>
<div class="search-wrap">
<input id="searchInput" type="text" placeholder="搜索网盘资源..." @keydown="handleKeydown" />
<button id="searchBtn" onclick="doSearch()">搜 索</button>
</div>
<div class="header-actions" id="userArea">
<template id="userLoggedIn">
<span class="user-badge" id="usernameDisplay"></span>
<button class="logout-btn-small" onclick="logout()">退出</button>
</template>
<template id="userLoggedOut">
<button class="login-btn-small" onclick="showLogin()">登录</button>
</template>
</div>
</div>
</div>
<!-- Info Bar -->
<div id="infoBar" class="info-bar" style="display:none">
<span id="infoCount" class="count"></span>
<span id="infoTime" class="time"></span>
<span id="infoFiltered" class="badge-err"></span>
</div>
<!-- Loading -->
<div id="loading" class="loading" style="display:none">
<div id="loadingText">🔍 正在搜索中...</div>
<div class="loading-bar"><div class="loading-bar-inner" id="loadingBar"></div></div>
</div>
<!-- Tabs -->
<div id="tabs" class="tabs" style="display:none"></div>
<!-- Results -->
<div id="results" class="results"></div>
<!-- Overlay -->
<div class="overlay" id="overlay" style="display:none" onclick="closeModal()"></div>
<!-- Share Modal -->
<div class="modal" id="shareModal" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:101">
<div class="modal-hdr" id="shareTitle">分享链接</div>
<div class="modal-body">
<div id="progressSteps" class="steps" style="display:none">
<div class="step" id="step1"><div class="step-dot"><span>1</span></div><div class="step-body"><span class="step-title">正在转存...</span><span class="step-status doing">进行中</span></div></div>
<div class="step" id="step2"><div class="step-dot"><span>2</span></div><div class="step-body"><span class="step-title">重命名文件(防和谐)...</span><span class="step-status wait">等待中</span></div></div>
<div class="step" id="step3"><div class="step-dot"><span>3</span></div><div class="step-body"><span class="step-title">生成分享链接...</span><span class="step-status wait">等待中</span></div></div>
</div>
<div id="saveError" class="error-alert" style="display:none"></div>
<div id="shareContent" style="display:none">
<div class="share-qr">
<div id="qrContainer"></div>
<div class="qr-label" id="qrLabel"></div>
<div class="qr-sub">保存到你自己的网盘</div>
</div>
<div class="share-section">
<div class="share-row">
<input id="shareLinkInput" type="text" readonly />
</div>
<div id="sharePwdRow" class="share-pwd" style="display:none">
<span>🔑 提取密码:</span>
<span class="pwd-tag" id="sharePwdTag"></span>
<span class="pwd-hint">打开链接后需输入密码</span>
</div>
<div class="share-tip">
<span class="warn-icon">⚠️</span>
<div class="tip-text">
<strong>请尽快复制链接到浏览器打开</strong> 或 <strong>用夸克APP扫码</strong><br>
<strong>转存至您的网盘,以免资源被官方和谐</strong>
</div>
</div>
<div class="warning-box">
<p class="warning-item">郑重警告一:网盘内除您所需资源外,不要打开任何不相关内容。</p>
<p class="warning-item">郑重警告二:网盘内除您所需资源外,不要打开任何不相关内容。</p>
<p class="warning-item">郑重警告三:网盘内除您所需资源外,不要打开任何不相关内容。</p>
<p class="warning-item">郑重警告四:以上警告说三遍,你还要明知故犯吗?</p>
</div>
<div class="share-disclaimer">
<span>⚠️ 本站资源仅供学习交流,请于24h内删除</span>
</div>
</div>
</div>
</div>
<div class="modal-ftr">
<button class="btn-disclaimer" onclick="openDisclaimer()">📜 免责声明</button>
<button class="btn-close" onclick="closeModal()">关闭</button>
<button class="btn-primary" id="copyBtn2" onclick="copyShareLink()" style="display:none">一键复制链接</button>
</div>
</div>
<!-- Login Modal -->
<div class="modal" id="loginModal" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:101">
<div class="modal-hdr">登录</div>
<div class="modal-body">
<div class="login-form">
<input id="loginUser" type="text" placeholder="用户名" />
<input id="loginPass" type="password" placeholder="密码" />
<button class="login-btn" id="loginBtn" onclick="handleLogin()">登录</button>
<div class="login-err" id="loginErr"></div>
</div>
</div>
<div class="modal-ftr">
<button class="btn-close" onclick="closeLogin()">取消</button>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast"></div>
<script>
// ===== Anime keywords for categorization =====
const ANIME_KWS=['仙逆','凡人修仙传','斗破苍穹','斗破','盘龙','完美世界','一念永恒','妖神记','星辰变','遮天','神墓','吞噬星空','武动乾坤','大主宰','全职高手','鬼灭之刃','海贼王','火影忍者','死神','龙珠','进击的巨人','咒术回战','一人之下','狐妖小红娘','魔道祖师','天官赐福','时光代理人','大王饶命','斗罗大陆','绝世唐门','不良人','秦时明月','全职法师','牧神记','三体','灵笼','雾山五行','凡人','仙王的日常生活','百妖谱','眷思量','镖人','伍六七','刺客伍六七','葬送的芙莉莲','间谍过家家']
// ===== Quotes =====
const QUOTES=['学而时习之,不亦说乎。','温故而知新,可以为师矣。','三人行,必有我师焉。','学而不思则罔,思而不学则殆。','博学之,审问之,慎思之,明辨之,笃行之。','千里之行,始于足下。','不积跬步,无以至千里。','知之为知之,不知为不知,是知也。','工欲善其事,必先利其器。','玉不琢,不成器;人不学,不知道。','学以致用,知行合一。','学海无涯,勤作舟。','书山有路,勤为径。','宝剑锋从磨砺出,梅花香自苦寒来。','锲而不舍,金石可镂。','业精于勤,荒于嬉。','读书破万卷,下笔如有神。','路漫漫其修远兮,吾将上下而求索。','采菊东篱下,悠然见南山。','海内存知己,天涯若比邻。','长风破浪会有时,直挂云帆济沧海。','会当凌绝顶,一览众山小。','山重水复疑无路,柳暗花明又一村。']
// ===== Home Page =====
function homeSearch(){
const q=document.getElementById('homeSearchInput').value.trim()
if(q)doSearchFromHome(q)
}
function doSearchFromHome(q){
document.getElementById('homePage').style.display='none'
document.getElementById('searchView').style.display='block'
document.getElementById('searchInput').value=q
window.history.replaceState({},'','/h5?q='+encodeURIComponent(q))
doSearch()
}
function renderHomePage(data){
fetch("/api/site-config").then(r=>r.json()).then(cfg=>{
// 显示 Logo(优先图片,其次文字)
var logoEl=document.getElementById("homeLogo");
var headerEl=document.getElementById("headerTitle");
if(cfg.site_logo){
logoEl.innerHTML='<img src="'+cfg.site_logo+'" class="home-logo-img" alt="logo" />';
logoEl.style.display="";
headerEl.innerHTML='<img src="'+cfg.site_logo+'" class="header-logo-img" alt="logo" />';
headerEl.style.display="";
}else if(cfg.site_name){
logoEl.textContent=cfg.site_name;
logoEl.style.display="";
headerEl.textContent=cfg.site_name;
headerEl.style.display="";
}else{
logoEl.textContent="CloudSearch";
logoEl.style.display="";
headerEl.textContent="CloudSearch";
headerEl.style.display="";
}
if(cfg.site_disclaimer){
document.getElementById("footerContent").innerHTML=cfg.site_disclaimer.replace(/\n/g,'<br>');
document.getElementById("siteFooter").style.display="block";
}
}).catch(()=>{})
const categories=data.categories||[]
const fetchedAt=data.fetchedAt||''
// Quote
fetch('https://v1.hitokoto.cn/').then(r=>r.json()).then(d=>{
document.getElementById('homeQuote').textContent='「 '+d.hitokoto+' 」'
document.getElementById('homeQuoteAuthor').textContent='---'+(d.from_who||d.from||'')
}).catch(()=>{
document.getElementById('homeQuote').textContent='「 学而时习之,不亦说乎。 」'
document.getElementById('homeQuoteAuthor').textContent='---孔子'
})
// Store expanded state per category
window.__expanded=window.__expanded||{}
window.__activeTab=window.__activeTab||{}
const el=document.getElementById('homeRankings')
let html=''
for(const cat of categories){
const icons={movie:'🎬',tv:'📺',western_movie:'🎥',western:'🌍',donghua:'🐉',global_anime:'🌐',variety:'🎤',niche:'💎',hotsite:'🏆'}
const icon=icons[cat.category]||'📋'
const key=cat.category
if(!window.__activeTab[key])window.__activeTab[key]='hot'
html+='<div class="rank-block">'
html+='<div class="rank-block-hdr">'+
'<span class="rank-block-title">'+icon+' '+cat.label+'</span>'+
'<div class="rank-block-tabs" id="rtabs-'+key+'">'+
'<span class="rank-tab'+(window.__activeTab[key]==='hot'?' active':'')+'" onclick="switchRankTab(\''+key+'\',\'hot\')">热榜</span>'+
'<span class="rank-tab'+(window.__activeTab[key]==='newest'?' active':'')+'" onclick="switchRankTab(\''+key+'\',\'newest\')">最新</span>'+
'</div>'+
'</div>'
html+='<div class="rank-block-items" id="ritems-'+key+'" data-hot=\''+JSON.stringify({items:cat.hot||[]}).replace(/'/g,"&#39;")+'\' data-newest=\''+JSON.stringify({items:cat.newest||[]}).replace(/'/g,"&#39;")+'\'>'
const items=window.__activeTab[key]==='hot'?(cat.hot||[]):(cat.newest||[])
html+=renderRankItems(items,key,false)
html+='</div>'
// 数据来源
html+='<div class="rank-block-ftr">'+
'<span>'+(cat.category!=='hotsite'?'数据来源:TMDB':'本站搜索数据')+'</span>'+
'<span class="ftr-time">'+fetchedAt+'</span>'+
'</div></div>'
}
el.innerHTML=html
}
function renderRankItems(items,key,expanded){
if(!items||items.length===0)return'<div style="padding:10px;text-align:center;color:#c0c4cc;font-size:12px">暂无数据</div>'
const limit=3
const show=expanded?items.length:Math.min(limit,items.length)
let html=items.slice(0,show).map((item,i)=>{
const c=i<3?' rank-idx top3':' rank-idx'
return '<div class="rank-item" onclick="doSearchFromHome(\''+item.keyword.replace(/'/g,"\\'")+'\')">'+
'<span class="'+c+'">'+(i+1)+'</span>'+
'<span class="rank-name">'+item.keyword+'</span>'+
'<span class="rank-cnt">'+(item.rating?'⭐'+item.rating:item.searchCount)+'</span>'+
'</div>'
}).join('')
if(items.length>limit&&!expanded){
html+='<div class="rank-expand" onclick="expandRank(\''+key+'\')">展开全部 ▼</div>'
}
return html
}
function expandRank(key){
const container=document.getElementById('ritems-'+key)
if(!container)return
const tab=window.__activeTab[key]||'hot'
const data=JSON.parse(tab==='hot'?container.dataset.hot:container.dataset.newest)
container.innerHTML=renderRankItems(data.items,key,true)
}
function switchRankTab(category,tab){
window.__activeTab[category]=tab
const tabsContainer=document.getElementById('rtabs-'+category)
if(tabsContainer){
tabsContainer.querySelectorAll('.rank-tab').forEach(t=>t.className='rank-tab')
tabsContainer.querySelector(tab==='hot'?'.rank-tab:first-child':'.rank-tab:last-child').className='rank-tab active'
}
const container=document.getElementById('ritems-'+category)
if(container){
const data=JSON.parse(tab==='hot'?container.dataset.hot:container.dataset.newest)
container.innerHTML=renderRankItems(data.items,category,false)
}
}
let userInfo = null
let allResults = []
let allChannels = []
let activeTab = ''
let currentSaveItem = null
const CLOUD_ICONS = {quark:'☁️',baidu:'🔵',aliyun:'🟠','115':'🟣',tianyi:'🔷','123pan':'🔴',uc:'🟡',xunlei:'🟢',pikpak:'🟤',magnet:'🧲',ed2k:'🔗',others:'📁'}
const CLOUD_LABELS = {quark:'夸克网盘',baidu:'百度网盘',aliyun:'阿里云盘','115':'115网盘',tianyi:'天翼云盘','123pan':'123云盘',uc:'UC网盘',xunlei:'迅雷云盘',pikpak:'PikPak',magnet:'磁力链接',ed2k:'电驴链接',others:'其他'}
const CLOUD_COLORS = {quark:'#07c160',baidu:'#4e6ef2',aliyun:'#ff6a00','115':'#9b59b6',tianyi:'#00a1d6','123pan':'#e74c3c',uc:'#f39c12',xunlei:'#2ecc71',pikpak:'#8e44ad',magnet:'#95a5a6',ed2k:'#7f8c8d',others:'#95a5a6'}
const CLOUD_ORDER = {quark:1,baidu:2,aliyun:3,'115':4,tianyi:5,'123pan':6,uc:7,xunlei:8,pikpak:9,magnet:10,ed2k:11,others:12}
// ===== Fetch helpers =====
function getToken(){return localStorage.getItem('h5_admin_token')}
function apiHeaders(){const h={'Content-Type':'application/json'};const t=getToken();if(t)h['Authorization']='Bearer '+t;return h}
// ===== Toast =====
let toastTimer
function showToast(msg,isError){
const el=document.getElementById('toast')
el.textContent=msg
el.className='toast show'+(isError?' error':'')
clearTimeout(toastTimer)
toastTimer=setTimeout(()=>el.className='toast',2000)
}
// ===== User =====
async function checkLogin(){
try{
const res=await fetch('/api/me',{headers:apiHeaders()})
if(res.ok){
const data=await res.json()
if(data.loggedIn){
userInfo=data
document.getElementById('userArea').innerHTML='<span class="user-badge">'+data.username+'</span><button class="logout-btn-small" onclick="logout()">退出</button>'
}
}
}catch(e){}
}
function logout(){
localStorage.removeItem('h5_admin_token')
userInfo=null
document.getElementById('userArea').innerHTML='<button class="login-btn-small" onclick="showLogin()">登录</button>'
showToast('已退出')
}
function showLogin(){
document.getElementById('loginErr').textContent=''
document.getElementById('loginUser').value=''
document.getElementById('loginPass').value=''
document.getElementById('loginModal').style.display='block'
document.getElementById('overlay').style.display='block'
}
function closeLogin(){
document.getElementById('loginModal').style.display='none'
document.getElementById('overlay').style.display='none'
}
async function handleLogin(){
const user=document.getElementById('loginUser').value.trim()
const pass=document.getElementById('loginPass').value
if(!user||!pass){showToast('请输入用户名和密码',true);return}
const btn=document.getElementById('loginBtn')
btn.disabled=true;btn.textContent='登录中...'
try{
const res=await fetch('/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:user,password:pass})})
if(res.ok){
const data=await res.json()
localStorage.setItem('h5_admin_token',data.token)
userInfo={username:user}
document.getElementById('userArea').innerHTML='<span class="user-badge">'+user+'</span><button class="logout-btn-small" onclick="logout()">退出</button>'
closeLogin()
showToast('登录成功')
}else{
const err=await res.json().catch(()=>({}))
document.getElementById('loginErr').textContent=err.error||'登录失败'
}
}catch(e){
document.getElementById('loginErr').textContent='网络错误'
}finally{
btn.disabled=false;btn.textContent='登录'
}
}
// ===== Search =====
function handleKeydown(e){if(e.key==='Enter')doSearch()}
let searchTimer
function doSearch(){
const q=document.getElementById('searchInput').value.trim()
if(!q)return
// Update URL
window.history.replaceState({},'','/h5?q='+encodeURIComponent(q))
// Show loading
document.getElementById('results').innerHTML=''
document.getElementById('tabs').style.display='none'
document.getElementById('infoBar').style.display='none'
document.getElementById('loading').style.display='block'
document.getElementById('loadingText').textContent='🔍 正在搜索中...'
document.getElementById('searchBtn').disabled=true
let progress=0
const bar=document.getElementById('loadingBar')
const progressTimer=setInterval(()=>{
if(progress<60)progress+=1+Math.random()*3
else if(progress<85)progress+=0.5+Math.random()
bar.style.width=progress+'%'
},200)
// Use streaming search for live updates
streamSearch(q,progressTimer,bar)
}
async function streamSearch(q,progressTimer,bar){
const startTime=Date.now()
try{
const response=await fetch('/api/query',{method:'POST',headers:apiHeaders(),body:JSON.stringify({q})})
if(!response.ok)throw new Error('搜索失败 ('+response.status+')')
const reader=response.body.getReader()
const decoder=new TextDecoder()
let buffer=''
let allItems=[]
let channels=[]
let totalCount=0
let filteredCount=0
while(true){
const {done,value}=await reader.read()
if(done)break
buffer+=decoder.decode(value,{stream:true})
const lines=buffer.split('\n')
buffer=lines.pop()||''
for(const line of lines){
if(!line.trim())continue
try{
const msg=JSON.parse(line)
if(msg.type==='stats'){
totalCount=msg.total||0
filteredCount=msg.filtered||0
document.getElementById('loadingText').textContent='🔍 搜索到 '+totalCount+' 条,正在验证...'
}else if(msg.type==='result'){
if(msg.valid&&msg.id){
allItems.push(msg.id)
}
}else if(msg.type==='complete'){
const results=msg.results||[]
channels=msg.channels||[]
clearInterval(progressTimer)
bar.style.width='100%'
setTimeout(()=>renderResults(results,channels,totalCount,filteredCount,Date.now()-startTime),300)
return
}
}catch(e){}
}
}
}catch(e){
clearInterval(progressTimer)
document.getElementById('loading').style.display='none'
document.getElementById('searchBtn').disabled=false
document.getElementById('results').innerHTML='<div class="empty">搜索失败:'+e.message+'</div>'
}
}
// ===== Render =====
function renderResults(results,channels,totalCount,filteredCount,time){
document.getElementById('loading').style.display='none'
document.getElementById('searchBtn').disabled=false
allResults=results
allChannels=channels||[]
// Info bar
if(totalCount>0){
document.getElementById('infoBar').style.display='flex'
document.getElementById('infoCount').textContent='已为您挑选到最符合 '+totalCount+' 条结果'
document.getElementById('infoTime').textContent='⏱ '+time+'ms'
if(filteredCount>0)document.getElementById('infoFiltered').textContent='❌ 失效 '+filteredCount
else document.getElementById('infoFiltered').textContent=''
}else{
document.getElementById('infoBar').style.display='none'
}
// Build tabs
const tabsEl=document.getElementById('tabs')
tabsEl.innerHTML=''
const typeCounts={}
for(const r of results){const ct=r.cloud_type||'others';typeCounts[ct]=(typeCounts[ct]||0)+1}
const sorted=Object.keys(typeCounts).sort((a,b)=>(CLOUD_ORDER[a]||99)-(CLOUD_ORDER[b]||99))
// "全部" tab
const allTab=document.createElement('div')
allTab.className='tab active'
allTab.textContent='📋 全部 ('+results.length+')'
allTab.onclick=()=>{setActiveTab('');renderCardList(results)}
tabsEl.appendChild(allTab)
for(const ct of sorted){
const tab=document.createElement('div')
tab.className='tab'
tab.textContent=(CLOUD_ICONS[ct]||'📁')+' '+(CLOUD_LABELS[ct]||ct)+' ('+typeCounts[ct]+')'
tab.onclick=()=>{setActiveTab(ct);renderCardList(results.filter(r=>(r.cloud_type||'others')===ct))}
tabsEl.appendChild(tab)
}
tabsEl.style.display=results.length>0?'flex':'none'
activeTab=''
// Render cards
renderCardList(results)
}
function setActiveTab(ct){
activeTab=ct
document.querySelectorAll('.tab').forEach((t,i)=>{
const isAll=i===0&&!ct
const active=i>0&&ct&&t.textContent.includes(CLOUD_LABELS[ct])
t.className='tab'+(active||isAll?' active':'')
})
}
function renderCardList(items){
const el=document.getElementById('results')
if(items.length===0){
el.innerHTML='<div class="empty">暂无结果</div>'
return
}
el.innerHTML=items.map((item,idx)=>{
const coverHtml=item.cover
? '<img src="'+escapeHtml(item.cover)+'" alt="" onerror="this.parentElement.innerHTML=\'<div class=placeholder>'+escapeHtml(CLOUD_ICONS[item.cloud_type||'others'])+'</div>\'" loading="lazy" />'
: '<div class="placeholder">'+escapeHtml(CLOUD_ICONS[item.cloud_type||'others'])+'</div>'
const cloudLabel=CLOUD_LABELS[item.cloud_type]||item.cloud_type||''
const cloudColor=CLOUD_COLORS[item.cloud_type]||'#95a5a6'
const tags=extractTags(item.title||'')
const cleanTitle=(item.title||'').replace(/【[^】]+】/g,'').trim()
const relativeTime=formatTime(item.update_time||item.datetime||'')
return '<div class="card" onclick="saveItem('+idx+')">'+
'<div class="card-cover">'+coverHtml+'<span class="tag" style="background:'+cloudColor+'">'+cloudLabel+'</span></div>'+
'<div class="card-body">'+
'<div class="card-title">'+escapeHtml(cleanTitle)+'</div>'+
'<div class="card-meta"><span>🕐 '+relativeTime+'</span>'+(item.file_size?'<span class="size">📦 '+escapeHtml(item.file_size)+'</span>':'')+'</div>'+
(tags.length>0?'<div class="card-tags">'+tags.map(t=>'<span'+(isQualityTag(t)?' class="quality"':'')+'>'+escapeHtml(t)+'</span>').join('')+'</div>':'')+
'<div class="card-actions">'+
'<span class="card-source">'+(item.source?escapeHtml(item.source):'网盘')+'</span>'+
'<button class="card-btn" onclick="event.stopPropagation();saveItem('+idx+')">🔗 获取分享链接</button>'+
'</div>'+
'</div>'+
'</div>'
}).join('')
// Store items for save reference
window.__h5Results=items
}
function escapeHtml(s){if(!s)return '';return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
function extractTags(title){
const tags=[]
// Quality tags
const quality=['4K','1080P','2160P','720P','480P','HDR','HDR10','BluRay','REMUX','HEVC','x264','x265','WEB-DL','WEBRip']
for(const q of quality){if(title.includes(q)&&!tags.includes(q))tags.push(q)}
const kw=['杜比视界','杜比全景声','高码率','内封简繁英字幕','内嵌字幕','中文字幕','中英字幕']
for(const k of kw){if(title.includes(k)&&!tags.includes(k))tags.push(k)}
return tags.slice(0,6)
}
function isQualityTag(t){const q=['4K','1080P','2160P','720P','480P','HDR','HDR10','BluRay','REMUX','HEVC','x264','x265','臻彩','高清','WEB-DL','WEBRip'];return q.includes(t)}
function formatTime(s){
if(!s)return ''
const d=new Date(s)
if(isNaN(d.getTime()))return s.slice(0,10)
const diff=Date.now()-d.getTime()
if(diff<0)return s.slice(0,10)
const mins=Math.floor(diff/60000)
if(mins<60)return mins<=1?'刚刚':mins+' 分钟前'
const hours=Math.floor(mins/60)
if(hours<24)return hours+' 小时前'
const days=Math.floor(hours/24)
if(days<30)return days+' 天前'
return Math.floor(days/30)+' 个月前'
}
// ===== Save / Share =====
function saveItem(idx){
const items=window.__h5Results||[]
currentSaveItem=items[idx]
if(!currentSaveItem)return
document.getElementById('progressSteps').style.display='block'
document.getElementById('shareContent').style.display='none'
document.getElementById('saveError').style.display='none'
document.getElementById('copyBtn2').style.display='none'
const title=(currentSaveItem.title||'').replace(/【[^】]+】/g,'').trim()||'资源'
document.getElementById('shareTitle').textContent=title
// Show modal
document.getElementById('overlay').style.display='block'
document.getElementById('shareModal').style.display='block'
// Reset steps
resetSteps()
advanceStep(1)
// Call save API
doSave()
}
async function doSave(){
try{
const res=await fetch('/api/save',{method:'POST',headers:apiHeaders(),body:JSON.stringify({type:'search',source:currentSaveItem,target_cloud:currentSaveItem.cloud_type||'quark'})})
const data=await res.json()
if(!data.success){
document.getElementById('progressSteps').style.display='none'
document.getElementById('saveError').style.display='flex'
document.getElementById('saveError').textContent=data.message||data.error||'保存失败'
return
}
// Step 2
advanceStep(2)
await sleep(500)
// Step 3
advanceStep(3)
await sleep(300)
if(data.share_url){
advanceStep(4)
await sleep(200)
showShareResult(data)
}else{
advanceStep(4)
document.getElementById('progressSteps').style.display='none'
document.getElementById('saveError').style.display='flex'
document.getElementById('saveError').textContent='生成分享链接失败'
}
}catch(e){
document.getElementById('progressSteps').style.display='none'
document.getElementById('saveError').style.display='flex'
document.getElementById('saveError').textContent=e.message||'保存请求失败'
}
}
function showShareResult(data){
document.getElementById('progressSteps').style.display='none'
document.getElementById('shareContent').style.display='block'
const link=data.share_url
document.getElementById('shareLinkInput').value=link
const diskLabel=CLOUD_LABELS[currentSaveItem.cloud_type]||'夸克网盘'
document.getElementById('qrLabel').textContent=diskLabel+' APP扫码转存'
// Generate QR
const qrContainer=document.getElementById('qrContainer')
qrContainer.innerHTML=''
new QRCode(qrContainer,{text:link,width:140,height:140})
// Password
const pwd=data.share_pwd||data.sharePwd||''
if(pwd){
document.getElementById('sharePwdRow').style.display='flex'
document.getElementById('sharePwdTag').textContent=pwd
}else{
document.getElementById('sharePwdRow').style.display='none'
}
document.getElementById('copyBtn2').style.display='inline-block'
}
function resetSteps(){
for(let i=1;i<=3;i++){
const el=document.getElementById('step'+i)
el.className='step'
el.querySelector('.step-dot').innerHTML='<span>'+i+'</span>'
el.querySelector('.step-status').textContent='等待中'
el.querySelector('.step-status').className='step-status wait'
}
}
function advanceStep(n){
for(let i=1;i<=3;i++){
const el=document.getElementById('step'+i)
if(i<n){
el.className='step done'
el.querySelector('.step-dot').innerHTML='<span class="step-check">✓</span>'
el.querySelector('.step-status').textContent='已完成'
el.querySelector('.step-status').className='step-status done'
}else if(i===n){
el.className='step active'
el.querySelector('.step-dot').innerHTML='<span>'+i+'</span>'
const titles=['正在转存到','正在重命名文件(防和谐)','正在生成分享链接']
el.querySelector('.step-title').textContent=titles[i-1]+'...'
el.querySelector('.step-status').textContent='进行中'
el.querySelector('.step-status').className='step-status doing'
}
}
}
function sleep(ms){return new Promise(r=>setTimeout(r,ms))}
function copyShareLink(){
const input=document.getElementById('shareLinkInput')
if(!input.value)return
if(navigator.clipboard&&navigator.clipboard.writeText){
navigator.clipboard.writeText(input.value).then(()=>showToast('链接已复制')).catch(()=>fallbackCopy(input.value))
}else{
fallbackCopy(input.value)
}
}
function fallbackCopy(text){
const ta=document.createElement('textarea')
ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';document.body.appendChild(ta)
ta.select()
try{document.execCommand('copy');showToast('链接已复制')}catch{showToast('复制失败',true)}
document.body.removeChild(ta)
}
function openDisclaimer(){
window.open('/disclaimer/','_blank')
}
function closeModal(){
document.getElementById('overlay').style.display='none'
document.getElementById('shareModal').style.display='none'
document.getElementById('loginModal').style.display='none'
}
// ===== Init =====
checkLogin()
// Add Enter key handler for home search
document.getElementById('homeSearchInput').addEventListener('keydown',function(e){if(e.key==='Enter')homeSearch()})
// Also add for search view input
document.getElementById('searchInput').addEventListener('keydown',function(e){if(e.key==='Enter')doSearch()})
// Fetch home page data
fetch('/api/rankings/categorized').then(r=>r.json()).then(data=>{
renderHomePage(data)
}).catch(()=>{
document.getElementById('homeQuote').textContent='「 学而时习之,不亦说乎。 」'
document.getElementById('homeQuoteAuthor').textContent='---孔子'
})
// Check URL for query
const params=new URLSearchParams(window.location.search)
const q=params.get('q')
if(q){
document.getElementById('homePage').style.display='none'
document.getElementById('searchView').style.display='block'
document.getElementById('searchInput').value=q
doSearch()
}
</script>
</body>
<!-- Footer -->
<div id="siteFooter" class="site-footer" style="display:none">
<div id="footerContent" class="footer-inner"></div>
<div class="footer-actions" id="footerActions">
<button class="footer-btn" onclick="openDisclaimer()">📜 免责声明</button>
</div>
</div>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

+193
View File
@@ -0,0 +1,193 @@
<template>
<router-view />
<button class="theme-toggle" @click="toggleTheme" :title="isDark ? '切换亮色' : '切换暗色'">
{{ isDark ? '☀️' : '🌙' }}
</button>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const isDark = ref(false)
function toggleTheme() {
isDark.value = !isDark.value
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : '')
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
}
onMounted(() => {
const saved = localStorage.getItem('theme')
if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
isDark.value = true
document.documentElement.setAttribute('data-theme', 'dark')
}
})
</script>
<style>
/* =============================================
CloudSearch Design System — Global Tokens
============================================= */
:root {
/* ── Backgrounds ── */
--bg: #f0f2f5;
--bg-card: #ffffff;
--bg-input: #f5f7fa;
--bg-page: #f0f2f5;
--bg-card-header: linear-gradient(135deg, #f8f9fc 0%, #eef1f8 100%);
/* ── Text ── */
--text: #1d2129;
--text-secondary: #4e5969;
--text-tertiary: #86909c;
--text-placeholder: #c9cdd4;
/* ── Borders ── */
--border: #e5e6eb;
--border-light: #f2f3f5;
/* ── Primary ── */
--primary: #409eff;
--primary-hover: #66b1ff;
--primary-light: rgba(64, 158, 255, 0.08);
--primary-soft: #ecf5ff;
/* ── Shadows ── */
--shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.08);
/* ── Radius ── */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
/* ── Sizing ── */
--sidebar-w: 240px;
}
[data-theme="dark"] {
--bg: #141414;
--bg-card: #1d1d1d;
--bg-input: #2a2a2a;
--bg-page: #0f0f0f;
--bg-card-header: linear-gradient(135deg, #1d1d1d 0%, #1a1a1a 100%);
--text: #e5e5e5;
--text-secondary: #999999;
--text-tertiary: #666666;
--text-placeholder: #555555;
--border: #333333;
--border-light: #2a2a2a;
--primary: #409eff;
--primary-hover: #66b1ff;
--primary-light: rgba(64, 158, 255, 0.15);
--primary-soft: rgba(64, 158, 255, 0.1);
--shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4);
}
[data-theme="dark"] body { background: var(--bg); color: var(--text); }
[data-theme="dark"] .el-card,
[data-theme="dark"] .el-dialog,
[data-theme="dark"] .el-menu,
[data-theme="dark"] .el-table,
[data-theme="dark"] .el-select-dropdown,
[data-theme="dark"] .el-popover {
--el-bg-color: var(--bg-card) !important;
--el-border-color: var(--border) !important;
--el-text-color-primary: var(--text) !important;
}
[data-theme="dark"] .el-dialog__body,
[data-theme="dark"] .el-table__body td {
background-color: var(--bg-card) !important;
}
[data-theme="dark"] .el-table__header th {
background-color: #262626 !important;
}
[data-theme="dark"] .el-input__wrapper,
[data-theme="dark"] .el-select .el-input__wrapper {
background-color: var(--bg-input) !important;
border-color: var(--border) !important;
box-shadow: 0 0 0 1px var(--border) inset !important;
}
[data-theme="dark"] .el-input__inner,
[data-theme="dark"] .el-textarea__inner {
background-color: var(--bg-input) !important;
color: var(--text) !important;
}
[data-theme="dark"] .el-button--default {
background: var(--bg-card);
border-color: var(--border);
color: var(--text);
}
.theme-toggle {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 99;
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--bg-card);
cursor: pointer;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-md);
transition: all 0.3s;
}
.theme-toggle:hover { transform: scale(1.1); border-color: var(--primary); }
#app { min-height: 100vh; background: var(--bg-page); color: var(--text); }
/* ── Global element-plus overrides ── */
.el-card {
border-radius: var(--radius-lg) !important;
border: 1px solid var(--border) !important;
box-shadow: var(--shadow-sm) !important;
transition: box-shadow 0.2s ease;
}
.el-card:hover {
box-shadow: var(--shadow-md) !important;
}
.el-card__header {
padding: 16px 20px !important;
border-bottom: 1px solid var(--border-light) !important;
font-weight: 600;
font-size: 14px;
background: var(--bg-card-header);
}
.el-card__body {
padding: 20px !important;
}
/* ── Typography helpers ── */
.form-tip {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.6;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: var(--text);
}
.section-divider {
border-top: 1px solid var(--border-light);
margin: 20px 0;
}
/* ── Fade transition ── */
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
+473
View File
@@ -0,0 +1,473 @@
import axios from 'axios'
import type {
SearchResponse,
VideoParseResult,
SaveResult,
QueryResponse,
RankingItem,
Promotion,
CloudConfig,
StatsData,
} from '../types'
const api = axios.create({
baseURL: '/api',
timeout: 30000,
})
// 请求拦截器 — 添加管理员 Token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('admin_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器 — 统一错误处理
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('admin_token')
// Don't redirect if already on the login page or if this was a login attempt itself
if (!window.location.pathname.startsWith('/admin/login') && !err.config?.url?.includes('/admin/login')) {
window.location.href = '/admin/login'
}
}
return Promise.reject(err)
}
)
// ===== 搜索与解析 =====
export async function query(q: string, page = 1): Promise<QueryResponse> {
const { data } = await api.post<QueryResponse>('/query', { q, page })
return data
}
/**
* 流式搜索 — 使用 NDJSON stream,逐条返回验证结果
* callback 接收五种事件:
* onSearching() - 搜索开始(立即返回)
* onSaved({results, total}) - 本地已保存资源(DB缓存,即时返回)
* onStats({total, channels, content_info, content_tags}) - 统计信息
* onResult(id, valid, message) - 单条链接验证结果
* onComplete({results, channels, total, filtered}) - 全部完成
*/
export async function streamSearch(
q: string,
callbacks: {
onSearching?: () => void
onSaved?: (data: { results: any[]; total: number }) => void
onStats: (stats: any) => void
onResult: (id: string, valid: boolean, message?: string) => void
onComplete: (data: any) => void
onError?: (err: any) => void
}
): Promise<void> {
const token = localStorage.getItem('admin_token')
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
try {
const response = await fetch('/api/query', {
method: 'POST',
headers,
body: JSON.stringify({ q }),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.trim()) continue
try {
const msg = JSON.parse(line)
switch (msg.type) {
case 'searching':
callbacks.onSearching?.()
break
case 'saved':
callbacks.onSaved?.(msg)
break
case 'stats':
callbacks.onStats(msg)
break
case 'result':
callbacks.onResult(msg.id, msg.valid, msg.message)
break
case 'complete':
callbacks.onComplete(msg)
break
}
} catch {
// skip malformed lines
}
}
}
} catch (err: any) {
callbacks.onError?.(err)
}
}
export async function searchPanSou(
kw: string,
page = 1,
pageSize = 20
): Promise<SearchResponse> {
const { data } = await api.get<SearchResponse>('/search', {
params: { kw, page, page_size: pageSize },
})
return data
}
export async function parseVideo(url: string): Promise<VideoParseResult> {
const { data } = await api.post<VideoParseResult>('/video/parse', { url })
return data
}
// ===== 保存与分享 =====
export async function saveToCloud(params: {
type: 'search' | 'video'
source: any
target_cloud: string
}): Promise<SaveResult> {
const { data } = await api.post<SaveResult>('/save', params)
return data
}
export async function saveVideoToCloud(params: {
video_url: string
title: string
target_cloud: string
}): Promise<SaveResult> {
const { data } = await api.post<SaveResult>('/video/save-to-cloud', params)
return data
}
// ===== 排行榜 =====
export async function getRankings(): Promise<RankingItem[]> {
const { data } = await api.get<RankingItem[]>('/rankings')
return data
}
export async function getHotKeywords(): Promise<string[]> {
const { data } = await api.get<string[]>('/rankings/hot')
return data
}
export async function getCategorizedRankings(): Promise<any> {
const { data } = await api.get<any>('/rankings/categorized')
return data
}
// ===== 管理员 =====
export async function adminLogin(
username: string,
password: string
): Promise<{ token: string }> {
const { data } = await api.post('/admin/login', { username, password })
return data
}
export async function getMe(): Promise<{ loggedIn: boolean; id?: number; username?: string }> {
const { data } = await api.get('/me')
return data
}
export async function getCloudConfigs(): Promise<CloudConfig[]> {
const { data } = await api.get('/admin/cloud-configs')
return data
}
export async function saveCloudConfig(
config: CloudConfig & { cookie?: string }
): Promise<CloudConfig> {
const { data } = await api.post('/admin/cloud-configs', config)
return data
}
export async function updateCloudConfig(
config: CloudConfig & { cookie?: string }
): Promise<CloudConfig> {
const { data } = await api.put(`/admin/cloud-configs/${config.id}`, config)
return data
}
export async function testCloudConnection(
cloudType: string,
cookie?: string,
id?: number
): Promise<{
success: boolean
message: string
nickname?: string
storage_used?: string
storage_total?: string
}> {
const { data } = await api.post(`/admin/cloud-configs/${cloudType}/test`, { cookie, id })
return data
}
export async function dailyCheckIn(
id: number
): Promise<{
success: boolean
message: string
signedDays?: number
}> {
const { data } = await api.post(`/admin/cloud-configs/${id}/checkin`)
return data
}
export async function skipCheckin(id: number): Promise<boolean> {
const { data } = await api.post(`/admin/cloud-configs/${id}/skip-checkin`)
return data.success
}
export async function checkinAll(): Promise<{
total: number
results: { id: number; nickname: string; success: boolean; message: string }[]
}> {
const { data } = await api.post('/admin/cloud-configs/checkin-all')
return data
}
export async function checkinSummary(): Promise<{
total: number
success: number
failed: number
pending: number
skipped: number
}> {
const { data } = await api.get('/admin/cloud-configs/checkin-summary')
return data
}
export async function deleteCloudConfig(
id: number
): Promise<void> {
await api.delete(`/admin/cloud-configs/${id}`)
}
export async function setPrimary(
id: number,
primary: boolean
): Promise<any> {
const { data } = await api.put(`/admin/cloud-configs/${id}/primary`, { primary })
return data
}
export async function getStats(days?: number): Promise<StatsData> {
const params: Record<string, number> = {}
if (days) params.days = days
const { data } = await api.get('/admin/stats', { params })
return data
}
// ===== 转存日志 =====
export async function getSaveRecords(page = 1, pageSize = 20, startDate?: string, endDate?: string, status?: string, cloud?: string, keyword?: string): Promise<{
total: number
records: SaveRecord[]
summary?: { total: number; success: number; failed: number; reused: number }
}> {
const params: Record<string, number | string> = { page, pageSize }
if (startDate) params.startDate = startDate
if (endDate) params.endDate = endDate
if (status) params.status = status
if (cloud) params.sourceType = cloud
if (keyword) params.keyword = keyword
const { data } = await api.get('/admin/save-records', { params })
return data
}
export interface SaveRecord {
id: number
source_type: string
source_title: string | null
source_url: string
target_cloud: string
share_url: string | null
share_pwd: string | null
file_size: string | null
file_count: number
duration_ms: number
status: string
error_message: string | null
folder_name: string | null
folder_count: number
original_folder_name: string | null
ip_address: string | null
ip_location: string | null
created_at: string
}
// ===== 系统配置 =====
/** Save/update per-config notification settings */
export async function saveConfigNotify(
configId: number,
settings: Record<string, any>
): Promise<{ success: boolean; message: string }> {
const { data } = await api.put(`/admin/cloud-configs/${configId}/notify`, settings)
return data
}
/** Get per-config notification settings */
export async function getConfigNotify(
configId: number
): Promise<Record<string, any>> {
const { data } = await api.get(`/admin/cloud-configs/${configId}/notify`)
return data
}
/** Test a notification channel (global or per-config) */
export async function getAllNotifierProviders(): Promise<Record<string, { name: string; label: string; params: { key: string; label: string; type: string; required: boolean; placeholder?: string; default?: any }[] }>> {
const { data } = await api.get('/admin/notify/providers')
return data
}
export async function testNotifyChannel(
channelType: string,
configId?: number
): Promise<{ success: boolean; message: string }> {
const { data } = await api.post('/admin/notify/test', { channelType, configId })
return data
}
export async function getSystemConfigs(): Promise<{ key: string; value: string; description: string }[]> {
const { data } = await api.get('/admin/system-configs')
return data
}
export async function updateSystemConfigs(
entries: { key: string; value: string }[]
): Promise<void> {
await api.put('/admin/system-configs', { entries })
}
// ===== 网盘类型开关 =====
export async function getCloudTypes(): Promise<{ types: { type: string; label: string; icon: string; enabled: boolean }[] }> {
const { data } = await api.get('/admin/cloud-types')
return data
}
export async function toggleCloudType(type: string, enabled: boolean): Promise<void> {
await api.put('/admin/cloud-types', { type, enabled })
}
// ===== 修改密码 =====
export async function changePassword(
oldPassword: string,
newPassword: string
): Promise<{ success: boolean; message: string }> {
const { data } = await api.post('/admin/change-password', { oldPassword, newPassword })
return data
}
export default api
export { query as searchQuery }
// ===== 系统设置(SettingsManage.vue) =====
export async function getSettings(): Promise<any[]> {
const { data } = await api.get('/admin/system-configs')
return data
}
export async function updateSetting(key: string, value: string): Promise<void> {
await api.put('/admin/system-configs', { entries: [{ key, value }] })
}
export async function uploadFallbackImage(file: File): Promise<{ success: boolean; url: string; message: string }> {
const form = new FormData()
form.append('image', file)
const { data } = await api.post('/admin/upload-fallback-image', form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return data
}
export async function uploadLogo(file: File): Promise<{ success: boolean; url: string; message: string }> {
const form = new FormData()
form.append('image', file)
const { data } = await api.post('/admin/upload-logo', form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return data
}
export async function getSiteConfig(): Promise<{ site_logo: string; site_name: string; search_fallback_image: string; site_disclaimer: string }> {
const { data } = await api.get('/site-config')
return data
}
// ===== Redis 连接测试 =====
export async function testRedisConnection(url: string): Promise<{ ok: boolean; latency: number; info: string }> {
const { data } = await api.post('/admin/test-redis', { url })
return data
}
// ===== 外部服务连接测试 =====
export async function testExternalService(params: {
type: 'pansou' | 'video_parser' | 'tmdb' | 'proxy' | 'ip_geo'
url?: string
token?: string
}): Promise<{ ok: boolean; latency: number; info: string }> {
const { data } = await api.post('/admin/test-external-service', params)
return data
}
// ===== 数据库状态 =====
export async function getDbStatus(): Promise<{
db_size: string
db_path: string
save_records: number
search_stats: number
system_configs: number
cloud_configs: number
content_cache: number
redis_status: string
redis_url: string
}> {
const { data } = await api.get('/admin/db-status')
return data
}
// ===== 存储清理 =====
export async function runCleanup(): Promise<{
success: boolean
files_trashed: number
logs_deleted: number
trash_emptied: boolean
errors: string[]
message: string
}> {
const { data } = await api.post('/admin/cleanup/run')
return data
}
export async function emptyAllTrash(): Promise<{
success: boolean
emptied: boolean
errors: string[]
message: string
}> {
const { data } = await api.post('/admin/cleanup/empty-trash')
return data
}
@@ -0,0 +1 @@
// This will be done in chunks via multiple commands to avoid escaping issues
+35
View File
@@ -0,0 +1,35 @@
<template>
<span class="cloud-badge" :style="{ background: CLOUD_COLORS[cloud_type] }">
<img v-if="showIcon && CLOUD_ICONS[cloud_type]" :src="CLOUD_ICONS[cloud_type]" class="badge-icon" />
{{ CLOUD_LABELS[cloud_type] }}
</span>
</template>
<script setup lang="ts">
import { CLOUD_LABELS, CLOUD_COLORS, CLOUD_ICONS } from '../types'
import type { CloudType } from '../types'
defineProps<{
cloud_type: CloudType
showIcon?: boolean
}>()
</script>
<style scoped>
.cloud-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
color: #fff;
font-size: 12px;
line-height: 1.5;
white-space: nowrap;
}
.badge-icon {
width: 14px;
height: 14px;
object-fit: contain;
}
</style>
+50
View File
@@ -0,0 +1,50 @@
<template>
<div class="cloud-select">
<el-select v-model="selectedCloud" placeholder="选择目标网盘" @change="handleSelect">
<el-option
v-for="item in cloudList"
:key="item.cloud_type"
:label="item.nickname || CLOUD_LABELS[item.cloud_type]"
:value="item.cloud_type"
/>
</el-select>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { CLOUD_LABELS } from '../types'
import type { CloudType, CloudConfig } from '../types'
import { getCloudConfigs } from '../api'
const props = defineProps<{
modelValue?: CloudType
}>()
const emit = defineEmits<{
select: [cloudType: CloudType]
'update:modelValue': [cloudType: CloudType]
}>()
const selectedCloud = ref<CloudType | undefined>(props.modelValue)
const cloudList = ref<CloudConfig[]>([])
onMounted(async () => {
try {
cloudList.value = await getCloudConfigs()
} catch (e) {
console.error('获取网盘配置失败', e)
}
})
function handleSelect(value: CloudType) {
emit('select', value)
emit('update:modelValue', value)
}
</script>
<style scoped>
.cloud-select {
width: 100%;
}
</style>
+87
View File
@@ -0,0 +1,87 @@
<template>
<div class="ranking-card" @click="handleClick">
<span class="rank-num" :class="{ 'top-three': index < 3 }">{{ index + 1 }}</span>
<img v-if="data.cover" :src="data.cover" class="rank-cover" />
<div class="rank-info">
<span class="rank-title">{{ data.title }}</span>
<span class="rank-count">🔍 {{ data.search_count }} 次搜索</span>
</div>
<CloudBadge :cloud_type="data.cloud_type" />
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import CloudBadge from './CloudBadge.vue'
import type { RankingItem } from '../types'
const props = defineProps<{
data: RankingItem
index: number
}>()
const router = useRouter()
function handleClick() {
router.push('/search?q=' + encodeURIComponent(props.data.title))
}
</script>
<style scoped>
.ranking-card {
display: flex;
align-items: center;
padding: 10px 12px;
background: var(--bg-white);
border-radius: 8px;
gap: 12px;
cursor: pointer;
transition: background 0.2s;
}
.ranking-card:hover {
background: #f0f5ff;
}
.rank-num {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
background: #f0f0f0;
flex-shrink: 0;
}
.rank-num.top-three {
background: var(--primary-color);
color: #fff;
}
.rank-cover {
width: 40px;
height: 40px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
}
.rank-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.rank-title {
font-size: 14px;
font-weight: 500;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rank-count {
font-size: 12px;
color: var(--text-secondary);
}
</style>
+530
View File
@@ -0,0 +1,530 @@
<template>
<div class="result-card" :class="{ 'clickable': loggedIn }" @click="loggedIn && openLink()">
<!-- 封面图区域(左侧) -->
<div class="card-cover">
<!-- 后台静默加载资源图,成功后切换 -->
<img v-if="showCover" :src="data.cover" :alt="data.title" @error="onCoverLoadError" loading="lazy" fetchpriority="low" />
<!-- 默认先显示兜底图(自家服务器,秒加载) -->
<img v-else-if="fallbackImage && !fallbackImgError" :src="fallbackImage" alt="cover" class="fallback-img" @error="onFallbackImgError" />
<!-- 兜底图也没有就用渐变色占位 -->
<div v-else class="cover-placeholder" :style="{ background: coverGradient }">
<span class="placeholder-icon">
<img v-if="cloudIcon.startsWith('data:') || cloudIcon.startsWith('http') || cloudIcon.startsWith('/')" :src="cloudIcon" style="width:36px;height:36px" />
<span v-else>{{ cloudIcon }}</span>
</span>
</div>
<span class="cover-tag" :style="{ background: CLOUD_COLORS[data.cloud_type] }">
{{ CLOUD_LABELS[data.cloud_type] }}
</span>
</div>
<!-- 右侧内容 -->
<div class="card-body">
<!-- 资源名称(已清洗前缀和【】)2行显示 -->
<div class="card-title" :title="data.title">{{ cleanTitle }}</div>
<!-- 更新时间 + 大小 -->
<div class="card-time">
<span>🕐 {{ relativeTime }}</span>
<span v-if="data.file_size" class="meta-size">📦 {{ data.file_size }}</span>
</div>
<!-- 标签行(仅质量/格式/字幕类标签) -->
<div v-if="displayTags.length > 0" class="card-tags">
<span v-for="(tag, ti) in displayTags" :key="ti" class="tag" :class="'tag-' + tagClass(tag)">{{ tag }}</span>
</div>
<!-- 底部行:来源 + 操作按钮 -->
<div class="card-bottom-row">
<div class="bottom-left">
<span v-if="sourceName" class="meta-source" :title="data.source">
{{ sourceIcon }} {{ sourceName }}
</span>
</div>
<div class="bottom-right">
<button v-if="data.share_url && !loggedIn" class="action-btn get-link-btn" @click.stop="handleSave">
🔗 获取分享链接
</button>
<button v-if="data.share_url && loggedIn" class="action-btn open-link-btn" @click.stop="openLink">
🔗 打开链接
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { CLOUD_LABELS, CLOUD_ICONS, CLOUD_COLORS } from '../types'
import type { SearchResult } from '../types'
const props = defineProps<{
data: SearchResult
fallbackTags?: string[]
fallbackImage?: string
loggedIn?: boolean
cloudTypeMap?: Record<string, { label: string; icon: string }>
}>()
const emit = defineEmits<{
save: [data: SearchResult]
}>()
const PRELOAD_TIMEOUT_MS = 10000 // 资源图静默加载超时(10秒后放弃)
const showCover = ref(false) // 是否切换显示资源图(默认先显示兜底)
const fallbackImgError = ref(false) // 兜底图加载失败
const coverLoading = ref(false) // 资源图是否正在后台尝试
let preloadTimer: ReturnType<typeof setTimeout> | null = null
onMounted(() => {
// 后台静默预加载资源图
if (props.data.cover && !showCover.value) {
coverLoading.value = true
const img = new Image()
let resolved = false
preloadTimer = setTimeout(() => {
// 超时未完成 -> 放弃,继续显示兜底图
if (!resolved) {
resolved = true
coverLoading.value = false
}
}, PRELOAD_TIMEOUT_MS)
img.onload = () => {
if (!resolved) {
resolved = true
showCover.value = true
coverLoading.value = false
if (preloadTimer) clearTimeout(preloadTimer)
}
}
img.onerror = () => {
if (!resolved) {
resolved = true
coverLoading.value = false
if (preloadTimer) clearTimeout(preloadTimer)
}
}
img.src = props.data.cover
}
})
onUnmounted(() => {
if (preloadTimer) clearTimeout(preloadTimer)
})
// 极意外情况:showCover 后图片加载失败 -> 回退到兜底图
function onCoverLoadError() {
showCover.value = false
// 注意:此时 fallbackImage 可能已经加载过,浏览器有缓存会直接显示
}
function onFallbackImgError() { fallbackImgError.value = true }
// 网盘图标 — 优先使用 prop 中的 API 数据,fallback emoji
const cloudIcon = computed(() => {
const icon = props.cloudTypeMap?.[props.data.cloud_type]?.icon
return icon || CLOUD_ICONS[props.data.cloud_type] || '📁'
})
// 封面渐变色(无图时)
const coverGradient = computed(() => {
const gradients: Record<string, string> = {
quark: 'linear-gradient(135deg, #e8f5e9, #c8e6c9)',
baidu: 'linear-gradient(135deg, #e3f2fd, #bbdefb)',
aliyun: 'linear-gradient(135deg, #fff3e0, #ffe0b2)',
'115': 'linear-gradient(135deg, #f3e5f5, #e1bee7)',
xunlei: 'linear-gradient(135deg, #e8f5e9, #a5d6a7)',
magnet: 'linear-gradient(135deg, #e8eaf6, #c5cae9)',
}
return gradients[props.data.cloud_type] || 'linear-gradient(135deg, #f5f5f5, #e0e0e0)'
})
// ===== 时间格式化 =====
function formatRelativeTime(dateStr?: string): string {
if (!dateStr) return ''
const now = Date.now()
const date = new Date(dateStr)
if (isNaN(date.getTime())) return dateStr.slice(0, 10)
const diffMs = now - date.getTime()
if (diffMs < 0) return dateStr.slice(0, 10)
const secs = Math.floor(diffMs / 1000)
if (secs < 60) return '刚刚'
const mins = Math.floor(secs / 60)
if (mins < 60) return `${mins} 分钟前`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours} 小时前`
const days = Math.floor(hours / 24)
if (days < 30) return `${days} 天前`
if (days < 365) return `${Math.floor(days / 30)} 个月前`
return `${Math.floor(days / 365)} 年前`
}
const relativeTime = computed(() => {
return formatRelativeTime(props.data.update_time || props.data.datetime)
})
// ===== 来源解析 =====
const sourceName = computed(() => {
const src = props.data.source || ''
if (!src) return ''
if (src.startsWith('tg:')) return '@' + src.slice(3)
if (src.startsWith('plugin:')) return src.slice(7)
return src
})
const sourceIcon = computed(() => {
const src = props.data.source || ''
if (src.startsWith('tg:')) return '📢'
if (src.startsWith('plugin:')) return '🔌'
return '📎'
})
// ===== 标题清洗 =====
// 移除各类前缀: [夸克网盘]、【#电影名称:】等
const CLEAN_PREFIXES = [
/^\[夸克网盘\][::]?\s*/,
/^【#电影名称:】\s*/,
/^【#电影名称[::]】\s*/,
/^【[^】]*[网盘|分享|电影|下载|资源]】[::]?\s*/,
/^\[[^\]]*[网盘|分享|电影|下载|资源]\]\s*/,
/^[##]电影名称[::]?\s*/,
/^[##]资源名称[::]?\s*/,
/^[##]标题[::]?\s*/,
/^【[^】]*资源名称[^】]*】\s*/,
/^【影片名称】\s*/,
/^【资源名称】\s*/,
/^【标题】\s*/,
]
const cleanTitle = computed(() => {
let title = props.data.title || ''
// 1. 去除【】内的通用前缀类标识
for (const pat of CLEAN_PREFIXES) {
title = title.replace(pat, '')
}
// 2. 去除剩余的【xxx】内容(保留内容作为标签,标题只留干净部分)
title = title.replace(/【[^】]+】/g, '').trim()
return title || props.data.title
})
// ===== 标签 =====
const QUALITY_TAG_SET = new Set([
'4K', '1080P', '2160P', '720P', '480P',
'HDR', 'HDR10', 'HDR10+', 'DV', '杜比视界', '杜比全景声',
'高码率', 'BluRay', 'REMUX', 'HEVC', 'x264', 'x265', 'AVC',
'内封简繁英字幕', '内嵌中英字幕', '内封简繁', '内嵌字幕',
'字幕', '中文字幕', '简繁字幕', '中英字幕', '内封字幕',
'臻彩', '高清', 'WEB-DL', 'WEBRip', '蓝光',
])
const QUALITY_PATTERNS = [
/\b(4K)\b/, /\b(1080[Pp])\b/, /\b(2160[Pp])\b/, /\b(720[Pp])\b/,
/\b(HDR10?\+?)\b/i, /\b(DV)\b/i,
/\b(BluRay|蓝光)\b/i, /\b(REMUX)\b/i, /\b(HEVC)\b/i,
/\b(x264)\b/i, /\b(x265)\b/i, /\b(WEB-DL)\b/i, /\b(WEBRip)\b/i,
]
const displayTags = computed(() => {
const title = props.data.title || ''
const tags: string[] = []
// 1. 从【xxx】提取内容,只保留质量/格式/字幕类标签
const bracketMatches = title.matchAll(/【([^】]+)】/g)
for (const m of bracketMatches) {
const inner = m[1]
const parts = inner.split(/[.·、,,\/\\|]/)
for (const p of parts) {
const trimmed = p.trim()
if (trimmed && QUALITY_TAG_SET.has(trimmed) && !tags.includes(trimmed)) {
tags.push(trimmed)
}
}
}
// 2. 额外从标题提取分辨率/编码标签
for (const pat of QUALITY_PATTERNS) {
const m = title.match(pat)
if (m) {
const found = m[1]
if (!tags.includes(found)) tags.push(found)
}
}
// 3. 从标题全文找包含的关键词
const fullTextKeywords = ['杜比视界', '杜比全景声', '高码率', '内封简繁英字幕', '内嵌中英字幕', '内封简繁', '内嵌字幕', '中文字幕', '简繁字幕', '中英字幕', '内封字幕', '臻彩']
for (const kw of fullTextKeywords) {
if (title.includes(kw) && !tags.includes(kw)) {
tags.push(kw)
}
}
if (tags.length === 0 && props.fallbackTags && props.fallbackTags.length > 0) {
return props.fallbackTags.slice(0, 6)
}
return tags.slice(0, 10)
})
function tagClass(tag: string): string {
const quality = ['4K', '1080P', '2160P', '720P', '480P', 'HDR', 'HDR10', 'HDR10+', 'DV', '杜比视界', 'BluRay', 'REMUX', 'HEVC', 'x264', 'x265', '臻彩', '高清', 'WEB-DL', 'WEBRip']
if (quality.includes(tag)) return 'quality'
if (tag.includes('字幕') || tag === '杜比全景声' || tag === '高码率') return 'subtitle'
return 'default'
}
// ===== 交互行为 =====
function handleSave() {
emit('save', props.data)
}
function openLink() {
if (props.data.share_url) {
window.open(props.data.share_url, '_blank')
}
}
</script>
<style scoped>
.result-card {
display: flex;
background: #fff;
border-radius: 10px;
padding: 12px;
gap: 14px;
border: 1px solid #ebeef5;
transition: all 0.2s ease;
content-visibility: auto;
contain-intrinsic-size: 130px 120px;
min-width: 0;
}
.result-card:hover {
border-color: #c0c4cc;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.result-card.clickable {
cursor: pointer;
}
.result-card.clickable:hover {
border-color: #409eff;
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.12);
}
/* ---- 封面(左侧,与右侧等高) ---- */
.card-cover {
position: relative;
flex: 0 0 100px;
max-width: 130px;
border-radius: 8px;
overflow: hidden;
background: #f0f2f5;
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
min-height: 100%;
}
.card-cover img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
position: absolute;
top: 0;
left: 0;
}
.card-cover img.fallback-img {
object-fit: contain;
background: #f0f2f5;
loading: eager;
}
.result-card:hover .card-cover img {
transform: scale(1.05);
}
.cover-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
}
.placeholder-icon {
font-size: 30px;
opacity: 0.5;
filter: grayscale(0.2);
}
.cover-tag {
position: absolute;
bottom: 5px;
left: 5px;
padding: 1px 7px;
border-radius: 4px;
color: #fff;
font-size: 11px;
font-weight: 600;
line-height: 1.5;
letter-spacing: 0.3px;
backdrop-filter: blur(2px);
z-index: 1;
}
/* ---- 右侧内容 ---- */
.card-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 5px;
}
/* 资源名称 */
.card-title {
font-size: 15px;
font-weight: 700;
color: #1a1a2e;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 更新时间 */
.card-time {
font-size: 12px;
color: #909399;
display: flex;
align-items: center;
gap: 10px;
}
.meta-size {
color: #67c23a;
white-space: nowrap;
font-size: 11px;
}
/* ---- 标签 ---- */
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tag {
display: inline-block;
padding: 1px 7px;
border-radius: 4px;
font-size: 11px;
line-height: 1.8;
white-space: nowrap;
transition: opacity 0.2s;
}
.tag-quality {
background: #fef0f0;
color: #e74c3c;
}
.tag-subtitle {
background: #f0f9eb;
color: #67c23a;
}
.tag-default {
background: #ecf5ff;
color: #409eff;
}
/* ---- 底部行:来源 + 操作 ---- */
.card-bottom-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: auto;
}
.bottom-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.bottom-right {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.meta-source {
color: #909399;
background: #f4f4f5;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
}
.action-btn {
padding: 5px 12px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.2s;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 3px;
}
.get-link-btn {
background: var(--primary-color);
color: #fff;
}
.get-link-btn:hover {
background: var(--primary-dark);
transform: scale(1.05);
}
.open-link-btn {
background: #67c23a;
color: #fff;
}
.open-link-btn:hover {
background: #5daf34;
transform: scale(1.05);
}
/* ===== 响应式 ===== */
@media (max-width: 640px) {
.result-card {
flex-direction: column;
gap: 10px;
}
.card-cover {
width: 100%;
height: 160px;
align-self: auto;
}
.card-cover img {
position: static;
height: 160px;
}
.card-cover img.fallback-img {
position: static;
}
.cover-placeholder {
position: static;
}
.card-bottom-row {
flex-wrap: wrap;
}
}
</style>
@@ -0,0 +1,130 @@
<template>
<div class="video-card">
<div class="video-cover">
<img :src="data.cover" :alt="data.title" />
<div class="play-icon">▶</div>
<span class="platform-tag">{{ data.platform }}</span>
</div>
<div class="video-info">
<h4>{{ data.title }}</h4>
<p v-if="data.author" class="video-author">👤 {{ data.author }}</p>
<p v-if="data.description" class="video-desc">{{ data.description }}</p>
</div>
<div class="video-actions">
<button class="save-btn" @click="handleSave">📥 保存到云盘并获取下载链接</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { VideoParseResult } from '../types'
const props = defineProps<{
data: VideoParseResult
}>()
const emit = defineEmits<{
save: [data: VideoParseResult]
}>()
function handleSave() {
emit('save', props.data)
}
</script>
<style scoped>
.video-card {
display: flex;
background: var(--bg-white);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
padding: 16px;
gap: 16px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.video-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
}
.video-cover {
position: relative;
flex-shrink: 0;
width: 200px;
height: 120px;
border-radius: 8px;
overflow: hidden;
}
.video-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 20px;
}
.platform-tag {
position: absolute;
top: 8px;
right: 8px;
padding: 2px 8px;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 12px;
border-radius: 4px;
}
.video-info {
flex: 1;
min-width: 0;
}
.video-info h4 {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
}
.video-author {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.video-desc {
font-size: 14px;
color: #606266;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.video-actions {
display: flex;
align-items: flex-end;
flex-shrink: 0;
}
.save-btn {
padding: 10px 20px;
background: var(--primary-color);
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
white-space: nowrap;
transition: background 0.2s;
}
.save-btn:hover {
background: var(--primary-dark);
}
</style>
@@ -0,0 +1,382 @@
/**
* useTrendChart
*
* Composable that manages a combined bar+line ECharts trend chart.
* Features:
* - Tree-shakeable ECharts import (core + only used chart types/components)
* - Auto resize via ResizeObserver
* - DataZoom slider for 30+ day ranges
* - Day-over-day delta in tooltips
* - Average markLine
* - Period summary callback
* - Proper cleanup on unmount
*/
import { ref, watch, nextTick, onUnmounted, type Ref } from 'vue'
import { init, use, graphic } from 'echarts/core'
import { BarChart, LineChart } from 'echarts/charts'
import {
TooltipComponent,
GridComponent,
LegendComponent,
DataZoomSliderComponent,
MarkLineComponent,
} from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import type { ECharts, EChartsCoreOption } from 'echarts/core'
// Register only the features we actually use
use([
BarChart, LineChart,
TooltipComponent, GridComponent, LegendComponent,
DataZoomSliderComponent, MarkLineComponent,
CanvasRenderer,
])
const { LinearGradient } = graphic
export interface TrendDataPoint {
date: string
searches: number
saves: number
searchDelta: number
saveDelta: number
}
export interface TrendSummary {
totalSearches: number
totalSaves: number
avgSearches: number
avgSaves: number
peakDay: string
peakSearches: number
peakSaves: number
dayCount: number
}
export function useTrendChart(
trendData: Ref<TrendDataPoint[]>,
onSummary?: Ref<TrendSummary | null> | ((s: TrendSummary) => void),
) {
const chartRef = ref<HTMLDivElement | null>(null)
let chartInstance: ECharts | null = null
let resizeObserver: ResizeObserver | null = null
const BAR_WIDTH_PCT = '35%'
const BAR_GAP_PCT = '30%'
function computeSummary(data: TrendDataPoint[]): TrendSummary {
const totalSearches = data.reduce((s, d) => s + d.searches, 0)
const totalSaves = data.reduce((s, d) => s + d.saves, 0)
const n = data.length || 1
let peakIdx = 0
let peakVal = 0
data.forEach((d, i) => {
const v = d.searches + d.saves
if (v > peakVal) { peakVal = v; peakIdx = i }
})
return {
totalSearches,
totalSaves,
avgSearches: Math.round(totalSearches / n),
avgSaves: Math.round(totalSaves / n),
peakDay: data[peakIdx]?.date?.slice(5) || '—',
peakSearches: data[peakIdx]?.searches || 0,
peakSaves: data[peakIdx]?.saves || 0,
dayCount: n,
}
}
function render() {
const el = chartRef.value
const data = trendData.value
if (!el) return
// Compute summary and emit
const summary = computeSummary(data)
if (typeof onSummary === 'function') {
onSummary(summary)
} else if (onSummary) {
onSummary.value = summary
}
// Empty state
if (!data.length) {
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
el.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#909399;font-size:13px;">暂无使用数据</div>'
return
}
// Handle DOM element recreation (v-if remounts chart div when navigating away & back)
if (!chartInstance || chartInstance.getDom() !== el) {
if (chartInstance) chartInstance.dispose()
chartInstance = init(el)
initResize()
}
const dates = data.map(d => d.date.slice(5)) // MM-DD
const n = data.length
const maxCount = Math.max(...data.map(d => Math.max(d.searches, d.saves)), 1)
const yMax = Math.ceil(maxCount * 1.35) || 1
const avgSearches = Math.round(summary.totalSearches / n)
const showDataZoom = n >= 30
const option: EChartsCoreOption = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
backgroundColor: 'rgba(255,255,255,0.97)',
borderColor: '#e8e8e8',
borderWidth: 1,
borderRadius: 8,
padding: [10, 14],
textStyle: { fontSize: 12, color: '#303133' },
formatter: (params: any) => {
const idx = params[0]?.dataIndex ?? 0
const dateLabel = data[idx]?.date || ''
const d = data[idx]
const seen = new Set<string>()
const items = params.filter((p: any) => {
const key = p.seriesName
if (seen.has(key)) return false
seen.add(key)
return true
})
let html = `<div style="font-weight:700;font-size:13px;margin-bottom:8px;color:#1a1a2e">${dateLabel}</div>`
items.forEach((p: any) => {
const rawVal = p.value
const val = Array.isArray(rawVal) ? rawVal[1] : rawVal
const isBar = p.seriesType === 'bar'
const isSearch = p.seriesName === '搜索'
const delta = isSearch ? d?.searchDelta : d?.saveDelta
const deltaStr = delta !== undefined && delta !== 0
? `<span style="margin-left:6px;font-size:11px;color:${delta > 0 ? '#f56c6c' : '#67c23a'}">${delta > 0 ? '↑' : '↓'}${Math.abs(delta)}</span>`
: (delta === 0 ? '<span style="margin-left:6px;font-size:11px;color:#909399">→0</span>' : '')
const icon = isBar
? `<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${p.color};vertical-align:middle"></span>`
: `<span style="display:inline-block;width:14px;height:2px;background:${p.color};vertical-align:middle"></span>`
html += `<div style="display:flex;align-items:center;gap:6px;margin-top:4px">${icon}<span>${p.seriesName}:<b>${val}</b> 次${deltaStr}</span></div>`
})
return html
},
},
legend: {
data: ['搜索', '保存'],
bottom: showDataZoom ? 30 : 0,
left: 'center',
itemWidth: 14,
itemHeight: 10,
textStyle: { fontSize: 11, color: '#666' },
},
grid: {
left: 8,
right: 12,
top: 28,
bottom: showDataZoom ? 70 : 42,
containLabel: true,
},
xAxis: {
type: 'category',
data: dates,
axisLabel: {
interval: 0,
fontSize: 11,
color: '#909399',
rotate: n > 15 ? 45 : 0,
},
axisLine: { lineStyle: { color: '#e8e8e8' } },
splitLine: { show: false },
axisTick: { show: false },
},
yAxis: {
type: 'value',
name: '次',
nameTextStyle: { fontSize: 10, color: '#909399' },
min: 0,
max: yMax,
splitNumber: 4,
axisLabel: {
fontSize: 10, color: '#909399' },
splitLine: { lineStyle: { color: '#f5f5f5', type: 'dashed' } },
},
series: [
// Bar — 搜索
{
name: '搜索',
type: 'bar',
data: data.map(d => d.searches),
barWidth: BAR_WIDTH_PCT,
barGap: BAR_GAP_PCT,
itemStyle: {
color: new LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#6366f1' },
{ offset: 1, color: '#a5b4fc' },
]),
borderRadius: [4, 4, 0, 0],
},
emphasis: {
itemStyle: {
color: new LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#4f46e5' },
{ offset: 1, color: '#818cf8' },
]),
},
},
animationDuration: 500,
animationEasing: 'cubicOut',
},
// Bar — 保存
{
name: '保存',
type: 'bar',
data: data.map(d => d.saves),
barWidth: BAR_WIDTH_PCT,
barGap: BAR_GAP_PCT,
itemStyle: {
color: new LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#10b981' },
{ offset: 1, color: '#6ee7b7' },
]),
borderRadius: [4, 4, 0, 0],
},
emphasis: {
itemStyle: {
color: new LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#059669' },
{ offset: 1, color: '#34d399' },
]),
},
},
animationDuration: 500,
animationEasing: 'cubicOut',
},
// Line — 搜索
{
name: '搜索',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 5,
data: data.map(d => d.searches),
lineStyle: { width: 2.5, color: '#4f46e5' },
itemStyle: { color: '#4f46e5', borderColor: '#fff', borderWidth: 2 },
areaStyle: {
color: new LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(99,102,241,0.12)' },
{ offset: 1, color: 'rgba(99,102,241,0.01)' },
]),
},
label: {
show: false,
},
connectNulls: true,
animationDuration: 700,
animationEasing: 'cubicOut',
z: 3,
markLine: avgSearches > 0 ? {
silent: true,
symbol: 'none',
lineStyle: { color: '#6366f1', type: 'dashed', width: 1, opacity: 0.5 },
label: {
formatter: `均 ${avgSearches}`,
position: 'insideEndTop',
fontSize: 10,
color: '#6366f1',
},
data: [{ yAxis: avgSearches, name: '日均搜索' }],
} : undefined,
},
// Line — 保存
{
name: '保存',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 5,
data: data.map(d => d.saves),
lineStyle: { width: 2.5, color: '#059669' },
itemStyle: { color: '#059669', borderColor: '#fff', borderWidth: 2 },
areaStyle: {
color: new LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(16,185,129,0.12)' },
{ offset: 1, color: 'rgba(16,185,129,0.01)' },
]),
},
label: {
show: false,
},
connectNulls: true,
animationDuration: 700,
animationEasing: 'cubicOut',
z: 3,
markLine: avgSearches > 0 ? {
silent: true,
symbol: 'none',
lineStyle: { color: '#10b981', type: 'dashed', width: 1, opacity: 0.5 },
label: {
formatter: `均 ${Math.round(summary.totalSaves / n)}`,
position: 'insideEndTop',
fontSize: 10,
color: '#10b981',
},
data: [{ yAxis: Math.round(summary.totalSaves / n), name: '日均保存' }],
} : undefined,
},
],
// DataZoom slider for 30+ day views
...(showDataZoom ? {
dataZoom: [{
type: 'slider',
bottom: 6,
height: 20,
start: 0,
end: 100,
borderColor: '#e8e8e8',
fillerColor: 'rgba(99,102,241,0.08)',
handleStyle: { color: '#6366f1', borderColor: '#6366f1' },
textStyle: { fontSize: 10, color: '#909399' },
}],
} : {}),
}
chartInstance.setOption(option, true)
}
// Auto-render when data changes
watch(
trendData,
() => {
nextTick(() => render())
},
{ deep: true },
)
// Setup ResizeObserver for responsive chart
function initResize() {
if (!chartRef.value) return
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
resizeObserver = new ResizeObserver(() => {
chartInstance?.resize()
})
resizeObserver.observe(chartRef.value)
}
// Cleanup
onUnmounted(() => {
resizeObserver?.disconnect()
resizeObserver = null
chartInstance?.dispose()
chartInstance = null
})
return {
chartRef,
render,
initResize,
}
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+13
View File
@@ -0,0 +1,13 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import './styles/global.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')
+363
View File
@@ -0,0 +1,363 @@
<template>
<div class="home-page">
<div class="hero-section">
<template v-if="configLoaded">
<img v-if="siteLogo" :src="siteLogo" :alt="siteName || 'CloudSearch'" class="logo-img" @error="(e: any) => { (e.target as HTMLElement).style.display='none'; siteLogo='' }" />
<div v-else class="logo-text">{{ siteName || 'CloudSearch' }}</div>
</template>
<div class="search-box">
<el-input
v-model="query"
placeholder="搜索网盘资源,或粘贴视频/网盘链接..."
size="large"
clearable
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" size="large" @click="handleSearch" class="search-btn">
搜 索
</el-button>
</div>
<div class="quote-section" v-if="currentQuote">
<span class="quote-text">「 {{ currentQuote }} 」</span>
<span class="quote-author">---{{ quoteAuthor }}</span>
</div>
</div>
<div class="content-section">
<div v-if="categories.length > 0" class="rankings-grid">
<div
v-for="cat in categories"
:key="cat.category"
class="rank-panel"
>
<div class="panel-header">
<span class="panel-title">{{ getCategoryIcon(cat.category) }} {{ cat.label }}</span>
<div class="panel-tabs">
<span class="panel-tab" :class="{ active: activeTab[cat.category] === 'hot' }" @click="switchTab(cat.category, 'hot')">热榜</span>
<span class="panel-tab" :class="{ active: activeTab[cat.category] === 'newest' }" @click="switchTab(cat.category, 'newest')">最新</span>
</div>
</div>
<div class="panel-body">
<div
v-for="(item, idx) in visibleItems(cat)"
:key="cat.category + '-' + idx"
class="rank-item"
@click="searchTag(item.keyword)"
>
<span class="rank-idx" :class="{ 'top-three': idx < 3 }">{{ idx + 1 }}</span>
<span class="rank-name">{{ item.keyword }}</span>
<span class="rank-cnt">{{ formatCount(item) }}</span>
</div>
<!-- 展开按钮 -->
<div v-if="hasMoreItems(cat)" class="rank-expand" @click="expandCategory(cat.category)">
展开全部 ▼
</div>
</div>
<div class="panel-footer">
<span v-if="cat.category === 'hotsite'">基于本站搜索数据</span>
<span v-else-if="cat.category === 'donghua' || cat.category === 'global_anime'">数据来源:Bilibili</span>
<span v-else-if="cat.category === 'movie' || cat.category === 'tv'">数据来源:百度</span>
<span v-else>数据来源:TMDB</span>
<span class="footer-time">{{ fetchedAt }}</span>
</div>
</div>
</div>
</div>
<div v-if="siteDisclaimer" class="site-footer">
<div class="footer-inner">{{ siteDisclaimer }}</div>
<div class="footer-actions">
<el-button class="footer-disclaimer-btn" size="small" @click="openDisclaimer">📜 免责声明</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Search } from '@element-plus/icons-vue'
import { getCategorizedRankings, getSiteConfig } from '../api'
const router = useRouter()
const query = ref('')
const categories = ref<any[]>([])
const expanded = reactive<Record<string, boolean>>({})
const activeTab = reactive<Record<string, string>>({})
const siteLogo = ref('')
const siteName = ref('')
const siteDisclaimer = ref('')
const configLoaded = ref(false)
const currentQuote = ref('')
const quoteAuthor = ref('')
const fetchedAt = ref('')
const INITIAL_SHOW = 8
const CATEGORY_ICONS: Record<string, string> = {
movie: '🎬', western_movie: '🎥', western_tv: '🌍',
donghua: '🐉', global_anime: '🌐',
tv: '📺',
niche: '💎', hotsite: '🏆',
}
function getCategoryIcon(cat: string): string {
return CATEGORY_ICONS[cat] || '📋'
}
function formatCount(item: any): string {
const parts: string[] = []
if (item.rating) {
parts.push(`⭐${item.rating}`)
}
if (item.searchCount > 0) {
const n = item.searchCount
if (n >= 100000000) {
parts.push(`${(n / 100000000).toFixed(1)}亿`)
} else if (n >= 10000) {
parts.push(`${(n / 10000).toFixed(0)}万`)
} else {
parts.push(String(n))
}
}
return parts.join(' ') || ''
}
function getItems(cat: any): any[] {
const tab = activeTab[cat.category] || 'hot'
return tab === 'hot' ? (cat.hot || []) : (cat.newest || [])
}
function visibleItems(cat: any): any[] {
const items = getItems(cat)
return expanded[cat.category] ? items : items.slice(0, INITIAL_SHOW)
}
function hasMoreItems(cat: any): boolean {
const items = getItems(cat)
return items.length > INITIAL_SHOW && !expanded[cat.category]
}
function expandCategory(category: string) {
expanded[category] = true
}
function switchTab(category: string, tab: string) {
activeTab[category] = tab
// 切换标签时收起展开
expanded[category] = false
}
function openDisclaimer() {
window.open('/disclaimer/', '_blank')
}
onMounted(async () => {
// 一言 API
try {
const res = await fetch('https://v1.hitokoto.cn/')
const data = await res.json()
currentQuote.value = data.hitokoto || ''
quoteAuthor.value = data.from_who || data.from || ''
} catch {
currentQuote.value = '学而时习之,不亦说乎。'
quoteAuthor.value = '孔子'
}
try {
const [catsData, siteCfg] = await Promise.all([
getCategorizedRankings(),
getSiteConfig(),
])
// 新格式: { fetchedAt, categories }
if (catsData.fetchedAt) {
fetchedAt.value = catsData.fetchedAt
categories.value = catsData.categories || []
} else {
// 兼容旧格式
categories.value = Array.isArray(catsData) ? catsData : []
}
for (const cat of categories.value) {
activeTab[cat.category] = 'hot'
expanded[cat.category] = false
}
if (siteCfg.site_logo) {
siteLogo.value = siteCfg.site_logo
}
if (siteCfg.site_name) {
siteName.value = siteCfg.site_name
}
if (siteCfg.site_disclaimer) {
siteDisclaimer.value = siteCfg.site_disclaimer
}
configLoaded.value = true
} catch (e) {
console.error('加载首页数据失败', e)
}
})
function handleSearch() {
const q = query.value.trim()
if (q) router.push('/search?q=' + encodeURIComponent(q))
}
function searchTag(tag: string) {
router.push('/search?q=' + encodeURIComponent(tag))
}
</script>
<style scoped>
.home-page { min-height: 100vh; display: flex; flex-direction: column; }
.hero-section {
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 60px 24px 40px;
}
.logo-text { font-size: 64px; font-weight: 700; color: var(--primary-color); margin-bottom: 32px; letter-spacing: -2px; }
.logo-img { max-width: 500px; max-height: 120px; width: auto; height: auto; object-fit: contain; margin-bottom: 32px; }
.search-box {
display: flex;
align-items: center;
width: 100%; max-width: 640px;
border: 1px solid #dfe1e5;
border-radius: 24px;
background: #fff;
box-shadow: none;
transition: box-shadow .2s, border-color .2s;
overflow: hidden;
}
.search-box:focus-within {
box-shadow: 0 1px 6px rgba(32,33,36,.28);
border-color: rgba(223,225,229,0);
}
.search-box :deep(.el-input__wrapper) {
border: none; box-shadow: none; background: transparent;
padding: 4px 20px; border-radius: 0;
}
.search-box :deep(.el-input__inner) {
font-size: 15px;
}
.search-btn {
flex-shrink: 0;
border: none;
border-radius: 999px;
padding: 0 24px;
height: 38px;
line-height: 38px;
margin: 4px;
font-size: 14px; font-weight: 600;
background: var(--primary-color); color: #fff;
cursor: pointer; transition: all .2s;
letter-spacing: 1px;
}
.search-btn:hover {
background: #3a7be0;
}
.search-btn:active {
background: #2d6ccf;
}
.quote-section { margin-top: 18px; max-width: 640px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.quote-text { font-size: 14px; color: #aab0b8; font-style: italic; letter-spacing: 0.5px; }
.quote-author { font-size: 12px; color: #c0c4cc; display: inline-block; margin-left: 4px; }
.content-section { max-width: 1500px; width: 100%; margin: 0 auto; padding: 0 16px 60px; }
.rankings-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px;
margin-top: 8px;
}
.rank-panel {
background: var(--bg-white,#fff);
border-radius: 12px; padding: 14px; border: 1px solid #ebeef5;
box-shadow: 0 1px 4px rgba(0,0,0,0.04); display: flex; flex-direction: column;
}
.panel-header {
display: flex; align-items: center; justify-content: space-between;
padding-bottom: 10px; border-bottom: 2px solid #f0f0f0; margin-bottom: 4px;
}
.panel-title { font-size: 15px; font-weight: 700; color: #303133; white-space: nowrap; }
.panel-tabs { display: flex; gap: 2px; background: #f0f2f5; border-radius: 6px; padding: 2px; }
.panel-tab {
font-size: 11px; padding: 3px 10px; border-radius: 5px; cursor: pointer;
color: #909399; font-weight: 500; transition: all .2s; user-select: none;
}
.panel-tab.active { background: #fff; color: var(--primary-color); font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.panel-body { flex: 1; display: flex; flex-direction: column; gap: 1px; }
.rank-item {
display: flex; align-items: center; gap: 8px; padding: 5px 6px;
border-radius: 6px; cursor: pointer; transition: background .15s;
}
.rank-item:hover { background: #f0f5ff; }
.rank-item:active { background: #e6f0ff; }
.rank-idx {
width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center;
justify-content: center; font-size: 12px; font-weight: 700;
color: #909399; background: #f0f0f0; flex-shrink: 0;
}
.rank-idx.top-three { background: var(--primary-color); color: #fff; }
.rank-name { flex: 1; min-width: 0; font-size: 13px; font-weight: 500; color: #303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rank-cnt { font-size: 11px; color: #c0c4cc; white-space: nowrap; flex-shrink: 0; }
.rank-expand {
text-align: center; padding: 6px; margin-top: 2px; font-size: 12px;
color: var(--primary-color); cursor: pointer; border-radius: 6px;
transition: background .15s; user-select: none;
}
.rank-expand:hover { background: #ecf5ff; }
.panel-footer {
margin-top: 8px; padding-top: 8px; border-top: 1px solid #f0f0f0;
display: flex; align-items: center; justify-content: space-between;
font-size: 11px; color: #c0c4cc;
}
.footer-time { font-family: monospace; font-size: 10px; }
@media (max-width: 900px) {
.hero-section { padding: 36px 16px 24px; }
.logo-text { font-size: 36px; margin-bottom: 20px; }
.logo-img { max-width: 360px; max-height: 100px; margin-bottom: 20px; }
.rankings-scroll { gap: 12px; }
}
.site-footer {
margin-top: auto;
padding: 20px 16px 32px;
background: #f9fafb;
border-top: 1px solid #ebeef5;
}
.footer-inner {
max-width: 800px;
margin: 0 auto;
font-size: 12px;
line-height: 1.8;
color: #909399;
text-align: center;
white-space: pre-line;
}
.footer-actions {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.footer-disclaimer-btn {
font-size: 12px !important;
color: #909399 !important;
}
.footer-disclaimer-btn:hover {
color: #409eff !important;
}
</style>
+350
View File
@@ -0,0 +1,350 @@
<template>
<div class="result-detail-page">
<div class="detail-container">
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<el-skeleton :rows="6" animated />
</div>
<!-- 网盘资源详情 -->
<div v-else-if="result" class="detail-card">
<div class="detail-header">
<div class="detail-cover">
<img :src="result.cover" :alt="result.title" />
<CloudBadge :cloud_type="result.cloud_type" />
</div>
<div class="detail-info">
<h1>{{ result.title }}</h1>
<div class="detail-meta">
<el-tag v-if="result.file_size">📦 {{ result.file_size }}</el-tag>
<el-tag v-if="result.update_time">🕐 {{ result.update_time }}</el-tag>
<el-tag v-if="result.source">📂 {{ result.source }}</el-tag>
</div>
<p v-if="result.description" class="detail-desc">{{ result.description }}</p>
</div>
</div>
<div class="detail-actions">
<el-button type="primary" size="large" @click="showSaveDialog">
📥 保存到网盘
</el-button>
</div>
</div>
<!-- 视频解析详情 -->
<div v-else-if="videoResult" class="detail-card">
<div class="detail-video">
<div class="video-preview">
<img :src="videoResult.cover" :alt="videoResult.title" />
<div class="play-overlay" @click="playVideo">
<div class="play-btn">▶ 播放</div>
</div>
</div>
<div class="video-info">
<h1>{{ videoResult.title }}</h1>
<p v-if="videoResult.author" class="video-author">👤 {{ videoResult.author }}</p>
<p class="video-platform">📺 {{ videoResult.platform }}</p>
<p v-if="videoResult.description" class="detail-desc">{{ videoResult.description }}</p>
</div>
</div>
<div class="detail-actions">
<el-button type="primary" size="large" @click="showSaveDialog">
📥 保存到云盘
</el-button>
</div>
<!-- 视频播放器 -->
<div v-if="showPlayer" class="video-player-wrapper">
<video :src="videoResult.video_url" controls autoplay class="video-player"></video>
</div>
</div>
<!-- 未找到 -->
<el-empty v-else description="未找到该资源" />
</div>
<!-- 保存弹窗 -->
<el-dialog v-model="saveDialogVisible" title="保存到网盘" width="420px">
<div class="save-dialog-content">
<p class="save-file-name">📄 {{ result?.title || videoResult?.title }}</p>
<CloudSelect @select="onCloudSelected" />
</div>
<template #footer>
<el-button @click="saveDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="confirmSave">确认保存</el-button>
</template>
</el-dialog>
<!-- 保存结果弹窗 -->
<el-dialog v-model="resultDialogVisible" title="保存成功" width="420px">
<div class="result-dialog-content">
<el-alert type="success" :title="saveResult?.message || '保存成功'" show-icon :closable="false" />
<div class="share-link-box">
<p class="share-label">分享链接:</p>
<div class="share-link-row">
<el-input v-model="shareLink" readonly />
<el-button @click="copyShareLink">复制</el-button>
</div>
</div>
</div>
<template #footer>
<el-button type="primary" @click="resultDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import CloudBadge from '../components/CloudBadge.vue'
import CloudSelect from '../components/CloudSelect.vue'
import { query, saveToCloud, saveVideoToCloud } from '../api'
import { ElMessage } from 'element-plus'
import type { SearchResult, VideoParseResult, SaveResult } from '../types'
const route = useRoute()
const loading = ref(false)
const result = ref<SearchResult | null>(null)
const videoResult = ref<VideoParseResult | null>(null)
const showPlayer = ref(false)
const saveDialogVisible = ref(false)
const saving = ref(false)
const selectedCloud = ref('')
const isVideo = ref(false)
const resultDialogVisible = ref(false)
const saveResult = ref<SaveResult | null>(null)
const shareLink = ref('')
onMounted(async () => {
const id = route.params.id as string
if (!id) return
loading.value = true
try {
// 用 id 作为搜索词重新查询具体结果
const res = await query(id)
if (res.intent === 'SEARCH' && res.results.length > 0) {
result.value = (res.results as SearchResult[])[0]
} else if (res.intent === 'VIDEO_PARSE' && res.results.length > 0) {
videoResult.value = (res.results as VideoParseResult[])[0]
}
} catch (e) {
console.error('获取详情失败', e)
} finally {
loading.value = false
}
})
function showSaveDialog() {
isVideo.value = !!videoResult.value
saveDialogVisible.value = true
}
function playVideo() {
showPlayer.value = true
}
function onCloudSelected(cloudType: string) {
selectedCloud.value = cloudType
}
async function confirmSave() {
if (!selectedCloud.value) {
ElMessage.warning('请选择目标网盘')
return
}
saving.value = true
try {
let res: SaveResult
if (isVideo.value && videoResult.value) {
res = await saveVideoToCloud({
video_url: videoResult.value.video_url,
title: videoResult.value.title,
target_cloud: selectedCloud.value,
})
} else if (result.value) {
res = await saveToCloud({
type: 'search',
source: result.value,
target_cloud: selectedCloud.value,
})
} else {
return
}
saveResult.value = res
shareLink.value = res.share_url
saveDialogVisible.value = false
resultDialogVisible.value = true
} catch (e) {
console.error('保存失败', e)
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
async function copyShareLink() {
try {
await navigator.clipboard.writeText(shareLink.value)
ElMessage.success('链接已复制到剪贴板')
} catch {
ElMessage.warning('复制失败,请手动复制')
}
}
</script>
<style scoped>
.result-detail-page {
min-height: 100vh;
background: #f5f7fa;
padding: 40px 24px;
}
.detail-container {
max-width: 1080px;
margin: 0 auto;
}
.detail-card {
background: var(--bg-white);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
padding: 32px;
}
.detail-header {
display: flex;
gap: 24px;
margin-bottom: 24px;
}
.detail-cover {
position: relative;
flex-shrink: 0;
width: 240px;
height: 180px;
border-radius: 12px;
overflow: hidden;
}
.detail-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.detail-info {
flex: 1;
}
.detail-info h1 {
font-size: 24px;
font-weight: 700;
color: #303133;
margin-bottom: 12px;
line-height: 1.4;
}
.detail-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.detail-desc {
font-size: 15px;
color: #606266;
line-height: 1.6;
}
.detail-actions {
border-top: 1px solid var(--border-color);
padding-top: 20px;
}
.detail-video {
margin-bottom: 24px;
}
.video-preview {
position: relative;
width: 100%;
border-radius: 12px;
overflow: hidden;
margin-bottom: 20px;
}
.video-preview img {
width: 100%;
max-height: 400px;
object-fit: cover;
display: block;
}
.play-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
cursor: pointer;
transition: background 0.2s;
}
.play-overlay:hover {
background: rgba(0, 0, 0, 0.4);
}
.play-btn {
padding: 12px 32px;
background: rgba(255, 255, 255, 0.9);
border-radius: 24px;
font-size: 18px;
font-weight: 600;
color: #303133;
}
.video-info h1 {
font-size: 24px;
font-weight: 700;
color: #303133;
margin-bottom: 12px;
}
.video-author,
.video-platform {
font-size: 15px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.video-player-wrapper {
margin-top: 24px;
border-top: 1px solid var(--border-color);
padding-top: 24px;
}
.video-player {
width: 100%;
border-radius: 8px;
}
.loading-state {
padding: 40px 0;
}
.save-dialog-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.save-file-name {
font-size: 15px;
font-weight: 500;
color: #303133;
}
.result-dialog-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.share-link-box {
display: flex;
flex-direction: column;
gap: 8px;
}
.share-label {
font-size: 14px;
color: #606266;
}
.share-link-row {
display: flex;
gap: 8px;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,889 @@
<template>
<div>
<!-- 仪表盘 -->
<div v-show="activeMenu === 'dashboard'" class="section-content">
<!-- Row 1: 6 个统计卡 — 文字在上,数字在下 -->
<div class="dash-row dash-row-stats">
<el-card
v-for="item in statItems" :key="item.key"
class="stat-card"
shadow="never"
>
<div class="stat-label">{{ item.label }}</div>
<div class="stat-value">{{ (stats as any)[item.key] ?? 0 }}</div>
</el-card>
</div>
<!-- Row 2: 网盘使用空间 -->
<div class="dash-row">
<el-card class="storage-card" shadow="never">
<template #header>
<span>💾 网盘存储空间</span>
</template>
<div class="storage-grid">
<div
v-for="item in stats.cloudUsage" :key="item.cloudType + '-' + (item.nickname || '')"
class="storage-drive-card"
>
<div class="drive-header">
<img :src="driveIcon(item.cloudType)" class="drive-icon" />
<span class="drive-name">{{ item.nickname || item.cloudType }}</span>
<span :class="['drive-status', item.isActive ? 'active' : 'inactive']">
{{ item.isActive ? '正常' : '停用' }}
</span>
</div>
<div class="drive-space">
<span class="drive-used">{{ item.storageUsed || '--' }}</span>
<span class="drive-sep">/</span>
<span class="drive-total">{{ item.storageTotal || '--' }}</span>
</div>
<el-progress
:percentage="cloudStoragePercent(item)"
:stroke-width="10"
:color="cloudStoragePercent(item) > 80 ? '#f56c6c' : cloudStoragePercent(item) > 60 ? '#e6a23c' : '#67c23a'"
/>
</div>
<el-empty v-if="stats.cloudUsage.length === 0" description="暂无网盘数据" :image-size="72" />
</div>
</el-card>
</div>
<!-- Row 3: 近 7 天趋势 + 热门搜索关键词 Top 20 + 操作来源 IP Top 10 -->
<div class="dash-row dash-row-cols-3">
<el-card class="insight-card trend-card" shadow="never">
<template #header>
<div class="insight-header">
<span>📈 使用趋势</span>
<div class="trend-day-btns">
<button
v-for="d in trendDayOptions" :key="d"
:class="['trend-day-btn', { active: trendDays === d }]"
@click="switchTrendDays(d)"
>{{ d }}天</button>
</div>
</div>
</template>
<div v-if="trendSummary" class="trend-summary-row">
<div class="trend-summary-item">
<span class="trend-summary-num">{{ trendSummary.totalSearches }}</span>
<span class="trend-summary-desc">总搜索</span>
</div>
<div class="trend-summary-item">
<span class="trend-summary-num">{{ trendSummary.totalSaves }}</span>
<span class="trend-summary-desc">总保存</span>
</div>
<div class="trend-summary-item">
<span class="trend-summary-num">{{ trendSummary.avgSearches }}/{{ trendSummary.avgSaves }}</span>
<span class="trend-summary-desc">日均搜索/保存</span>
</div>
<div class="trend-summary-item">
<span class="trend-summary-num">{{ trendSummary.peakDay }}</span>
<span class="trend-summary-desc">峰值日 {{ trendSummary.peakSearches }}+{{ trendSummary.peakSaves }}</span>
</div>
</div>
<div ref="chartRef" class="trend-chart-echarts"></div>
</el-card>
<el-card class="insight-card" shadow="never">
<template #header><span>🔍 热门搜索 Top 20</span></template>
<div class="keyword-list">
<el-tag
v-for="(kw, i) in stats.hotKeywords" :key="kw.keyword"
:type="keywordTagType(i)"
size="small"
class="keyword-tag"
>{{ kw.keyword }}<sup class="kw-count">{{ kw.count }}</sup></el-tag>
<el-empty v-if="stats.hotKeywords.length === 0" description="暂无搜索数据" :image-size="56" />
</div>
</el-card>
<el-card class="insight-card" shadow="never">
<template #header>
<el-tabs v-model="rightTab" class="card-tabs" @tab-click="handleTabClick">
<el-tab-pane label="🌐 IP Top 10" name="ip" />
<el-tab-pane label="🗺️ 地域 Top 10" name="province" />
</el-tabs>
</template>
<div class="ip-list" v-show="rightTab === 'ip'">
<div v-for="(ip, i) in stats.topIps" :key="ip.ip" class="ip-row">
<span class="ip-rank">{{ i + 1 }}</span>
<span class="ip-addr">{{ ip.ip }}</span>
<span class="ip-loc" v-if="ip.ip_location">{{ formatLocation(ip.ip_location) }}</span>
<span class="ip-count">{{ ip.count }} 次</span>
</div>
<el-empty v-if="stats.topIps.length === 0" description="暂无数据" :image-size="56" />
</div>
<div class="province-list" v-show="rightTab === 'province'">
<div
v-for="(item, i) in stats.provinceRankings.slice(0, 10)"
:key="item.province"
class="province-row"
>
<span class="province-rank">{{ i + 1 }}</span>
<span class="province-bar-wrap">
<span
class="province-bar"
:style="{
width: (item.count / maxProvinceCount) * 100 + '%',
background: provinceBarColor(i),
}"
></span>
</span>
<span class="province-name">{{ item.province }}</span>
<span class="province-count">{{ item.count }} 次</span>
</div>
<el-empty v-if="!stats.provinceRankings?.length" description="暂无数据" :image-size="56" />
</div>
</el-card>
</div>
</div>
<!-- 网盘设置及授权 -->
<div v-if="showCloudToggle" class="section-content">
<el-card class="config-card" shadow="never">
<template #header>
<span>📂 网盘类型开关</span>
</template>
<div class="cloud-toggle-grid">
<div
v-for="ct in cloudTypes"
:key="ct.type"
class="cloud-toggle-chip"
>
<img :src="ct.icon" class="cloud-icon-img" />
<span class="cloud-label">{{ ct.label }}</span>
<el-tag v-if="ct.type === 'others'" size="small" type="info">关</el-tag>
<el-switch
:model-value="ct.enabled"
size="small"
@change="(val: boolean) => handleCloudToggle(ct.type, val)"
/>
</div>
</div>
<div class="form-tip" style="margin-top: 12px;">
关闭的网盘类型在搜索结果中不会展示。修改后立即生效,无需点击保存。
</div>
</el-card>
<CloudConfig />
</div>
<!-- 系统配置 -->
<div v-if="showSystemConfig" class="section-content">
<SystemConfig :section="activeSystemSection" />
</div>
<!-- 转存日志 -->
<div v-if="activeMenu === 'save-records'" class="section-content">
<SaveRecords />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, reactive, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useTrendChart, type TrendSummary } from '../../composables/useTrendChart'
import {
DataBoard,
Connection,
Setting,
SwitchButton,
DocumentCopy,
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getStats, getCloudConfigs, getSiteConfig, getCloudTypes, toggleCloudType } from '../../api'
import type { StatsData } from '../../types'
import CloudConfig from './CloudConfig.vue'
import SystemConfig from './SystemConfig.vue'
import SaveRecords from './SaveRecords.vue'
import { getSaveRecords } from '../../api'
interface CloudTypeInfo {
type: string
label: string
icon: string
enabled: boolean
}
const router = useRouter()
const appVersion = ref("")
const stats = ref<StatsData>({
todaySearches: 0,
todaySaves: 0,
monthSearches: 0,
monthSaves: 0,
totalSearches: 0,
totalSaves: 0,
hotKeywords: [],
trendTrend: [],
cloudUsage: [],
topIps: [],
provinceRankings: [],
})
const siteName = ref('')
const cloudTypes = ref<CloudTypeInfo[]>([])
// ── ECharts trend chart (via composable) ──
/** ── Trend summary (MUST be declared before useTrendChart callback) ── */
const { chartRef: _chartRef, render: renderTrendChart, initResize: initTrendChart } = useTrendChart(computed(() => stats.value.trendTrend), (s: any) => { trendSummary.value = s })
const chartRef = _chartRef as any
// Force render after mount (data may already be loaded)
watch(() => stats.value.trendTrend, () => {
renderTrendChart()
}, { deep: true })
// Reload chart when switching back to dashboard
watch(() => activeMenu.value, (val, oldVal) => {
if (val === 'dashboard' && oldVal !== 'dashboard') {
nextTick(() => {
const el = document.querySelector('.trend-chart-echarts') as HTMLElement | null
if (el && el.childElementCount === 0) {
renderTrendChart()
initTrendChart()
} else if (el) {
initTrendChart()
}
})
}
})
// ── Dashboard stat items: label top, value bottom ──
const statItems = [
{ key: 'todaySearches', label: '今日搜索' },
{ key: 'todaySaves', label: '今日保存' },
{ key: 'monthSearches', label: '本月搜索' },
{ key: 'monthSaves', label: '本月保存' },
{ key: 'totalSearches', label: '总搜索量' },
{ key: 'totalSaves', label: '总保存量' },
]
function keywordTagType(i: number): string {
if (i === 0) return 'danger'
if (i < 3) return 'warning'
if (i < 8) return ''
return 'info'
}
/** Strip "中国/China" prefix from location string for consistent display */
function formatLocation(loc: string): string {
if (!loc) return ''
return loc.replace(/^(中国|China)\s*/i, '').trim()
}
// ── Province ranking helpers ──
const maxProvinceCount = computed(() => {
const items = stats.value.provinceRankings
return items.length > 0 ? Math.max(...items.map(i => i.count)) : 1
})
const PROVINCE_COLORS = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399', '#b37feb', '#36cfc9', '#ff85c0']
function provinceBarColor(i: number): string {
return PROVINCE_COLORS[i % PROVINCE_COLORS.length]
}
// ── Right card tabs ──
const rightTab = ref('ip')
function handleTabClick() {
// no-op for now, tabs handle switching via v-show
}
// ── Drive icon lookup from API data ──
const FALLBACK_ICON = 'data:image/svg+xml,' + encodeURIComponent('<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect rx="4" width="24" height="24" fill="#909399"/><text x="12" y="16" text-anchor="middle" fill="white" font-size="14" font-weight="bold" font-family="Arial">☁</text></svg>')
function driveIcon(type: string): string {
return cloudTypes.value.find(c => c.type === type)?.icon || FALLBACK_ICON
}
// ── Trend day range ──
const trendDayOptions = [7, 15, 30, 60]
const trendDays = ref(7)
const trendSummary = ref<TrendSummary | null>(null)
async function switchTrendDays(d: number) {
trendDays.value = d
await loadStats()
}
// Menu selection
const activeMenu = ref('dashboard')
const activeSystemSection = ref('')
// ── Recent saves ──
const recentSaves = ref<any[]>([])
const systemInfo = reactive({
uptime: '--',
memory: '--',
version: '--',
dbOk: false,
redisOk: false,
pansouOk: false,
})
async function loadRecentSaves() {
try {
const data = await getSaveRecords(1, 5)
recentSaves.value = data.records || []
} catch { recentSaves.value = [] }
}
async function loadSystemInfo() {
try {
const res = await fetch('/health')
const h = await res.json()
systemInfo.uptime = formatUptime(h.uptime || 0)
systemInfo.memory = formatBytes(h.memory || 0)
systemInfo.version = h.version || '--'
systemInfo.dbOk = h.components?.db === 'connected'
systemInfo.redisOk = h.components?.redis === 'connected'
systemInfo.pansouOk = h.components?.pansou === 'ok'
} catch {}
}
function formatUptime(s: number): string {
const d = Math.floor(s / 86400)
const h = Math.floor((s % 86400) / 3600)
const m = Math.floor((s % 3600) / 60)
const parts = []
if (d > 0) parts.push(d + '天')
if (h > 0) parts.push(h + '时')
parts.push(m + '分')
return parts.join(' ')
}
function formatBytes(b: number): string {
if (b >= 1073741824) return (b / 1073741824).toFixed(1) + ' GB'
if (b >= 1048576) return (b / 1048576).toFixed(0) + ' MB'
return (b / 1024).toFixed(0) + ' KB'
}
function formatSaveTime(t: string): string {
if (!t) return ''
const d = new Date(t)
if (isNaN(d.getTime())) return t.slice(0, 10)
const diff = Date.now() - d.getTime()
const mins = Math.floor(diff / 60000)
if (mins < 60) return mins <= 1 ? '刚刚' : mins + '分钟前'
const hours = Math.floor(mins / 60)
if (hours < 24) return hours + '小时前'
return t.slice(0, 10)
}
const pageTitles: Record<string, string> = {
dashboard: '仪表盘',
'save-records': '转存日志',
}
const sysSectionTitles: Record<string, string> = {
'sys-site': '网站设置',
'sys-services': '外部服务 & 缓存',
'sys-strategy': '搜索结果展示策略',
'sys-validation': '链接验证配置',
'sys-filter': '搜索标题过滤规则',
'sys-password': '修改管理员密码',
}
const pageTitle = computed(() => {
if (activeMenu.value.startsWith('sys-')) {
return '系统配置 — ' + (sysSectionTitles[activeMenu.value] || '')
}
if (activeMenu.value === 'cloud-configs-toggle' || activeMenu.value === 'cloud-configs-cleanup') {
return '网盘管理'
}
return pageTitles[activeMenu.value] || '管理后台'
})
const showCloudToggle = computed(() => {
return activeMenu.value === 'cloud-configs-toggle'
})
const showSystemConfig = computed(() => {
return activeMenu.value.startsWith('sys-')
})
onMounted(async () => {
try {
const cfg = await getSiteConfig()
if (cfg.site_name) {
siteName.value = cfg.site_name
document.title = cfg.site_name + ' - 管理后台'
}
} catch {}
try {
const [configs, ctResult] = await Promise.all([
getCloudConfigs(),
getCloudTypes(),
])
cloudTypes.value = ctResult.types
await loadStats()
await loadRecentSaves()
await loadSystemInfo()
initTrendChart()
} catch (e) {
console.error('加载数据失败', e)
}
})
async function loadStats() {
try {
stats.value = await getStats(trendDays.value)
try { const h = await fetch("/health"); const hv = await h.json(); appVersion.value = hv.version; } catch {}
} catch (e) {
console.error('加载统计数据失败', e)
}
}
function cloudStoragePercent(item: { storageUsed: string; storageTotal: string }): number {
if (!item.storageUsed || !item.storageTotal) return 0
const used = parseFloat(item.storageUsed)
const total = parseFloat(item.storageTotal)
if (total <= 0) return 0
return Math.round((used / total) * 100)
}
function handleMenuSelect(index: string) {
if (index === 'logout') {
handleLogout()
return
}
activeMenu.value = index
// For system config sub-items, set which section to scroll to
if (index.startsWith('sys-')) {
activeSystemSection.value = index
}
// Refresh cloud types when switching to cloud configs
if (index === 'cloud-configs-toggle') {
getCloudTypes().then(r => { cloudTypes.value = r.types }).catch(() => {})
}
}
function handleLogout() {
localStorage.removeItem('admin_token')
router.push('/admin/login')
}
function goBackHome() {
window.location.href = '/'
}
// ── 网盘类型开关 ──
async function handleCloudToggle(type: string, enabled: boolean) {
const ct = cloudTypes.value.find(c => c.type === type)
if (!ct) return
try {
await toggleCloudType(type, enabled)
ct.enabled = enabled
} catch (e: any) {
ElMessage.error(e.message || '切换失败')
// Revert optimistic update
ct.enabled = !enabled
}
}
</script>
<style scoped>
.section-content { }
/* ── Row 1: 统计卡片 ── */
.dash-row { margin-bottom: 20px; }
.dash-row-stats {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 14px;
}
@media (max-width: 1200px) { .dash-row-stats { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 700px) { .dash-row-stats { grid-template-columns: repeat(2, 1fr); } }
.stat-card {
text-align: center;
background: var(--bg-card) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius-lg) !important;
transition: transform 0.2s ease, box-shadow 0.2s ease;
cursor: default;
position: relative;
overflow: hidden;
}
/* 统计卡片左侧装饰条 — 不同颜色区分 */
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
border-radius: 14px 0 0 14px;
}
.stat-card:nth-child(1)::before { background: linear-gradient(180deg, #409eff, #79bbff); }
.stat-card:nth-child(2)::before { background: linear-gradient(180deg, #67c23a, #95d475); }
.stat-card:nth-child(3)::before { background: linear-gradient(180deg, #e6a23c, #f3d19e); }
.stat-card:nth-child(4)::before { background: linear-gradient(180deg, #7c3aed, #a78bfa); }
.stat-card:nth-child(5)::before { background: linear-gradient(180deg, #f56c6c, #f89898); }
.stat-card:nth-child(6)::before { background: linear-gradient(180deg, #36cfc9, #6fe0d9); }
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0,0,0,0.08) !important;
}
.stat-card :deep(.el-card__body) {
padding: 22px 14px 18px !important;
}
.stat-label {
font-size: 13px;
color: var(--text-tertiary);
font-weight: 500;
margin-bottom: 8px;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 30px;
font-weight: 800;
background: linear-gradient(135deg, #409eff, #7c3aed);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -1px;
line-height: 1.2;
}
/* ── Row 2: 三列洞察卡片 ── */
.dash-row-cols-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 14px;
}
@media (max-width: 1100px) { .dash-row-cols-3 { grid-template-columns: 1fr 1fr; } }
@media (max-width: 700px) { .dash-row-cols-3 { grid-template-columns: 1fr; } }
.insight-card {
border-radius: var(--radius-lg);
border: 1px solid var(--border) !important;
transition: box-shadow 0.2s;
}
.insight-card:hover {
box-shadow: var(--shadow-md) !important;
}
.insight-card :deep(.el-card__body) {
padding: 0 !important;
}
.insight-card.trend-card :deep(.el-card__body) {
padding: 8px 6px 0 !important;
height: 400px;
overflow: hidden;
}
.insight-card:not(.trend-card) :deep(.el-card__body) {
padding: 14px 18px !important;
max-height: 400px;
overflow-y: auto;
}
.insight-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.trend-day-btns {
display: flex;
gap: 4px;
}
.trend-day-btn {
padding: 2px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-card);
font-size: 11px;
color: var(--text-tertiary);
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.trend-day-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.trend-day-btn.active {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
/* ECharts */
.trend-chart-echarts {
width: 100%;
height: 100%;
min-height: 320px;
}
/* Trend summary */
.trend-summary-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
padding: 12px 14px 6px;
border-bottom: 1px solid var(--border-light);
}
.trend-summary-item { text-align: center; }
.trend-summary-num {
display: block;
font-size: 17px;
font-weight: 700;
color: var(--text);
line-height: 1.3;
}
.trend-summary-desc {
display: block;
font-size: 11px;
color: var(--text-tertiary);
margin-top: 2px;
}
/* Keywords */
.keyword-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-content: flex-start;
}
.keyword-tag { cursor: default; }
.kw-count {
font-size: 9px;
margin-left: 2px;
opacity: 0.7;
font-weight: 400;
}
/* IP list */
.ip-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.ip-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: var(--radius-sm);
background: var(--bg);
transition: background 0.2s;
}
.ip-row:hover {
background: var(--border-light);
}
.ip-rank {
width: 20px;
height: 20px;
border-radius: 50%;
background: linear-gradient(135deg, #409eff, #7c3aed);
color: #fff;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ip-addr {
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 13px;
color: var(--text);
font-weight: 500;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ip-loc {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
background: var(--primary-soft);
color: var(--primary);
white-space: nowrap;
flex-shrink: 0;
}
.ip-count {
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
flex-shrink: 0;
}
/* Province */
.province-list {
display: flex;
flex-direction: column;
gap: 5px;
}
.province-row {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
border-radius: var(--radius-sm);
background: var(--bg);
}
.province-row:hover {
background: var(--border-light);
}
.province-rank {
width: 20px;
height: 20px;
border-radius: 50%;
background: linear-gradient(135deg, #409eff, #7c3aed);
color: #fff;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.province-bar-wrap {
flex: 1;
height: 16px;
background: var(--border-light);
border-radius: 8px;
overflow: hidden;
min-width: 0;
}
.province-bar {
display: block;
height: 100%;
border-radius: 8px;
transition: width 0.4s ease;
min-width: 4px;
}
.province-name {
font-size: 13px;
color: var(--text);
font-weight: 500;
white-space: nowrap;
width: 90px;
text-align: right;
flex-shrink: 0;
}
.province-count {
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
width: 50px;
text-align: right;
flex-shrink: 0;
}
/* ── Storage section ── */
.storage-card {
border-radius: var(--radius-lg);
border: 1px solid var(--border) !important;
}
.storage-grid {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.storage-drive-card {
flex: 1 1 280px;
min-width: 260px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 16px;
background: var(--bg-card);
transition: box-shadow 0.2s, transform 0.2s;
position: relative;
overflow: hidden;
}
.storage-drive-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #409eff, #7c3aed);
}
.storage-drive-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.drive-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.drive-icon {
width: 22px;
height: 22px;
border-radius: 5px;
flex-shrink: 0;
object-fit: contain;
}
.drive-name {
font-size: 14px;
font-weight: 600;
color: var(--text);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drive-status {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
flex-shrink: 0;
}
.drive-status.active { background: #f0f9eb; color: #67c23a; }
.drive-status.inactive { background: var(--bg); color: var(--text-tertiary); }
.drive-space {
font-size: 12px;
margin-bottom: 8px;
display: flex;
align-items: baseline;
gap: 3px;
}
.drive-used { color: var(--text); font-weight: 600; }
.drive-sep { color: var(--border); }
.drive-total { color: var(--text-secondary); font-weight: 500; }
/* ── Cloud toggle ── */
.config-card {
margin-bottom: 20px;
}
.config-card :deep(.el-card__header) {
background: var(--bg-card-header);
}
.cloud-toggle-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.cloud-toggle-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-radius: var(--radius-sm);
background: var(--bg);
border: 1px solid var(--border);
transition: background 0.2s, box-shadow 0.15s;
}
.cloud-toggle-chip:hover {
background: var(--border-light);
box-shadow: var(--shadow-sm);
}
.cloud-icon-img {
width: 20px;
height: 20px;
border-radius: 4px;
flex-shrink: 0;
object-fit: contain;
}
.cloud-label {
font-size: 13px;
font-weight: 500;
flex: 1;
}
</style>
@@ -0,0 +1,312 @@
<template>
<div class="admin-layout">
<aside class="admin-sidebar">
<div class="sidebar-brand">
<div class="sidebar-logo">☁️</div>
<div class="sidebar-brand-text">
<h2>{{ siteName || 'CloudSearch' }}</h2>
<p>管理控制台</p>
</div>
</div>
<el-menu
:default-active="activeMenu"
class="sidebar-menu"
@select="handleMenuSelect"
>
<el-menu-item index="dashboard">
<el-icon><DataBoard /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-sub-menu index="cloud-configs">
<template #title>
<el-icon><Connection /></el-icon>
<span>网盘管理</span>
</template>
<el-menu-item index="cloud-configs-toggle">📋 设置及授权</el-menu-item>
<el-menu-item index="cloud-configs-cleanup">🧹 存储清理</el-menu-item>
</el-sub-menu>
<el-sub-menu index="system">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统设置</span>
</template>
<el-menu-item index="sys-site">🌐 网站设置</el-menu-item>
<el-menu-item index="sys-services">🔗 外部服务 & 缓存</el-menu-item>
<el-menu-item index="sys-strategy">⚡ 性能配置</el-menu-item>
<el-menu-item index="sys-password">🔑 修改密码</el-menu-item>
<el-menu-item index="sys-notify">📬 消息推送</el-menu-item>
</el-sub-menu>
<el-menu-item index="save-records">
<el-icon><DocumentCopy /></el-icon>
<span>转存日志</span>
</el-menu-item>
<div class="sidebar-spacer"></div>
<div class="sidebar-version">v{{ appVersion }}</div>
<el-menu-item index="logout">
<el-icon><SwitchButton /></el-icon>
<span>退出登录</span>
</el-menu-item>
</el-menu>
</aside>
<div class="admin-content">
<header class="content-header">
<div class="content-breadcrumb">
<span class="breadcrumb-current">{{ pageTitle }}</span>
</div>
<div class="content-actions">
<el-button text size="small" @click="goBackHome">
<el-icon><ArrowLeft /></el-icon>
返回前台
</el-button>
</div>
</header>
<main class="content-body">
<router-view />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { DataBoard, Connection, Setting, SwitchButton, DocumentCopy, ArrowLeft } from '@element-plus/icons-vue'
import { getSiteConfig } from '../../api'
const router = useRouter()
const route = useRoute()
const siteName = ref('')
const appVersion = ref('')
const pageTitles: Record<string, string> = {
dashboard: '仪表盘',
'cloud-configs-toggle': '网盘设置及授权',
'cloud-configs-cleanup': '存储清理',
'sys-site': '网站设置',
'sys-services': '外部服务 & 缓存',
'sys-strategy': '性能配置',
'sys-password': '修改管理员密码',
'sys-notify': '消息推送',
'save-records': '转存日志',
}
const activeMenu = computed(() => {
const name = route.name as string
if (name === 'admin-cloud-configs') return 'cloud-configs-toggle'
if (name === 'admin-cleanup') return 'cloud-configs-cleanup'
if (name === 'admin-system') {
const sec = route.query.section as string
return sec || 'sys-site'
}
if (name === 'admin-save-records') return 'save-records'
return 'dashboard'
})
const pageTitle = computed(() => {
return pageTitles[activeMenu.value] || '仪表盘'
})
function handleMenuSelect(index: string) {
if (index === 'dashboard') {
router.push('/admin/dashboard')
} else if (index === 'cloud-configs-toggle') {
router.push('/admin/cloud-configs')
} else if (index === 'cloud-configs-cleanup') {
router.push('/admin/cleanup')
} else if (index.startsWith('sys-')) {
router.push({ path: '/admin/system', query: { section: index } })
} else if (index === 'save-records') {
router.push('/admin/save-records')
} else if (index === 'logout') {
localStorage.removeItem('admin_token')
router.push('/admin/login')
}
}
function goBackHome() {
router.push('/')
}
onMounted(async () => {
try {
const cfg = await getSiteConfig()
siteName.value = cfg.site_name || ''
} catch {}
try {
const h = await fetch('/health')
const hv = await h.json()
appVersion.value = hv.version
} catch {}
})
</script>
<style scoped>
.admin-layout {
display: flex;
height: 100vh;
background: var(--bg-page);
}
/* ── Sidebar ── */
.admin-sidebar {
width: var(--sidebar-w);
flex-shrink: 0;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #111827 0%, #1e293b 100%);
position: relative;
z-index: 10;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 20px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.sidebar-logo {
font-size: 28px;
line-height: 1;
flex-shrink: 0;
}
.sidebar-brand-text h2 {
font-size: 16px;
font-weight: 700;
margin: 0;
color: #fff;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-brand-text p {
font-size: 11px;
margin: 2px 0 0;
color: rgba(255, 255, 255, 0.45);
letter-spacing: 1px;
}
/* ── Menu ── */
.sidebar-menu {
flex: 1;
display: flex;
flex-direction: column;
background: transparent !important;
border-right: none !important;
padding: 4px 0;
}
.sidebar-menu :deep(.el-menu-item),
.sidebar-menu :deep(.el-sub-menu__title) {
color: rgba(255, 255, 255, 0.65);
height: 44px;
line-height: 44px;
transition: all 0.2s ease;
margin: 0 6px;
border-radius: var(--radius-sm);
}
.sidebar-menu :deep(.el-menu-item):hover,
.sidebar-menu :deep(.el-sub-menu__title):hover {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.9);
}
.sidebar-menu :deep(.el-menu-item.is-active) {
color: #fff;
background: linear-gradient(90deg, rgba(64, 158, 255, 0.25) 0%, rgba(99, 102, 241, 0.15) 100%);
font-weight: 500;
}
.sidebar-menu :deep(.el-menu-item)::after {
display: none;
}
.sidebar-menu :deep(.el-sub-menu .el-menu) {
background: rgba(0, 0, 0, 0.2) !important;
}
.sidebar-menu :deep(.el-sub-menu .el-menu .el-menu-item) {
padding-left: 52px !important;
font-size: 13px;
height: 38px;
line-height: 38px;
}
.sidebar-menu :deep(.el-icon) {
font-size: 16px;
}
.sidebar-spacer {
flex: 1;
}
.sidebar-version {
text-align: center;
font-size: 11px;
color: rgba(255, 255, 255, 0.25);
padding: 8px 0;
letter-spacing: 0.5px;
}
/* ── Content Area ── */
.admin-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.content-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 28px;
background: var(--bg-card);
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
}
.content-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
}
.breadcrumb-current {
font-size: 18px;
font-weight: 700;
color: var(--text);
}
.content-actions :deep(.el-button) {
color: var(--text-secondary);
gap: 4px;
}
.content-body {
flex: 1;
overflow-y: auto;
padding: 24px 28px;
}
/* ── Global page save bar ── */
.content-body :deep(.save-bar) {
position: fixed;
bottom: 32px;
right: 32px;
z-index: 100;
background: var(--bg-card);
padding: 12px 16px;
border-radius: var(--radius-lg);
border: 1px solid var(--border);
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
display: flex;
gap: 10px;
transition: box-shadow 0.2s, transform 0.2s;
}
.content-body :deep(.save-bar:hover) {
box-shadow: 0 6px 24px rgba(0,0,0,0.18);
transform: translateY(-2px);
}
</style>
+169
View File
@@ -0,0 +1,169 @@
<template>
<div class="admin-login-page">
<div class="login-bg-pattern"></div>
<div class="login-card">
<div class="login-brand">
<div class="login-logo">☁️</div>
<h1 class="login-title">{{ siteName || 'CloudSearch' }}</h1>
<p class="login-subtitle">管理后台</p>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="0" size="large" @keyup.enter="handleLogin">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" class="login-btn" @click="handleLogin">
{{ loading ? '登录中...' : '登 录' }}
</el-button>
</el-form-item>
</el-form>
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
<p class="login-footer">CloudSearch v{{ appVersion }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { User, Lock } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getSiteConfig, adminLogin } from '../../api'
import type { ElForm } from 'element-plus'
const formRef = ref<InstanceType<typeof ElForm>>()
const loading = ref(false)
const errorMsg = ref('')
const siteName = ref('')
const appVersion = ref('')
getSiteConfig().then(cfg => {
if (cfg.site_name) siteName.value = cfg.site_name
}).catch(() => {})
const form = reactive({
username: '',
password: '',
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
async function handleLogin() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
errorMsg.value = ''
try {
const res = await adminLogin(form.username, form.password)
localStorage.setItem('admin_token', res.token)
ElMessage.success('登录成功')
window.location.href = '/admin'
} catch (e: any) {
errorMsg.value = e?.response?.data?.message || e?.message || '登录失败'
} finally {
loading.value = false
}
}
onMounted(async () => {
try {
const h = await fetch('/health')
const hv = await h.json()
appVersion.value = hv.version || ''
} catch {}
})
</script>
<style scoped>
.admin-login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
position: relative;
overflow: hidden;
}
.login-bg-pattern {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 20% 50%, rgba(102, 126, 234, 0.12) 0%, transparent 50%),
radial-gradient(circle at 80% 30%, rgba(118, 75, 162, 0.12) 0%, transparent 50%),
radial-gradient(circle at 50% 80%, rgba(64, 158, 255, 0.08) 0%, transparent 50%);
}
.login-card {
position: relative;
width: 400px;
padding: 48px 40px 36px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: var(--radius-xl);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.25);
}
.login-brand {
text-align: center;
margin-bottom: 36px;
}
.login-logo {
font-size: 48px;
margin-bottom: 8px;
line-height: 1;
}
.login-title {
font-size: 26px;
font-weight: 800;
color: #1d2129;
margin: 0 0 4px;
letter-spacing: 1px;
}
.login-subtitle {
font-size: 14px;
color: #86909c;
margin: 0;
letter-spacing: 2px;
}
.login-btn {
width: 100%;
height: 44px;
font-size: 15px;
letter-spacing: 4px;
border-radius: var(--radius-md);
}
.error-msg {
text-align: center;
color: #f56c6c;
font-size: 13px;
margin-top: 12px;
padding: 8px 12px;
background: #fef0f0;
border-radius: var(--radius-sm);
line-height: 1.4;
}
.login-footer {
text-align: center;
color: #c9cdd4;
font-size: 11px;
margin-top: 20px;
margin-bottom: 0;
}
[data-theme="dark"] .login-card {
background: rgba(29, 29, 29, 0.95);
}
[data-theme="dark"] .login-title {
color: #e5e5e5;
}
[data-theme="dark"] .login-subtitle {
color: #666666;
}
[data-theme="dark"] .error-msg {
background: rgba(245, 108, 108, 0.12);
}
</style>
@@ -0,0 +1,355 @@
<template>
<div class="cleanup-section">
<el-card class="config-card">
<template #header><span>🧹 存储清理</span></template>
<div class="cleanup-grid">
<!-- 列1: ⏱ 基础清理策略 -->
<div class="cleanup-group">
<div class="cleanup-group-label">⏱ 基础清理策略</div>
<el-form label-width="120px" label-position="left" size="small">
<el-form-item label="自动清理">
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<el-switch v-model="cleanupEnabled" size="small" />
<span class="cleanup-hint">每天自动检查一次,删除过期日志、移入回收站文件</span>
</div>
</el-form-item>
<el-form-item label="清空回收站">
<div style="display: flex; align-items: center; gap: 10px;">
<el-switch v-model="cleanupEmptyTrash" size="small" />
<span class="cleanup-hint">清理时一并清空各网盘回收站</span>
</div>
</el-form-item>
<el-form-item label="白名单目录">
<div style="width: 100%;">
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px;" v-if="whitelistDirs.length">
<el-tag v-for="(dir, i) in whitelistDirs" :key="i" closable size="small" @close="removeWhitelistDir(i)">{{ dir }}</el-tag>
</div>
<div style="display: flex; gap: 6px;">
<el-input v-model="newWhitelistDir" placeholder="输入目录名" size="small" style="width: 160px" @keyup.enter="addWhitelistDir" />
<el-button type="primary" size="small" @click="addWhitelistDir">添加</el-button>
</div>
</div>
</el-form-item>
</el-form>
</div>
<!-- 列2: 📦 保留设置 -->
<div class="cleanup-group">
<div class="cleanup-group-label">📦 保留设置</div>
<el-form label-width="120px" label-position="left" size="small">
<el-form-item label="文件保留">
<div style="display: flex; align-items: center; gap: 8px;">
<el-input-number v-model="cleanupFileRetentionDays" :min="1" :max="365" style="width: 100px" size="small" />
<span>天</span>
</div>
</el-form-item>
<el-form-item label="日志保留">
<div style="display: flex; align-items: center; gap: 8px;">
<el-input-number v-model="cleanupLogRetentionDays" :min="1" :max="365" style="width: 100px" size="small" />
<span>天</span>
</div>
</el-form-item>
<el-form-item label="Cookie检测">
<div style="display: flex; align-items: center; gap: 8px;">
<el-input-number v-model="verifyIntervalMinutes" :min="5" :max="1440" :step="5" style="width: 100px" size="small" />
<span>分钟</span>
</div>
</el-form-item>
<el-form-item label="空间校准">
<div style="display: flex; align-items: center; gap: 8px;">
<el-input-number v-model="storageRefreshIntervalMinutes" :min="5" :max="1440" :step="5" style="width: 100px" size="small" />
<span>分钟</span>
</div>
</el-form-item>
</el-form>
</div>
<!-- 列3: 📊 空间阈值自动清理 -->
<div class="cleanup-group">
<div class="cleanup-group-label">📊 空间阈值自动清理</div>
<el-form label-width="120px" label-position="left" size="small">
<el-form-item label="启用">
<el-switch v-model="cleanupSpaceThresholdEnabled" size="small" />
<span class="cleanup-hint" style="margin-left: 8px;">已用空间超过阈值时,按比例删除最旧的转存文件</span>
</el-form-item>
<el-form-item v-if="cleanupSpaceThresholdEnabled" label="使用阈值">
<el-slider v-model="cleanupSpaceThresholdPercent" :min="50" :max="99" style="width: 140px" show-input size="small" />
</el-form-item>
<el-form-item v-if="cleanupSpaceThresholdEnabled" label="删除比例">
<el-slider v-model="cleanupSpaceThresholdDeletePercent" :min="5" :max="50" :step="5" style="width: 140px" show-input size="small" />
</el-form-item>
</el-form>
</div>
<!-- 列4: 🔗 分享链接复用 -->
<div class="cleanup-group">
<div class="cleanup-group-label">🔗 分享链接复用</div>
<el-form label-width="120px" label-position="left" size="small">
<el-form-item label="复用">
<el-switch v-model="saveReuseEnabled" size="small" />
<span class="cleanup-hint" style="margin-left: 8px;">相同原始链接不再重复转存,复用已有分享链接(会自动验证原链接有效性;60秒内重复请求直接返回已有链接)</span>
</el-form-item>
</el-form>
</div>
</div>
<!-- 底部:手动操作(跨列全宽) -->
<div class="cleanup-actions">
<div class="cleanup-actions-buttons">
<el-button type="primary" :loading="cleanupSaving" @click="handleSaveCleanupConfigs">💾 保存清理配置</el-button>
<el-button type="danger" :loading="cleanupRunning" @click="handleRunCleanup">{{ cleanupRunning ? '清理中...' : '🗑️ 立即清理' }}</el-button>
<el-button type="warning" :loading="emptyTrashRunning" @click="handleEmptyTrash">{{ emptyTrashRunning ? '清空中...' : '🧹 清空回收站' }}</el-button>
</div>
<div v-if="lastCleanupTime" class="cleanup-info">
⏰ 上次清理:{{ lastCleanupTime }}
<span v-if="lastCleanupStats" style="margin-left: 12px;">📊 {{ lastCleanupStats }}</span>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getSystemConfigs, updateSystemConfigs, runCleanup, emptyAllTrash } from '../../api'
interface ConfigMap {
[key: string]: string | number
}
const sysConfigs = reactive<ConfigMap>({})
const cleanupRunning = ref(false)
const emptyTrashRunning = ref(false)
const cleanupSaving = ref(false)
const lastCleanupTime = computed(() => String(sysConfigs.cleanup_last_run || ''))
const lastCleanupStats = computed(() => {
const raw = String(sysConfigs.cleanup_last_stats || '')
if (!raw) return ''
try {
const s = JSON.parse(raw)
const parts = []
if (s.filesTrashed > 0) parts.push(`移入回收站 ${s.filesTrashed} 个文件夹`)
if (s.logsDeleted > 0) parts.push(`删除 ${s.logsDeleted} 条日志`)
if (s.trashEmptied) parts.push(`已清空回收站`)
if (s.errors > 0) parts.push(`⚠️ ${s.errors} 个错误`)
return parts.join(' / ') || '无操作'
} catch { return '' }
})
const cleanupEnabled = computed({
get: () => String(sysConfigs.cleanup_enabled) === 'true',
set: (val: boolean) => { sysConfigs.cleanup_enabled = val ? 'true' : 'false' },
})
const cleanupEmptyTrash = computed({
get: () => String(sysConfigs.cleanup_empty_trash) !== 'false',
set: (val: boolean) => { sysConfigs.cleanup_empty_trash = val ? 'true' : 'false' },
})
const cleanupFileRetentionDays = computed({
get: () => Number(sysConfigs.cleanup_file_retention_days ?? 7),
set: (val: number) => { sysConfigs.cleanup_file_retention_days = val },
})
const cleanupLogRetentionDays = computed({
get: () => Number(sysConfigs.cleanup_log_retention_days ?? 30),
set: (val: number) => { sysConfigs.cleanup_log_retention_days = val },
})
const cleanupSpaceThresholdEnabled = computed({
get: () => String(sysConfigs.cleanup_space_threshold_enabled) === 'true',
set: (val: boolean) => { sysConfigs.cleanup_space_threshold_enabled = val ? 'true' : 'false' },
})
const cleanupSpaceThresholdPercent = computed({
get: () => Number(sysConfigs.cleanup_space_threshold_percent ?? 90),
set: (val: number) => { sysConfigs.cleanup_space_threshold_percent = val },
})
const cleanupSpaceThresholdDeletePercent = computed({
get: () => Number(sysConfigs.cleanup_space_threshold_delete_percent ?? 10),
set: (val: number) => { sysConfigs.cleanup_space_threshold_delete_percent = val },
})
const saveReuseEnabled = computed({
get: () => String(sysConfigs.save_reuse_enabled) !== 'false',
set: (val: boolean) => { sysConfigs.save_reuse_enabled = val ? 'true' : 'false' },
})
// 白名单
const whitelistDirs = ref<string[]>([])
const newWhitelistDir = ref('')
function loadWhitelistDirs() {
try {
const raw = String(sysConfigs.cleanup_whitelist_dirs || '[]')
whitelistDirs.value = JSON.parse(raw)
} catch {
whitelistDirs.value = []
}
}
function addWhitelistDir() {
const name = newWhitelistDir.value.trim()
if (!name) return
if (whitelistDirs.value.includes(name)) {
ElMessage.warning('该目录已在白名单中')
return
}
whitelistDirs.value.push(name)
newWhitelistDir.value = ''
}
function removeWhitelistDir(index: number) {
whitelistDirs.value.splice(index, 1)
}
// Cookie检测间隔 + 空间校准间隔
const verifyIntervalMinutes = computed({
get: () => Number(sysConfigs.cleanup_verify_interval ?? 30),
set: (val: number) => { sysConfigs.cleanup_verify_interval = val },
})
const storageRefreshIntervalMinutes = computed({
get: () => Number(sysConfigs.storage_refresh_interval ?? 180),
set: (val: number) => { sysConfigs.storage_refresh_interval = val },
})
async function loadCleanupConfigs() {
try {
const raw = await getSystemConfigs()
for (const cfg of raw) {
sysConfigs[cfg.key] = cfg.value
}
loadWhitelistDirs()
} catch (e) {
console.error('加载清理配置失败', e)
}
}
async function handleSaveCleanupConfigs() {
cleanupSaving.value = true
try {
const keys = [
'cleanup_enabled', 'cleanup_file_retention_days', 'cleanup_log_retention_days',
'cleanup_empty_trash',
'cleanup_space_threshold_enabled', 'cleanup_space_threshold_percent', 'cleanup_space_threshold_delete_percent',
'save_reuse_enabled',
'cleanup_verify_interval', 'storage_refresh_interval',
]
const entries = keys.map(key => ({ key, value: String(sysConfigs[key] ?? '') }))
entries.push({ key: 'cleanup_whitelist_dirs', value: JSON.stringify(whitelistDirs.value) })
await updateSystemConfigs(entries)
ElMessage.success('清理配置已保存')
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '保存失败')
} finally {
cleanupSaving.value = false
}
}
async function handleRunCleanup() {
cleanupRunning.value = true
try {
const result = await runCleanup()
if (result.success) {
ElMessage.success(result.message)
} else {
ElMessage.warning(result.message)
}
await loadCleanupConfigs()
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '清理失败')
} finally {
cleanupRunning.value = false
}
}
async function handleEmptyTrash() {
emptyTrashRunning.value = true
try {
const result = await emptyAllTrash()
if (result.success) {
ElMessage.success(result.message)
} else {
ElMessage.warning(result.message)
}
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '清空回收站失败')
} finally {
emptyTrashRunning.value = false
}
}
onMounted(() => {
loadCleanupConfigs()
})
</script>
<style scoped>
.cleanup-section .config-card {
/* 全宽展示,没有max-width限制 */
}
.cleanup-section :deep(.el-card__header) {
font-size: 16px;
font-weight: 600;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border);
}
/* ── 2列网格布局 ── */
.cleanup-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.cleanup-group {
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 16px 18px;
background: var(--bg-card);
transition: box-shadow 0.2s;
}
.cleanup-group:hover {
box-shadow: var(--shadow-sm);
}
.cleanup-group-label {
font-size: 14px;
font-weight: 600;
color: var(--primary);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px dashed var(--border-light);
}
.cleanup-hint {
color: var(--text-tertiary);
font-size: 12px;
line-height: 1.5;
}
/* ── 底部操作栏(跨列全宽) ── */
.cleanup-actions {
margin-top: 20px;
padding: 16px 18px;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.cleanup-actions-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.cleanup-info {
font-size: 13px;
color: var(--text-tertiary);
display: flex;
flex-wrap: wrap;
gap: 4px;
}
/* ── 响应式:窄屏时改为单列 ── */
@media (max-width: 900px) {
.cleanup-grid {
grid-template-columns: 1fr;
}
}
</style>
+763
View File
@@ -0,0 +1,763 @@
<template>
<div class="cloud-config">
<!-- 网盘类型开关 -->
<el-card class="toggle-card" style="margin-bottom: 20px;">
<template #header><span>📂 网盘设置及授权</span></template>
<div class="cloud-toggle-grid">
<div
v-for="ct in cloudTypes"
:key="ct.type"
class="cloud-toggle-chip"
>
<img :src="ct.icon" class="cloud-icon-img" />
<span class="cloud-label">{{ ct.label }}</span>
<el-tag v-if="ct.type === 'others'" size="small" type="info">关</el-tag>
<el-switch
:model-value="ct.enabled"
size="small"
@change="(val: boolean) => handleCloudToggle(ct.type, val)"
/>
</div>
</div>
<div class="form-tip" style="margin-top: 12px;">
关闭的网盘类型在搜索结果中不会展示。修改后立即生效,无需点击保存。
</div>
</el-card>
<div class="toolbar">
<el-button type="primary" @click="openDialog(null)">新增配置</el-button>
<el-button @click="verifyAll">全部重新验证</el-button>
</div>
<el-card shadow="never" class="table-card">
<template #header><span>📋 网盘配置列表</span></template>
<el-table :data="configs" stripe style="width: 100%" empty-text="暂无网盘配置,点击上方「新增配置」添加">
<el-table-column label="网盘类型" width="110">
<template #default="{ row }">
<CloudBadge :cloud_type="row.cloud_type" />
</template>
</el-table-column>
<el-table-column prop="nickname" label="昵称" width="140">
<template #default="{ row }">
<span v-if="row.nickname" class="nickname-text">{{ row.nickname }}</span>
<el-text v-else type="info" size="small">未设置</el-text>
</template>
</el-table-column>
<el-table-column prop="promotion_account" label="推广账号" width="160">
<template #default="{ row }">
<span v-if="row.promotion_account" class="promotion-text">{{ row.promotion_account }}</span>
<el-text v-else type="info" size="small">-</el-text>
</template>
</el-table-column>
<el-table-column prop="cloud_type_uid" label="标识(__uid)" width="180">
<template #default="{ row }">
<span v-if="row.cloud_type_uid" class="uid-cell">{{ row.cloud_type_uid }}</span>
<el-text v-else type="info" size="small">-</el-text>
</template>
</el-table-column>
<el-table-column label="验证" width="80" align="center">
<template #default="{ row }">
<span v-if="row._verifying" class="verifying">
<el-icon class="is-loading"><Loading /></el-icon>
</span>
<el-tag v-else-if="row.verification_status === 'valid'" type="success" size="small">有效</el-tag>
<el-tag v-else-if="row.verification_status === 'invalid'" type="danger" size="small">无效</el-tag>
<el-tag v-else type="info" size="small">未验证</el-tag>
</template>
</el-table-column>
<el-table-column label="空间" width="200">
<template #default="{ row }">
<div v-if="row.storage_total && row.storage_total !== '-'" class="storage-cell">
<div class="storage-bar-wrap">
<div
class="storage-bar-fill"
:style="{ width: storagePercent(row) + '%' }"
:class="storageBarClass(row)"
></div>
</div>
<div class="storage-text">
<span class="storage-used">{{ row.storage_used || '计算中...' }}</span>
<span class="storage-sep">/</span>
<span class="storage-total">{{ row.storage_total }}</span>
<span class="storage-free">(可用 {{ storageFree(row) }})</span>
</div>
</div>
<el-text v-else type="info" size="small">—</el-text>
</template>
</el-table-column>
<!-- 转存统计 -->
<el-table-column label="转存数" width="80" align="center">
<template #default="{ row }">
<span v-if="row.total_saves > 0" class="save-count">{{ row.total_saves }}次</span>
<el-text v-else type="info" size="small">-</el-text>
</template>
</el-table-column>
<el-table-column label="转存启用" width="80" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.is_transfer_enabled !== 0"
size="small"
@change="(val: boolean) => handleToggleTransfer(row, val)"
/>
</template>
</el-table-column>
<el-table-column label="默认账号" width="100" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.is_primary === 1"
:disabled="!row.is_transfer_enabled"
size="small"
@change="(val: boolean) => handleTogglePrimary(row, val)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="390" align="center">
<template #default="{ row }">
<el-button text type="primary" @click="openDialog(row)">编辑</el-button>
<el-button text type="primary" @click="verifyOne(row)">验证</el-button>
<el-popconfirm title="确定删除该配置?" @confirm="handleDelete(row)">
<template #reference>
<el-button text type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑配置' : '新增配置'" width="560px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<!-- 白名单目录提示 -->
<el-alert
v-show="whitelistDirs.length > 0"
type="warning"
show-icon
:closable="false"
style="margin-bottom: 18px;"
>
<template #title>
<div style="line-height: 1.6;">
<div>🧹 请在网盘主目录内创建:<b>{{ whitelistDirs.join('、') }}</b> 目录</div>
<div>并将你的重要文件移至该目录,<b>只有这个目录不会被自动清理</b></div>
</div>
</template>
</el-alert>
<el-form-item label="网盘类型" prop="cloud_type">
<el-select v-model="form.cloud_type" style="width: 100%" :disabled="!!editingId" @change="onCloudTypeChange">
<el-option
v-for="[key, label] in cloudTypeOptions"
:key="key"
:label="label"
:value="key"
/>
</el-select>
</el-form-item>
<el-form-item label="推广平台及账号" prop="promotion_account" style="margin-bottom: 18px;">
<el-input
v-model="form.promotion_account"
placeholder="请填写您的推广平台及账号,例:蜂小推-13288889999"
clearable
/>
</el-form-item>
<el-form-item label="Cookie" prop="cookie">
<el-input
v-model="form.cookie"
type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
:placeholder="cookiePlaceholder"
input-style="font-family: monospace; font-size: 12px;"
/>
</el-form-item>
<!-- Cookie 获取教程(根据网盘类型切换) -->
<el-form-item label=" " v-if="form.cloud_type && form.cloud_type !== ''" class="cookie-tips-item">
<div class="cookie-tips" :class="`cookie-tips-${form.cloud_type}`">
<div class="cookie-tips-header">
<span class="cookie-tips-title">📖 {{ cloudTypeLabel }} Cookie 获取教程</span>
</div>
<ol class="cookie-tips-steps" v-html="cookieTutorialHtml"></ol>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { Loading } from '@element-plus/icons-vue'
import { CLOUD_LABELS } from '../../types'
import type { CloudType, CloudConfig } from '../../types'
import { ElMessage } from 'element-plus'
import { getCloudConfigs, saveCloudConfig, updateCloudConfig, deleteCloudConfig, testCloudConnection, getCloudTypes, toggleCloudType, setPrimary } from '../../api'
import { getSystemConfigs } from '../../api'
import CloudBadge from '../../components/CloudBadge.vue'
import type { ElForm } from 'element-plus'
interface CloudTypeInfo { type: string; label: string; icon: string; enabled: boolean }
const cloudTypes = ref<CloudTypeInfo[]>([])
const formRef = ref<InstanceType<typeof ElForm>>()
const configs = ref<(CloudConfig & { _verifying?: boolean })[]>([])
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref<number | null>(null)
const defaultForm = () => ({
cloud_type: '' as CloudType | '',
nickname: '',
promotion_account: '',
is_transfer_enabled: false,
cookie: '',
_verifying: false,
_storageUsed: '',
_storageTotal: '',
})
const form = reactive<{
cloud_type: CloudType | ''
nickname: string
promotion_account: string
is_transfer_enabled: boolean
cookie: string
_verifying: boolean
_storageUsed: string
_storageTotal: string
}>(defaultForm())
const rules = computed(() => ({
cloud_type: [{ required: true, message: '请选择网盘类型', trigger: 'change' }],
nickname: [{ required: false, message: '请填写昵称(区分多个同类型网盘)', trigger: 'blur' }],
promotion_account: [{ required: true, message: '请填写推广平台及账号', trigger: 'blur' }],
}))
const cloudTypeOptions = computed(() => {
return Object.entries(CLOUD_LABELS) as [CloudType, string][]
})
const cookiePlaceholder = computed(() => {
if (!form.cloud_type) return '请先选择网盘类型'
const t = form.cloud_type
if (t === 'quark' || t === 'baidu') return `请输入 ${CLOUD_LABELS[t] || t} 的完整 Cookie`
return editingId.value ? '留空则保持原有' : '输入完整 Cookie'
})
const cloudTypeLabel = computed(() => {
return CLOUD_LABELS[form.cloud_type as CloudType] || form.cloud_type || ''
})
/** Cookie 获取教程 HTML(根据不同网盘类型) */
const cookieTutorialHtml = computed(() => {
const t = form.cloud_type
if (!t) return ''
const tutorials: Record<string, string> = {
quark: `<li>在电脑上打开 <a href="https://pan.quark.cn" target="_blank">pan.quark.cn</a> 并登录你的夸克账号</li>
<li>按 <code>F12</code> 打开开发者工具 → 切换到 <strong>网络 (Network)</strong> 选项卡</li>
<li>刷新页面,在请求列表中点击任意一个请求(如 <code>account/info</code>)</li>
<li>在右侧 <strong>请求头 (Request Headers)</strong> 中找到 <code>Cookie</code> 字段</li>
<li>复制整个 Cookie 值(<b>从开头到结束的完整内容</b>),粘贴到上方输入框</li>
<li>点击「<b>自动获取</b>」按钮验证 Cookie 是否有效</li>
<div class="cookie-tips-note">⚠️ 必须包含 <code>__st=s%...</code> 字段!请复制浏览器请求头的 <b>整个 Cookie</b>(F12 → Network → 请求头 → Cookie 项),不要只复制部分。</div>`,
baidu: `<li>在电脑上打开 <a href="https://pan.baidu.com" target="_blank">pan.baidu.com</a> 并登录你的百度账号</li>
<li>按 <code>F12</code> 打开开发者工具 → 切换到 <strong>网络 (Network)</strong> 选项卡</li>
<li>刷新页面,在请求列表中点击任意一个请求</li>
<li>在右侧 <strong>请求头 (Request Headers)</strong> 中找到 <code>Cookie</code> 字段</li>
<li>复制整个 Cookie 值,粘贴到上方输入框</li>
<li>点击「<b>自动获取</b>」按钮验证 Cookie 是否有效</li>
<div class="cookie-tips-note">💡 需要包含 <code>BDUSS</code> 和 <code>STOKEN</code></div>`,
aliyun: `<li>在电脑上打开 <a href="https://www.aliyundrive.com" target="_blank">aliyundrive.com</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「自动获取」验证</li>
<div class="cookie-tips-note">💡 需包含 <code>token</code> 等有效字段</div>`,
'115': `<li>在电脑上打开 <a href="https://115.com" target="_blank">115.com</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「自动获取」验证</li>
<div class="cookie-tips-note">💡 需包含 <code>UID</code>、<code>CID</code>、<code>SEID</code> 等字段</div>`,
tianyi: `<li>在电脑上打开 <a href="https://cloud.189.cn" target="_blank">cloud.189.cn</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「自动获取」验证</li>
<div class="cookie-tips-note">💡 需包含 <code>COOKIE_LOGIN_USER</code>、<code>SESSION</code> 等字段</div>`,
'123pan': `<li>在电脑上打开 <a href="https://www.123pan.com" target="_blank">123pan.com</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
uc: `<li>在电脑上打开 <a href="https://drive.uc.cn" target="_blank">drive.uc.cn</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
xunlei: `<li>在电脑上打开 <a href="https://pan.xunlei.com" target="_blank">pan.xunlei.com</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
pikpak: `<li>在电脑上打开 <a href="https://www.mypikpak.com" target="_blank">mypikpak.com</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
}
return tutorials[t] || `<li>在电脑上打开该网盘网站并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,复制任意请求的 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「自动获取」验证</li>`
})
onMounted(async () => {
await loadConfigs()
await loadCloudTypes()
await loadSystemConfigs()
})
// 每30分钟自动验证一次
let verifyTimer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
verifyTimer = setInterval(() => {
autoVerifyAll()
}, 30 * 60 * 1000)
})
onUnmounted(() => {
if (verifyTimer) clearInterval(verifyTimer)
})
async function loadCloudTypes() {
try {
const result = await getCloudTypes()
cloudTypes.value = result.types
} catch (e) { console.error('加载网盘类型失败', e) }
}
/** 加载系统配置,提取清理白名单目录 */
const systemConfigs = ref<Record<string, string>>({})
const whitelistDirs = computed(() => {
try {
const raw = systemConfigs.value.cleanup_whitelist_dirs || '[]'
const arr = JSON.parse(raw)
return Array.isArray(arr) && arr.length > 0 ? arr : []
} catch { return [] }
})
async function loadSystemConfigs() {
try {
const list = await getSystemConfigs()
const map: Record<string, string> = {}
for (const item of list) {
map[item.key] = item.value
}
systemConfigs.value = map
} catch (e) { console.error('加载系统配置失败', e) }
}
async function handleCloudToggle(type: string, enabled: boolean) {
const ct = cloudTypes.value.find(c => c.type === type)
if (!ct) return
try {
await toggleCloudType(type, enabled)
ct.enabled = enabled
} catch (e: any) { ElMessage.error(e.message || '切换失败'); ct.enabled = !enabled }
}
async function loadConfigs() {
try {
configs.value = await getCloudConfigs()
} catch (e) {
console.error('加载网盘配置失败', e)
}
}
async function handleToggleTransfer(row: CloudConfig, enabled: boolean) {
const newVal = enabled ? 1 : 0
try {
await updateCloudConfig({
id: row.id!,
cloud_type: row.cloud_type,
nickname: row.nickname || '',
promotion_account: row.promotion_account || '',
is_transfer_enabled: newVal,
is_active: row.is_active,
cookie: undefined, // don't send cookie on toggle-only
})
row.is_transfer_enabled = newVal
ElMessage.success(enabled ? '转存已开启' : '转存已关闭')
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '操作失败')
}
}
async function handleTogglePrimary(row: CloudConfig, enabled: boolean) {
try {
await setPrimary(row.id!, enabled)
row.is_primary = enabled ? 1 : 0
ElMessage.success(enabled ? `已将「${row.nickname || row.cloud_type}」设为默认账号` : '已取消默认账号')
} catch (e: any) {
ElMessage.error(e.response?.data?.error || e.message || '操作失败')
}
}
async function autoVerifyAll() {
for (const cfg of configs.value) {
if (cfg.cookie_preview || cfg.nickname) {
await verifyOne(cfg, true)
}
}
}
async function verifyAll() {
for (const cfg of configs.value) {
if ((cfg.cookie_preview || cfg.nickname) && !cfg._verifying) {
await verifyOne(cfg, false)
}
}
ElMessage.success('全部验证完成')
}
async function verifyOne(row: CloudConfig & { _verifying?: boolean }, silent = false) {
if (!row.cookie_preview && !row.nickname) {
if (!silent) ElMessage.warning('该配置没有 Cookie,请先编辑保存后再验证')
return
}
row._verifying = true
try {
const result = await testCloudConnection(row.cloud_type, undefined, row.id)
row.verification_status = result.success ? 'valid' : 'invalid'
if (result.success) {
if (result.nickname && !row.nickname) row.nickname = result.nickname
if (result.storage_used) row.storage_used = result.storage_used
if (result.storage_total) row.storage_total = result.storage_total
if (!silent) ElMessage.success(`${CLOUD_LABELS[row.cloud_type]}:${result.message}`)
} else {
if (!silent) ElMessage.error(`${CLOUD_LABELS[row.cloud_type]}:${result.message}`)
}
} catch (e: any) {
row.verification_status = 'invalid'
if (!silent) ElMessage.error(`${CLOUD_LABELS[row.cloud_type]}:验证失败`)
} finally {
row._verifying = false
}
}
function openDialog(row: CloudConfig | null) {
if (row) {
editingId.value = row.id ?? null
form.cloud_type = row.cloud_type
form.nickname = row.nickname || ''
form.promotion_account = row.promotion_account || ''
form.is_transfer_enabled = row.is_transfer_enabled !== 0
form.cookie = row.cookie || ''
form._verifying = false
form._storageUsed = ''
form._storageTotal = ''
} else {
editingId.value = null
form.cloud_type = '' as CloudType | ''
form.nickname = ''
form.promotion_account = ''
form.is_transfer_enabled = false
form.cookie = ''
form._verifying = false
form._storageUsed = ''
form._storageTotal = ''
}
dialogVisible.value = true
}
function onCloudTypeChange() {
// Cookie 输入框提示会自动更新(computed)
}
async function handleSave() {
// 1. 表单校验(含推广账号必填)
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
// 2. 如果有 Cookie,验证 Cookie 有效/无效(不回填昵称和空间)
if (form.cookie) {
try {
const verifyResult = await testCloudConnection(form.cloud_type as CloudType, form.cookie)
if (!verifyResult.success) {
ElMessage.error(`Cookie验证失败:${verifyResult.message}`)
saving.value = false
return
}
} catch (e: any) {
ElMessage.error(`Cookie验证失败:${e.response?.data?.error || '网络错误'}`)
saving.value = false
return
}
}
// 3. 保存配置
if (editingId.value) {
await updateCloudConfig({
id: editingId.value,
cloud_type: form.cloud_type as CloudType,
nickname: form.nickname,
promotion_account: form.promotion_account,
is_transfer_enabled: form.is_transfer_enabled ? 1 : 0,
cookie: form.cookie || undefined,
is_active: 1,
})
ElMessage.success('配置更新成功')
} else {
const saved = await saveCloudConfig({
cloud_type: form.cloud_type as CloudType,
nickname: form.nickname,
promotion_account: form.promotion_account,
is_transfer_enabled: form.is_transfer_enabled ? 1 : 0,
cookie: form.cookie,
is_active: 1,
})
ElMessage.success('配置保存成功')
}
dialogVisible.value = false
editingId.value = null
await loadConfigs()
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '保存失败')
} finally {
saving.value = false
}
}
async function handleDelete(row: CloudConfig) {
try {
await deleteCloudConfig(row.id!)
ElMessage.success('删除成功')
await loadConfigs()
} catch (e) {
ElMessage.error('删除失败')
}
}
/** 解析字节数 → 数值 */
function parseBytes(s: string): number {
const m = s.match(/^([\d.]+)\s*(B|KB|MB|GB|TB)$/i)
if (!m) return 0
const n = parseFloat(m[1])
const units: Record<string, number> = { B: 1, KB: 1024, MB: 1024**2, GB: 1024**3, TB: 1024**4 }
return n * (units[m[2].toUpperCase()] || 1)
}
function storagePercent(row: CloudConfig): number {
if (!row.storage_total || row.storage_total === '-' || !row.storage_used) return 0
const total = parseBytes(row.storage_total)
const used = parseBytes(row.storage_used)
if (total === 0) return 0
return Math.min(100, Math.round((used / total) * 100))
}
function storageBarClass(row: CloudConfig): string {
const pct = storagePercent(row)
if (pct >= 90) return 'bar-danger'
if (pct >= 70) return 'bar-warning'
return 'bar-normal'
}
function storageFree(row: CloudConfig): string {
if (!row.storage_total || row.storage_total === '-') return '?'
if (!row.storage_used) return '计算中...'
const total = parseBytes(row.storage_total)
const used = parseBytes(row.storage_used)
if (total === 0) return '?'
const free = total - used
if (free < 1024) return '小于 1 KB'
if (free < 1024 * 1024) return (free / 1024).toFixed(1) + ' KB'
if (free < 1024 * 1024 * 1024) return (free / (1024 * 1024)).toFixed(1) + ' MB'
if (free < 1024 * 1024 * 1024 * 1024) return (free / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
return (free / (1024 * 1024 * 1024 * 1024)).toFixed(1) + ' TB'
}
</script>
<style scoped>
.cloud-config {
/* Uses global card styles from App.vue */
}
/* ── Table card wrapper ── */
.table-card {
border-radius: var(--radius-lg);
border: 1px solid var(--border) !important;
margin-bottom: 20px;
}
.table-card :deep(.el-card__header) {
font-size: 15px;
font-weight: 600;
background: var(--bg-card-header);
border-bottom: 1px solid var(--border);
padding: 12px 18px;
}
.table-card :deep(.el-card__body) {
padding: 0;
}
/* ── Toolbar ── */
.toolbar {
margin-bottom: 16px;
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
/* ── Table cells ── */
.nickname-text {
font-weight: 600;
color: var(--text);
}
.promotion-text {
font-size: 12px;
color: var(--text-secondary);
}
.uid-cell {
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 11px;
color: var(--text-tertiary);
letter-spacing: 0.3px;
}
.save-count {
font-size: 12px;
color: var(--text-tertiary);
}
.verifying {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-tertiary);
}
/* ── Storage bar ── */
.storage-cell {
display: flex;
flex-direction: column;
gap: 3px;
padding: 2px 0;
}
.storage-bar-wrap {
height: 4px;
background: var(--border-light);
border-radius: 2px;
overflow: hidden;
}
.storage-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s;
}
.storage-bar-fill.bar-normal { background: #67c23a; }
.storage-bar-fill.bar-warning { background: #e6a23c; }
.storage-bar-fill.bar-danger { background: #f56c6c; }
.storage-text {
font-size: 11px;
color: var(--text-tertiary);
display: flex;
align-items: center;
gap: 3px;
}
.storage-used { color: var(--text-secondary); font-weight: 600; }
.storage-total { color: var(--text); font-weight: 600; }
.storage-free { color: var(--text-tertiary); }
/* ── Cookie tutorial card ── */
.cookie-tips-item :deep(.el-form-item__content) {
margin-left: 0 !important;
}
.cookie-tips {
background: #f8faff;
border: 1px solid #e8f0fe;
border-radius: var(--radius-sm);
padding: 14px 16px;
font-size: 12px;
line-height: 1.8;
color: var(--text-secondary);
width: 100%;
box-sizing: border-box;
}
.cookie-tips-header {
margin-bottom: 10px;
}
.cookie-tips-title {
font-weight: 700;
color: var(--primary);
font-size: 13px;
}
.cookie-tips-steps {
margin: 0;
padding-left: 20px;
}
.cookie-tips-steps li {
margin-bottom: 4px;
}
.cookie-tips-steps code {
background: var(--primary-soft);
padding: 1px 5px;
border-radius: 3px;
font-size: 11px;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
}
.cookie-tips-note {
margin-top: 8px;
padding: 6px 10px;
background: #fffbe6;
border: 1px solid #fff3c4;
border-radius: 4px;
color: #8a6d3b;
font-size: 11px;
line-height: 1.5;
}
.cookie-tips-note code {
background: #f5f0e0;
font-size: 11px;
}
[data-theme="dark"] .cookie-tips {
background: rgba(64, 158, 255, 0.06);
border-color: rgba(64, 158, 255, 0.15);
}
[data-theme="dark"] .cookie-tips-title {
color: #66b1ff;
}
[data-theme="dark"] .cookie-tips-steps code {
background: rgba(64, 158, 255, 0.12);
}
[data-theme="dark"] .cookie-tips-note {
background: rgba(255, 193, 7, 0.1);
border-color: rgba(255, 193, 7, 0.2);
color: #d4a84b;
}
[data-theme="dark"] .cookie-tips-note code {
background: rgba(255, 193, 7, 0.12);
}
/* ── Cloud toggle (re-used from AdminDashboard) ── */
.cloud-toggle-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.cloud-toggle-chip { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); }
.cloud-toggle-chip:hover { border-color: var(--primary); }
.cloud-icon-img { width: 20px; height: 20px; object-fit: contain; flex-shrink: 0; }
.cloud-label { font-size: 13px; font-weight: 500; }
/* ── Dialog refinements ── */
:deep(.el-dialog__header) {
font-weight: 700;
font-size: 16px;
}
:deep(.el-dialog__body) {
padding: 20px 24px;
}
:deep(.el-dialog__wrapper .el-dialog) {
border-radius: var(--radius-lg);
}
</style>
+816
View File
@@ -0,0 +1,816 @@
<template>
<div class="save-records">
<!-- ── Toolbar ── -->
<div class="toolbar">
<div class="toolbar-row">
<div class="filter-group">
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 100px" @change="loadRecords(1)">
<el-option label="全部状态" value="" />
<el-option label="✓ 成功" value="success" />
<el-option label="♻️ 复用" value="reused" />
<el-option label="✗ 失败" value="failed" />
</el-select>
<el-select v-model="cloudFilter" placeholder="网盘" clearable style="width: 100px" @change="loadRecords(1)">
<el-option label="全部网盘" value="" />
<el-option v-for="ct in cloudTypes" :key="ct" :label="cloudLabel(ct)" :value="ct">
<span :style="{ display: 'inline-flex', alignItems: 'center', gap: '6px' }">
<img :src="cloudIcon(ct)" style="width:16px;height:16px" />
{{ cloudLabel(ct) }}
</span>
</el-option>
</el-select>
<div class="time-btns">
<button
v-for="btn in timeButtons" :key="btn.key"
:class="['time-btn', { active: activeTimeBtn === btn.key }]"
@click="setTimeFilter(btn.key)"
>{{ btn.label }}</button>
</div>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 220px"
@change="onDateRangeChange"
/>
<el-input
v-model="searchKeyword"
placeholder="搜索资源名称…"
clearable
style="width: 180px"
@clear="loadRecords(1)"
@keyup.enter="loadRecords(1)"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<div class="toolbar-actions">
<el-button size="small" @click="resetFilters">重置筛选</el-button>
<span class="record-count">共 {{ total }} 条</span>
</div>
</div>
</div>
<!-- ── 转存统计汇总 ── -->
<div v-if="summary" class="save-summary">
<span class="summary-item summary-all">📊 共 <strong>{{ summary.total }}</strong> 条</span>
<span class="summary-divider">|</span>
<span class="summary-item summary-success">✅ 成功 <strong>{{ summary.success }}</strong></span>
<span class="summary-item summary-reused">♻️ 复用 <strong>{{ summary.reused }}</strong></span>
<span class="summary-item summary-failed">❌ 失败 <strong>{{ summary.failed }}</strong></span>
<span class="summary-item summary-rate" v-if="summary.total > 0">
成功率 <strong>{{ ((summary.success + summary.reused) / summary.total * 100).toFixed(1) }}%</strong>
</span>
</div>
<!-- ── Table ── -->
<el-card shadow="never" class="save-table-card">
<template #header><span>📋 转存日志列表</span></template>
<div class="el-table-wrap">
<el-table
:data="records" stripe style="width: 100%"
v-loading="loading"
empty-text="暂无转存记录"
@expand-change="onExpandChange"
:row-class-name="rowClassName"
>
<el-table-column type="expand" width="36">
<template #default="{ row }">
<div class="expand-detail">
<!-- Row 1: 原始链接 + 文件大小 + 文件夹+文件数 -->
<div class="detail-row">
<div class="detail-cell" style="flex:2">
<span class="detail-label">原始链接</span>
<a :href="row.source_url" target="_blank" class="detail-link">{{ row.source_url }}</a>
</div>
<div class="detail-cell" v-if="row.file_size">
<span class="detail-label">文件大小</span>
<code class="detail-code">{{ row.file_size ? (function(n){if(n==null||n==='')return'-';var v=typeof n==='string'?parseInt(n,10):n;if(!v||v<=0)return'-';var u=['B','KB','MB','GB','TB'];var i=0,s=v;while(s>=1024&&i<4){s/=1024;i++}return s.toFixed(i===0?0:2)+' '+u[i]})(row.file_size) : '-' }}</code>
</div>
<div class="detail-cell" v-if="row.status !== 'reused' && (row.folder_count > 0 || row.file_count > 0)">
<span class="detail-label">文件夹</span>
<span><strong>{{ row.folder_count || 0 }}</strong> 个</span>
</div>
<div class="detail-cell" v-if="row.status !== 'reused' && (row.folder_count > 0 || row.file_count > 0)">
<span class="detail-label">文件</span>
<span><strong>{{ row.file_count || 0 }}</strong> 个</span>
</div>
<div class="detail-cell" v-if="row.status === 'reused'">
<span class="detail-label">复用方式</span>
<span class="reuse-msg">♻️ 直接使用已有分享链接,无需实际转存</span>
</div>
</div>
<!-- Row 2: 使用账号 + 原始文件夹名 -->
<div class="detail-row">
<div class="detail-cell" v-if="row.config_nickname">
<span class="detail-label">使用账号</span>
<el-tag size="small" type="success" effect="plain">{{ row.config_nickname }}</el-tag>
</div>
<div class="detail-cell" v-if="row.original_folder_name">
<span class="detail-label">原始文件夹名</span>
<code class="detail-code">{{ row.original_folder_name }}</code>
</div>
<div class="detail-cell" v-if="row.folder_name">
<span class="detail-label">转存文件夹</span>
<code class="detail-code">{{ row.folder_name }}</code>
</div>
</div>
<!-- Row 3: 分享链接 + 分享密码 + 耗时 -->
<div class="detail-row">
<div class="detail-cell" v-if="row.share_url" style="flex:2">
<span class="detail-label">分享链接</span>
<a :href="row.share_url" target="_blank" class="detail-link">{{ row.share_url }}</a>
</div>
<div class="detail-cell" v-if="row.share_pwd">
<span class="detail-label">分享密码</span>
<el-tag size="small" type="warning">{{ row.share_pwd }}</el-tag>
</div>
<div class="detail-cell">
<span class="detail-label">耗时</span>
<span :class="['detail-duration', durationClass(row.duration_ms)]">{{ formatDuration(row.duration_ms) }}</span>
</div>
</div>
<!-- Row 4: IP地址 + 归属地 + 创建时间 -->
<div class="detail-row" v-if="row.ip_address">
<div class="detail-cell">
<span class="detail-label">IP 地址</span>
<code class="detail-code">{{ row.ip_address }}</code>
</div>
<div class="detail-cell" v-if="row.ip_location">
<span class="detail-label">归属地</span>
<code class="detail-code">{{ formatLocation(row.ip_location) }}</code>
</div>
<div class="detail-cell">
<span class="detail-label">时间</span>
<code class="detail-code">{{ formatTime(row.created_at) }}</code>
</div>
</div>
<!-- Row 5: 错误信息(整行) -->
<div class="detail-row" v-if="row.status === 'failed' && row.error_message">
<div class="detail-cell detail-full">
<span class="detail-label">错误信息</span>
<pre class="detail-error">{{ row.error_message.includes(' | ') ? row.error_message.split(' | ')[1] : row.error_message.split(' | ')[0] }}</pre>
</div>
</div>
<div class="detail-row" v-if="row.status === 'failed' && row.error_message && row.error_message.includes(' | ')">
<div class="detail-cell detail-full">
<span class="detail-label">友好提示</span>
<pre class="detail-error">{{ row.error_message.split(' | ')[0] }}</pre>
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="序号" width="68" align="center">
<template #default="{ $index }">
{{ (currentPage - 1) * pageSize + $index + 1 }}
</template>
</el-table-column>
<el-table-column label="时间" width="140">
<template #default="{ row }">
<span :title="row.created_at">{{ formatTime(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="网盘" width="70" align="center">
<template #default="{ row }">
<el-tooltip :content="cloudLabel(row.source_type)" placement="top">
<img :src="cloudIcon(row.source_type)" style="width:22px;height:22px;cursor:default" />
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="状态" width="72" align="center">
<template #default="{ row }">
<el-tooltip :content="statusTip(row.status)" placement="top">
<span :class="['status-badge', statusClass(row.status)]">
{{ statusIcon(row.status) }}
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="资源名称" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span :title="row.source_title || ''">{{ row.source_title || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="耗时" width="85" align="center">
<template #default="{ row }">
<span :class="['duration', durationClass(row.duration_ms)]">
{{ formatDuration(row.duration_ms) }}
</span>
</template>
</el-table-column>
<el-table-column label="归属地" min-width="130" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.ip_location" class="loc-badge">{{ formatLocation(row.ip_location) }}</span>
<span v-else class="no-data">-</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.status === 'failed' && row.error_message" class="err-msg" :title="row.error_message">
{{ truncateErr(row.error_message) }}
</span>
<span v-else-if="row.status === 'failed'" class="err-msg">失败</span>
<span v-else-if="row.status === 'reused'" class="reuse-msg">♻️ 复用已有链接</span>
<span v-else class="no-data">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right" align="center">
<template #default="{ row }">
<div class="action-cell">
<el-tooltip content="复制分享链接" placement="top">
<el-button size="small" circle text :disabled="!row.share_url" @click="copyText(row.share_url!)">
<el-icon><Link /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="打开分享链接" placement="top">
<el-button size="small" circle text :disabled="!row.share_url" @click="windowOpen(row.share_url!)">
<el-icon><TopRight /></el-icon>
</el-button>
</el-tooltip>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<!-- ── Pagination ── -->
<div class="pagination-wrap" v-if="total > 0">
<div class="pagination-info">
第 {{ (currentPage - 1) * pageSize + 1 }}-{{ Math.min(currentPage * pageSize, total) }} 条,共 {{ total }} 条
</div>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[15, 20, 30, 50, 100]"
layout="sizes, prev, pager, next, jumper"
@current-change="loadRecords"
@size-change="loadRecords(1)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getSaveRecords, getCloudTypes } from '../../api'
import type { SaveRecord } from '../../api'
import { ElMessage } from 'element-plus'
import { Search, CopyDocument, Link, TopRight } from '@element-plus/icons-vue'
const records = ref<SaveRecord[]>([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const statusFilter = ref('')
const cloudFilter = ref('')
const searchKeyword = ref('')
const activeTimeBtn = ref('today')
const timeStart = ref('')
const timeEnd = ref('')
const dateRange = ref<string[] | null>(null)
const cloudTypes = ref<string[]>([])
const summary = ref<{ total: number; success: number; failed: number; reused: number } | null>(null)
const timeButtons = [
{ key: 'today', label: '今日' },
{ key: 'week', label: '本周' },
{ key: 'month', label: '本月' },
{ key: 'lastMonth', label: '上月' },
]
// ── Cloud type helpers — loaded from backend API ──
const cloudTypeMap = ref<Record<string, { label: string; icon: string }>>({})
const FALLBACK_ICON_SVG = '<svg viewBox="0 0 24 24" width="16" height="16"><rect rx="4" width="24" height="24" fill="#909399"/><text x="12" y="16" text-anchor="middle" fill="white" font-size="14" font-weight="bold" font-family="Arial">☁</text></svg>'
async function loadCloudTypes() {
try {
const res = await getCloudTypes()
const map: Record<string, { label: string; icon: string }> = {}
for (const ct of res.types) {
map[ct.type] = { label: ct.label, icon: ct.icon }
}
cloudTypeMap.value = map
} catch {
// ignore
}
}
function cloudLabel(t: string): string { return cloudTypeMap.value[t]?.label || t }
function cloudIcon(t: string): string { return cloudTypeMap.value[t]?.icon || FALLBACK_ICON_SVG }
function extractCloudTypes(data: SaveRecord[]) {
const set = new Set<string>()
data.forEach(r => { if (r.source_type) set.add(r.source_type) })
const existing = new Set(cloudTypes.value)
set.forEach(t => { if (!existing.has(t)) cloudTypes.value.push(t) })
}
// ── Formatting helpers ──
function fmt(d: Date): string {
const y = d.getFullYear()
const M = String(d.getMonth() + 1).padStart(2, '0')
const D = String(d.getDate()).padStart(2, '0')
return `${y}-${M}-${D}`
}
function formatTime(t: string): string {
if (!t) return '-'
let ts = t
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(ts)) ts = ts.replace(' ', 'T') + '+08:00'
const d = new Date(ts)
if (isNaN(d.getTime())) return t
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function formatDuration(ms: number): string {
if (!ms) return '-'
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
function durationClass(ms: number): string {
if (!ms) return ''
if (ms > 30000) return 'dur-slow'
if (ms > 10000) return 'dur-warn'
return 'dur-fast'
}
function fileCountType(n: number): string {
if (n >= 50) return 'danger'
if (n >= 10) return 'warning'
return 'success'
}
function truncateErr(msg: string): string {
return msg.length > 50 ? msg.slice(0, 50) + '…' : msg
}
// ── Status helpers ──
function statusTip(status: string): string {
if (status === 'success') return '转存成功'
if (status === 'reused') return '♻️ 复用已有分享链接'
return '转存失败'
}
function statusClass(status: string): string {
if (status === 'success') return 'status-ok'
if (status === 'reused') return 'status-reuse'
return 'status-fail'
}
function statusIcon(status: string): string {
if (status === 'success') return '✓'
if (status === 'reused') return '♻️'
return '✗'
}
/** 省份/城市中英文翻译(用于 api.ip.sb 返回的英语地名) */
const CN_PLACES: Record<string, string> = {
// 省份/直辖市
'Anhui':'安徽','Beijing':'北京','Chongqing':'重庆','Fujian':'福建','Gansu':'甘肃','Guangdong':'广东',
'Guangxi':'广西','Guizhou':'贵州','Hainan':'海南','Hebei':'河北','Henan':'河南','Heilongjiang':'黑龙江',
'Hubei':'湖北','Hunan':'湖南','Inner Mongolia':'内蒙古','Jiangsu':'江苏','Jiangxi':'江西','Jilin':'吉林',
'Liaoning':'辽宁','Ningxia':'宁夏','Qinghai':'青海','Shaanxi':'陕西','Shandong':'山东','Shanghai':'上海',
'Shanxi':'山西','Sichuan':'四川','Tianjin':'天津','Tibet':'西藏','Xinjiang':'新疆','Yunnan':'云南','Zhejiang':'浙江',
'Hong Kong':'香港','Macau':'澳门','Taiwan':'台湾',
// 主要城市
'Changsha':'长沙','Hefei':'合肥','Fuzhou':'福州','Lanzhou':'兰州',
'Guangzhou':'广州','Nanning':'南宁','Guiyang':'贵阳','Haikou':'海口',
'Shijiazhuang':'石家庄','Zhengzhou':'郑州','Harbin':'哈尔滨','Wuhan':'武汉',
'Nanjing':'南京','Nanchang':'南昌','Changchun':'长春','Shenyang':'沈阳',
'Yinchuan':'银川','Xining':'西宁',"Xi'an":"西安",'Jinan':'济南',
'Taiyuan':'太原','Chengdu':'成都','Shenzhen':'深圳','Hangzhou':'杭州',
'Suzhou':'苏州','Wuxi':'无锡','Ningbo':'宁波','Dongguan':'东莞',
'Foshan':'佛山','Zhuhai':'珠海','Qingdao':'青岛','Dalian':'大连',
'Xiamen':'厦门','Kunming':'昆明','Lhasa':'拉萨','Urumqi':'乌鲁木齐',
'Linyi':'临沂','Wenzhou':'温州','Quanzhou':'泉州',
};
/** 常见英文ISP → 中文 */
const CN_ISP: Record<string, string> = {
'China Telecom':'中国电信','China Mobile':'中国移动','China Unicom':'中国联通',
'Chinanet':'中国电信','ChinaNet':'中国电信','CMNET':'中国移动',
'CNC Group':'中国联通','unicom':'中国联通','telecom':'中国电信','mobile':'中国移动',
'China Education and Research Network':'教育网','CERNET':'教育网',
'China Networks':'中国网络','China163':'中国电信','CHINANET BACKBONE':'中国电信',
'Tencent Cloud':'腾讯云','Alibaba Cloud':'阿里云','Aliyun':'阿里云','Huawei Cloud':'华为云',
'Baidu':'百度','Beijing Baidu':'百度',
};
function formatLocation(loc: string): string {
// Remove "China" prefix
let s = loc.replace(/^(中国|China)\s*/i, '')
// Split into parts
const parts = s.split(/\s+/).filter(Boolean)
// Translate each part
return parts.map(p => CN_PLACES[p] || CN_ISP[p] || p).join(' ')
}
function rowClassName({ row }: { row: SaveRecord }) {
return row.status === 'failed' ? 'row-failed' : ''
}
// ── Actions ──
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制到剪贴板')
} catch {
// fallback
const ta = document.createElement('textarea')
ta.value = text
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
ElMessage.success('已复制到剪贴板')
}
}
function windowOpen(url: string) {
window.open(url, '_blank')
}
function onExpandChange(row: SaveRecord, expanded: boolean[]) {
// just placeholder for potential future use
}
// ── Filter helpers ──
function setTimeFilter(key: string) {
activeTimeBtn.value = key
dateRange.value = null
const now = new Date()
const y = now.getFullYear()
const m = now.getMonth()
let start: Date
let end: Date
switch (key) {
case 'today':
start = new Date(y, m, now.getDate())
end = start
break
case 'week': {
const dow = now.getDay()
start = new Date(y, m, now.getDate() + (dow === 0 ? -6 : 1 - dow))
end = now
break
}
case 'month':
start = new Date(y, m, 1)
end = now
break
case 'lastMonth':
start = new Date(y, m - 1, 1)
end = new Date(y, m, 0)
break
default:
start = new Date(y, m, now.getDate())
end = start
}
timeStart.value = fmt(start)
const nextDay = new Date(end.getFullYear(), end.getMonth(), end.getDate() + 1)
timeEnd.value = fmt(nextDay)
loadRecords(1)
}
function onDateRangeChange(val: string[] | null) {
if (val && val.length === 2) {
activeTimeBtn.value = ''
timeStart.value = val[0]
const next = new Date(val[1])
next.setDate(next.getDate() + 1)
timeEnd.value = fmt(next)
loadRecords(1)
} else {
setTimeFilter('today')
}
}
function resetFilters() {
statusFilter.value = ''
cloudFilter.value = ''
searchKeyword.value = ''
dateRange.value = null
setTimeFilter('today')
}
async function loadRecords(page = 1) {
loading.value = true
try {
currentPage.value = page
const s = statusFilter.value || undefined
const c = cloudFilter.value || undefined
const kw = searchKeyword.value || undefined
const res = await getSaveRecords(page, pageSize.value, timeStart.value, timeEnd.value, s, c, kw)
records.value = res.records
total.value = res.total
summary.value = res.summary || null
extractCloudTypes(res.records)
} catch (e) {
console.error('加载转存记录失败', e)
} finally {
loading.value = false
}
}
onMounted(() => {
setTimeFilter('today')
loadCloudTypes()
})
</script>
<style scoped>
.save-records { padding: 0; }
/* ── Toolbar ── */
.toolbar-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 20px;
padding-bottom: 16px;
flex-wrap: wrap;
border-bottom: 1px solid var(--el-border-color-light, #ebeef5);
}
.filter-group {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.record-count {
font-size: 13px;
color: var(--text-secondary, #909399);
white-space: nowrap;
}
/* ── Time buttons ── */
.time-btns {
display: flex;
gap: 2px;
background: var(--el-fill-color-light, #f0f2f5);
border-radius: 8px;
padding: 3px;
}
.time-btn {
border: none;
background: transparent;
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
color: #606266;
cursor: pointer;
transition: all .25s ease;
}
.time-btn:hover {
color: var(--el-color-primary);
background: rgba(64,158,255,.06);
}
.time-btn.active {
background: linear-gradient(135deg, var(--el-color-primary), var(--el-color-primary-light-3, #79bbff));
color: #fff;
font-weight: 600;
box-shadow: 0 2px 6px rgba(64,158,255,.3);
}
/* ── Table ── */
.save-table-card {
border-radius: var(--radius-lg);
border: 1px solid var(--border) !important;
}
.save-table-card :deep(.el-card__header) {
font-size: 15px;
font-weight: 600;
background: var(--bg-card-header);
border-bottom: 1px solid var(--border);
padding: 12px 18px;
}
.save-table-card :deep(.el-card__body) {
padding: 0;
}
.save-records :deep(.el-table) {
border: 1px solid var(--el-border-color-light, #ebeef5);
border-radius: 8px;
}
.save-records .el-table-wrap {
overflow-x: auto;
}
.save-records :deep(.el-table th.el-table__cell) {
background-color: var(--el-fill-color-light, #f5f7fa);
font-weight: 600;
color: var(--el-text-color-primary, #303133);
white-space: nowrap;
}
.save-records :deep(.el-table .el-table__cell) {
padding: 8px 8px;
white-space: nowrap !important;
}
.save-records :deep(.el-table .el-table__cell .cell) {
white-space: nowrap !important;
}
/* ── Cell content: one-line with ellipsis ── */
.cell-nowrap {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.save-records :deep(.el-table__row:hover > .el-table__cell) {
background-color: var(--el-color-primary-light-9, #ecf5ff);
}
/* Row highlight for failed */
.save-records :deep(.row-failed > .el-table__cell) {
background-color: rgba(245,108,108,.04);
}
/* ── Status badge ── */
.status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 13px;
font-weight: bold;
}
.status-ok {
background: rgba(103,194,58,.15);
color: #67c23a;
}
.status-reuse {
background: rgba(64,158,255,.15);
color: #409eff;
}
.status-fail {
background: rgba(245,108,108,.15);
color: #f56c6c;
}
/* ── Cell helpers ── */
.ip-text {
font-family: monospace;
font-size: 12px;
}
.loc-badge {
display: inline-block;
background: linear-gradient(135deg, #e8f4ff, #f0f8ff);
color: #1a6ea0;
font-size: 12px;
padding: 2px 10px;
border-radius: 4px;
border: 1px solid #b8d9f0;
white-space: nowrap;
}
.err-msg {
color: var(--el-color-danger);
font-size: 12px;
}
.reuse-msg {
color: var(--el-color-primary);
font-size: 12px;
}
.no-data {
color: var(--text-secondary, #c0c4cc);
}
/* ── Duration color ── */
.duration { font-size: 12px; font-family: monospace; }
.dur-fast { color: #67c23a; }
.dur-warn { color: #e6a23c; }
.dur-slow { color: #f56c6c; font-weight: 600; }
/* ── Expand detail (flex rows) ── */
.expand-detail {
padding: 16px 24px;
background: var(--el-fill-color-lighter, #fafafa);
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-row {
display: flex;
align-items: flex-start;
gap: 16px 24px;
flex-wrap: wrap;
}
.detail-cell {
display: flex;
flex-direction: column;
gap: 4px;
flex: none;
}
.detail-cell.detail-full {
flex: 1 1 100%;
}
.detail-label {
font-size: 12px;
color: #909399;
font-weight: 500;
}
.detail-link {
color: var(--el-color-primary);
font-size: 13px;
word-break: break-all;
text-decoration: none;
}
.detail-link:hover { text-decoration: underline; }
.detail-code {
font-size: 12px;
background: #f0f0f0;
padding: 4px 8px;
border-radius: 4px;
word-break: break-all;
}
.detail-error {
margin: 0;
font-size: 12px;
color: #f56c6c;
background: rgba(245,108,108,.08);
padding: 8px 12px;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-word;
max-height: 120px;
overflow-y: auto;
}
/* ── Action cell ── */
.action-cell {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
}
/* ── Pagination ── */
.pagination-wrap {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 20px;
padding: 12px 16px;
background: var(--el-fill-color-light, #f5f7fa);
border-radius: 8px;
flex-wrap: wrap;
gap: 12px;
}
.pagination-info {
font-size: 13px;
color: var(--text-secondary, #909399);
white-space: nowrap;
}
/* ── 转存统计汇总 ── */
.save-summary {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
margin-bottom: 8px;
background: var(--el-fill-color-light, #f5f7fa);
border-radius: 8px;
font-size: 13px;
flex-wrap: wrap;
}
.summary-item {
white-space: nowrap;
}
.summary-success strong { color: #67c23a; }
.summary-failed strong { color: #f56c6c; }
.summary-reused strong { color: #e6a23c; }
.summary-rate { color: #909399; }
.summary-rate strong { color: #409eff; }
.summary-divider { color: #dcdfe6; font-size: 12px; user-select: none; }
</style>
+318
View File
@@ -0,0 +1,318 @@
<template>
<div class="stats-view">
<!-- 概览卡片 -->
<div class="overview-cards">
<el-card class="overview-card" shadow="never">
<div class="overview-value primary">{{ stats.todaySearches }}</div>
<div class="overview-label">今日搜索</div>
</el-card>
<el-card class="overview-card" shadow="never">
<div class="overview-value success">{{ stats.todaySaves }}</div>
<div class="overview-label">今日保存</div>
</el-card>
<el-card class="overview-card" shadow="never">
<div class="overview-value primary">{{ stats.monthSearches }}</div>
<div class="overview-label">本月搜索</div>
</el-card>
<el-card class="overview-card" shadow="never">
<div class="overview-value success">{{ stats.monthSaves }}</div>
<div class="overview-label">本月保存</div>
</el-card>
<el-card class="overview-card" shadow="never">
<div class="overview-value info">{{ stats.totalSearches }}</div>
<div class="overview-label">总搜索量</div>
</el-card>
<el-card class="overview-card" shadow="never">
<div class="overview-value warning">{{ stats.totalSaves }}</div>
<div class="overview-label">总保存量</div>
</el-card>
</div>
<div class="stats-grid">
<!-- 近7天趋势 -->
<el-card class="stats-card" shadow="never">
<template #header>
<span>📈 近 7 天趋势</span>
</template>
<el-table :data="stats.trendTrend" stripe size="small" style="width: 100%">
<el-table-column label="日期" min-width="120">
<template #default="{ row }">
{{ formatDate(row.date) }}
</template>
</el-table-column>
<el-table-column label="搜索" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" type="primary" effect="plain">{{ row.searches }}</el-tag>
</template>
</el-table-column>
<el-table-column label="保存" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" type="success" effect="plain">{{ row.saves }}</el-tag>
</template>
</el-table-column>
<el-table-column label="趋势" min-width="150">
<template #default="{ row }">
<div class="trend-bar-wrap">
<div class="trend-bar" :style="{ width: trendBarWidth(row) + '%', background: trendBarColor(row) }"></div>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 热门搜索关键词 -->
<el-card class="stats-card" shadow="never">
<template #header>
<span>🔥 热门搜索关键词 Top 20</span>
</template>
<el-table :data="stats.hotKeywords" stripe size="small" style="width: 100%" :max-height="480">
<el-table-column type="index" label="排名" width="60" />
<el-table-column prop="keyword" label="关键词" min-width="200" />
<el-table-column prop="count" label="搜索次数" width="120" align="center">
<template #default="{ row }">
<el-tag>{{ row.count }}</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-if="stats.hotKeywords.length === 0" description="暂无数据" :image-size="60" />
</el-card>
<!-- 网盘使用空间 -->
<el-card class="stats-card" shadow="never">
<template #header>
<span>💾 网盘使用空间</span>
</template>
<div v-for="item in stats.cloudUsage" :key="item.cloudType" class="storage-item">
<div class="storage-header">
<span class="storage-label">{{ item.nickname || item.cloudType }}</span>
<span class="storage-badge" :class="item.isActive ? 'active' : 'inactive'">{{ item.isActive ? '正常' : '停用' }}</span>
</div>
<div class="storage-detail">
<span class="storage-usage">{{ item.storageUsed || '未知' }} / {{ item.storageTotal || '未知' }}</span>
<span class="storage-pct">{{ storagePercent(item) }}%</span>
</div>
<el-progress :percentage="storagePercent(item)" :stroke-width="14" :color="storagePercent(item) > 80 ? '#f56c6c' : '#67c23a'" />
</div>
<el-empty v-if="stats.cloudUsage.length === 0" description="暂无网盘数据" :image-size="60" />
</el-card>
<!-- 保存操作来源 IP Top 10 -->
<el-card class="stats-card" shadow="never">
<template #header>
<span>🌐 保存操作来源 IP Top 10</span>
</template>
<el-table :data="stats.topIps" stripe size="small" style="width: 100%">
<el-table-column type="index" label="排名" width="60" />
<el-table-column prop="ip" label="IP" min-width="160">
<template #default="{ row }">
<span style="font-family: monospace; font-size: 12px">{{ row.ip }}</span>
</template>
</el-table-column>
<el-table-column label="访问地址" min-width="160">
<template #default="{ row }">
<span v-if="row.ip_location" style="font-size: 12px">{{ formatLocation(row.ip_location) }}</span>
<span v-else class="no-data">-</span>
</template>
</el-table-column>
<el-table-column prop="count" label="操作次数" width="100" align="center">
<template #default="{ row }">
<el-tag type="warning" effect="plain">{{ row.count }}</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-if="stats.topIps.length === 0" description="暂无数据" :image-size="60" />
</el-card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getStats } from '../../api'
import type { StatsData } from '../../types'
const stats = ref<StatsData>({
todaySearches: 0,
todaySaves: 0,
monthSearches: 0,
monthSaves: 0,
totalSearches: 0,
totalSaves: 0,
hotKeywords: [],
trendTrend: [],
cloudUsage: [],
topIps: [],
provinceRankings: [],
})
onMounted(async () => {
try {
stats.value = await getStats()
} catch (e) {
console.error('加载统计数据失败', e)
}
})
function formatDate(d: string): string {
if (!d) return '-'
return d.split(' ')[0] || d
}
/** Strip "中国/China" prefix from location string for consistent display */
function formatLocation(loc: string): string {
if (!loc) return ''
return loc.replace(/^(中国|China)\s*/i, '').trim()
}
function storagePercent(item: { storageUsed: string; storageTotal: string }): number {
if (!item.storageUsed || !item.storageTotal) return 0
const used = parseFloat(item.storageUsed)
const total = parseFloat(item.storageTotal)
if (total <= 0) return 0
return Math.round((used / total) * 100)
}
/// 趋势条宽度(基于当日搜索+保存之和)
function trendBarWidth(row: { searches: number; saves: number }): number {
const max = Math.max(...stats.value.trendTrend.map(r => r.searches + r.saves), 1)
return ((row.searches + row.saves) / max) * 100
}
function trendBarColor(row: { searches: number; saves: number }): string {
const total = row.searches + row.saves
if (total === 0) return '#ebeef5'
// Color gradient: more saves → more green
const ratio = row.saves / Math.max(total, 1)
if (ratio > 0.5) return '#67c23a'
if (ratio > 0.2) return '#409eff'
return '#909399'
}
</script>
<style scoped>
.overview-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
/* --- 概览卡片:阴影 + 悬浮 + 渐变底色 --- */
.overview-card {
border: none !important;
border-radius: 12px !important;
background: linear-gradient(135deg, #ffffff 0%, #f8faff 100%);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.overview-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.overview-card :deep(.el-card__body) {
text-align: center;
padding: 22px 16px;
background: transparent !important;
}
.overview-value {
font-size: 36px;
font-weight: 700;
margin-bottom: 6px;
}
.overview-value.primary { color: var(--el-color-primary); }
.overview-value.success { color: var(--el-color-success); }
.overview-value.info { color: var(--el-color-info); }
.overview-value.warning { color: var(--el-color-warning); }
.overview-label {
font-size: 14px;
color: var(--el-text-color-secondary);
}
/* --- stats-card 美化阴影和圆角 --- */
.stats-grid {
display: flex;
flex-direction: column;
gap: 16px;
}
.stats-card {
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
transition: box-shadow 0.3s ease;
border: 1px solid var(--el-border-color-light, #ebeef5);
}
.stats-card:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.09);
}
.stats-card :deep(.el-card__header) {
padding: 12px 16px;
font-size: 15px;
font-weight: 600;
border-bottom: 1px solid var(--el-border-color-lighter);
background: linear-gradient(90deg, #fafbfc 0%, #ffffff 100%);
}
/* --- 表格行 hover 高亮 --- */
.stats-view :deep(.el-table__body tr:hover > td.el-table__cell) {
background-color: #ecf5ff !important;
cursor: default;
}
.stats-view :deep(.el-table__body tr) {
transition: background-color 0.2s ease;
}
/* --- 趋势条 --- */
.trend-bar-wrap {
height: 16px;
background: #f0f2f5;
border-radius: 8px;
overflow: hidden;
}
.trend-bar {
height: 100%;
border-radius: 8px;
transition: width .3s;
min-width: 4px;
}
/* --- 存储进度条更圆润 --- */
.stats-view :deep(.el-progress-bar__outer) {
border-radius: 99px !important;
overflow: hidden;
}
.stats-view :deep(.el-progress-bar__inner) {
border-radius: 99px !important;
}
.storage-item {
padding: 12px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.storage-item:last-child {
border-bottom: none;
}
.storage-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.storage-label {
font-size: 14px;
font-weight: 500;
}
.storage-badge {
font-size: 11px;
padding: 1px 8px;
border-radius: 4px;
}
.storage-badge.active { background: #f0f9eb; color: #67c23a; }
.storage-badge.inactive { background: #fef0f0; color: #f56c6c; }
.storage-detail {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 6px;
}
.storage-pct {
font-weight: 600;
}
</style>
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'home',
component: () => import('./pages/HomePage.vue'),
},
{
path: '/search',
name: 'search',
component: () => import('./pages/SearchResult.vue'),
},
{
path: '/result/:id',
name: 'result-detail',
component: () => import('./pages/ResultDetail.vue'),
},
{
path: '/admin/login',
name: 'admin-login',
component: () => import('./pages/admin/AdminLogin.vue'),
},
{
path: '/admin',
component: () => import('./pages/admin/AdminLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
redirect: '/admin/dashboard',
},
{
path: 'dashboard',
name: 'admin-dashboard',
component: () => import('./pages/admin/AdminDashboard.vue'),
},
{
path: 'cloud-configs',
name: 'admin-cloud-configs',
component: () => import('./pages/admin/CloudConfig.vue'),
},
{
path: 'cleanup',
name: 'admin-cleanup',
component: () => import('./pages/admin/Cleanup.vue'),
},
{
path: 'system',
name: 'admin-system',
component: () => import('./pages/admin/SystemConfig.vue'),
},
{
path: 'save-records',
name: 'admin-save-records',
component: () => import('./pages/admin/SaveRecords.vue'),
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('admin_token')
if (to.meta.requiresAuth && !token) {
next('/admin/login')
} else {
next()
}
})
export default router
+27
View File
@@ -0,0 +1,27 @@
/* 全局样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
color: #333;
background: #f5f7fa;
}
:root {
--primary-color: #409eff;
--primary-dark: #337ecc;
--text-secondary: #909399;
--border-color: #e4e7ed;
--bg-white: #ffffff;
--shadow-card: 0 2px 12px rgba(0, 0, 0, 0.08);
--radius-card: 12px;
}
a {
text-decoration: none;
color: var(--primary-color);
}
+191
View File
@@ -0,0 +1,191 @@
/* ===== 搜索结果类型 ===== */
export interface SearchResult {
id: string
title: string
description?: string
cover?: string
file_size?: string
update_time?: string
datetime?: string
cloud_type: CloudType
share_url?: string
source?: string
password?: string
file_id?: string
valid?: boolean
}
export type CloudType =
| 'quark' | 'baidu' | 'aliyun' | '115'
| 'tianyi' | '123pan' | 'uc' | 'xunlei'
| 'pikpak' | 'magnet' | 'ed2k' | 'others'
export const CLOUD_LABELS: Record<CloudType, string> = {
quark: '夸克网盘',
baidu: '百度网盘',
aliyun: '阿里云盘',
'115': '115网盘',
tianyi: '天翼云盘',
'123pan': '123云盘',
uc: 'UC网盘',
xunlei: '迅雷云盘',
pikpak: 'PikPak',
magnet: '磁力链接',
ed2k: '电驴链接',
others: '其他',
}
export const CLOUD_COLORS: Record<CloudType, string> = {
quark: '#07c160',
baidu: '#4e6ef2',
aliyun: '#ff6a00',
'115': '#9b59b6',
tianyi: '#00a1d6',
'123pan': '#e74c3c',
uc: '#f39c12',
xunlei: '#2ecc71',
pikpak: '#8e44ad',
magnet: '#95a5a6',
ed2k: '#7f8c8d',
others: '#95a5a6',
}
/**
* 网盘图标映射 — 全部使用内联 SVG data URI,无需外部文件
/**
* 网盘图标映射 — 使用仓库中的 PNG 图标文件
* 图标存储在 public/icons/ 目录下,Vite 构建时自动打包
*/
export const CLOUD_ICONS: Record<string, string> = {
baidu: '/icons/baidu.png',
aliyun: '/icons/aliyun.png',
quark: '/icons/quark.png',
'115': '/icons/115.png',
tianyi: '/icons/tianyi.png',
'123pan': '/icons/123pan.png',
uc: '/icons/uc.png',
xunlei: '/icons/xunlei.png',
pikpak: '/icons/pikpak.png',
magnet: '/icons/magnet.png',
ed2k: '/icons/ed2k.png',
others: '/icons/others.png',
}
/* ===== 视频解析类型 ===== */
export interface VideoParseResult {
title: string
cover: string
video_url: string
author?: string
platform: string
description?: string
}
/* ===== 保存结果类型 ===== */
export interface SaveResult {
success: boolean
share_url: string
share_pwd?: string
expire_at?: string
file_name: string
file_size: string
message?: string
}
/* ===== 排行榜类型 ===== */
export interface RankingItem {
id: number
title: string
search_count: number
cloud_type: CloudType
cover?: string
}
/* ===== 推广类型 ===== */
export interface Promotion {
id?: number
title: string
description: string
image_url: string
link_url: string
position: 'home_banner' | 'search_top' | 'sidebar'
sort_order: number
active: boolean
click_count?: number
start_time?: string
end_time?: string
}
/* ===== 网盘配置类型 ===== */
export interface CloudConfig {
id?: number
cloud_type: CloudType
nickname?: string
is_active: boolean
cookie?: string
cookie_preview?: string
storage_used?: string
storage_total?: string
checkin_status?: 'none' | 'success' | 'failed' | 'pending' | 'skipped'
last_checkin_at?: string
checkin_message?: string
consecutive_failures?: number
last_used_at?: string
total_saves?: number
verification_status?: 'untested' | 'valid' | 'invalid'
last_verified_at?: string
promotion_account?: string
is_transfer_enabled?: number
}
/* ===== API 响应类型 ===== */
export interface ApiResponse<T = any> {
success: boolean
data?: T
message?: string
error?: string
}
export interface SearchResponse {
results: SearchResult[]
total: number
filtered: number
channels?: ChannelGroup[]
link_validation?: boolean
}
export interface ChannelGroup {
cloud_type: string
label: string
color: string
count: number
items: any[]
newestTime?: string
}
export interface StatsData {
todaySearches: number
todaySaves: number
monthSearches: number
monthSaves: number
totalSearches: number
totalSaves: number
hotKeywords: { keyword: string; count: number }[]
trendTrend: { date: string; searches: number; saves: number; searchDelta: number; saveDelta: number }[]
cloudUsage: { cloudType: string; nickname: string; storageUsed: string; storageTotal: string; isActive: boolean }[]
topIps: { ip: string; ip_location: string | null; count: number }[]
provinceRankings: { province: string; count: number }[]
}
/* ===== 意图识别结果 ===== */
export type IntentType = 'SEARCH' | 'VIDEO_PARSE' | 'CLOUD_SAVE'
export interface QueryResponse {
intent: IntentType
results: SearchResult[] | VideoParseResult[]
channels?: ChannelGroup[]
total: number
filtered?: number
platform?: string
link_validation?: boolean
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
})
+79
View File
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>CloudSearch 管理后台</title>
<style>
:root{--bg:#f5f6fa;--card:#fff;--text:#2c3e50;--sub:#7f8c8d;--pri:#3498db;--pri-hover:#2980b9;--danger:#e74c3c;--success:#27ae60;--warn:#f39c12;--border:#e8ecf1;--sidebar-bg:#1a1a2e;--sidebar-text:#b0b8c8;--sidebar-active:#fff;--radius:8px}
*{margin:0;padding:0;box-sizing:border-box}
#app{display:flex;flex:1}body{font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);display:flex;min-height:100vh}
.sidebar{width:220px;background:var(--sidebar-bg);color:var(--sidebar-text);flex-shrink:0;display:flex;flex-direction:column}
.sidebar-title{padding:20px;font-size:17px;font-weight:700;color:#fff;border-bottom:1px solid rgba(255,255,255,.08)}
.nav-item{display:flex;align-items:center;gap:8px;padding:9px 16px 9px 20px;cursor:pointer;color:var(--sidebar-text);font-size:13px;transition:.15s;border-left:3px solid transparent}
.nav-item:hover{color:#fff;background:rgba(255,255,255,.05)}
.nav-item.active{color:var(--sidebar-active);background:rgba(52,152,219,.15);border-left-color:var(--pri)}
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
.topbar{padding:16px 24px;font-size:16px;font-weight:600;background:var(--card);border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
#content{flex:1;overflow-y:auto;padding:24px}
.card{background:var(--card);border-radius:var(--radius);padding:20px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.card-title{font-size:14px;font-weight:600;margin-bottom:12px;display:flex;align-items:center;gap:8px}
.stats-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:12px;margin-bottom:20px}
.stat-card{background:var(--card);border-radius:var(--radius);padding:8px 12px;box-shadow:0 1px 3px rgba(0,0,0,.04);display:flex;align-items:center;justify-content:space-between}
.form-group{margin-bottom:14px;display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap}
.form-group label{font-size:13px;color:var(--text);font-weight:500;min-width:130px;padding-top:6px}
.form-control{padding:7px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;outline:none;transition:.15s;max-width:360px;width:100%}
.form-control:focus{border-color:var(--pri)}
.form-control.wide{max-width:500px}
textarea.form-control{min-height:70px;resize:vertical}
.btn{padding:7px 16px;border:none;border-radius:6px;font-size:13px;cursor:pointer;font-weight:500;transition:.15s;display:inline-flex;align-items:center;gap:4px}
.btn-pri{background:var(--pri);color:#fff}.btn-pri:hover{background:var(--pri-hover)}
.btn-danger{background:var(--danger);color:#fff}.btn-danger:hover{background:#c0392b}
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}.btn-outline:hover{background:var(--bg)}
.btn-sm{padding:4px 10px;font-size:12px}
.toggle{position:relative;display:inline-block;width:42px;height:22px}
.toggle input{opacity:0;width:0;height:0}
.toggle-slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:#ccc;border-radius:22px;transition:.2s}
.toggle-slider:before{position:absolute;content:"";height:16px;width:16px;left:3px;bottom:3px;background:#fff;border-radius:50%;transition:.2s}
.toggle input:checked+.toggle-slider{background:var(--pri)}
.toggle input:checked+.toggle-slider:before{transform:translateX(20px)}
.toast{position:fixed;top:16px;right:16px;padding:12px 20px;border-radius:8px;color:#fff;font-size:13px;z-index:999;animation:slideIn .3s ease}
.toast-success{background:var(--success)}.toast-error{background:var(--danger)}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
.loading{text-align:center;padding:40px;color:var(--sub)}
</style>
</head>
<body>
<div id="app">
<div class="sidebar">
<div class="sidebar-title">CloudSearch</div>
<div class="nav-item active" data-page="dashboard" onclick="nav('dashboard')">📊 仪表盘</div>
<div class="nav-item" data-page="sys-site" onclick="nav('sys-site')">⚙️ 网站设置</div>
<div class="nav-item" data-page="sys-services" onclick="nav('sys-services')">🔗 外部服务</div>
<div class="nav-item" data-page="sys-manage" onclick="nav('sys-manage')">🖥 服务管理</div>
<div class="nav-item" data-page="sys-strategy" onclick="nav('sys-strategy')">🚀 性能配置</div>
<div class="nav-item" data-page="sys-password" onclick="nav('sys-password')">🔑 修改密码</div>
<div class="nav-item" data-page="cloud-config" onclick="nav('cloud-config')">☁️ 网盘设置</div>
<div class="nav-item" data-page="cleanup" onclick="nav('cleanup')">🗑 存储清理</div>
<div style="margin-top:auto;padding:12px"><button class="btn btn-outline btn-sm" onclick="logout()" style="width:100%;color:var(--sidebar-text)">退出登录</button></div>
</div>
<div class="main">
<div class="topbar"><span id="pageTitle">仪表盘</span><span id="appVersion" style="font-size:12px;color:var(--sub)">v-</span></div>
<div id="content"></div>
</div>
</div>
<script src="/admin/js/admin-core.js"></script>
<script src="/admin/js/admin-helpers.js"></script>
<script src="/admin/js/admin-login.js"></script>
<script src="/admin/js/admin-dashboard.js"></script>
<script src="/admin/js/admin-site.js"></script>
<script src="/admin/js/admin-services.js"></script>
<script src="/admin/js/admin-password.js"></script>
<script src="/admin/js/cloud/cloud-core.js"></script>
<script src="/admin/js/cloud/cloud-render.js"></script>
<script src="/admin/js/cloud/cloud-dialog.js"></script>
<script src="/admin/js/cloud/cloud-actions.js"></script>
<script src="/admin/js/admin-cleanup.js"></script>
<script src="/admin/js/admin-boot.js"></script>
</body>
</html>
@@ -0,0 +1,15 @@
// Fetch app version
fetch('/api/version').then(r => r.json()).then(d => {
var el = document.getElementById('appVersion');
if (el && d.version) el.textContent = 'v' + d.version;
}).catch(function(){});
(function(){
if(!TOKEN){
document.getElementById('app').innerHTML = '<div class="login-wrap"><div id="content"></div></div>';
page_login(document.getElementById('content'));
} else {
nav('dashboard');
}
})();
@@ -0,0 +1,155 @@
function page_cleanup(c){
c.innerHTML = '<div id="cleanupRoot"><p style="text-align:center;padding:40px">Loading...</p></div>';
loadCleanupPage();
}
function loadCleanupPage(){
Promise.all([
api('/api/admin/cloud-configs').catch(function(){ return []; }),
api('/api/admin/verify/status').catch(function(){ return {}; }),
api('/api/admin/system-configs').catch(function(){ return {}; })
]).then(function(res){
var accounts = res[0] || [];
var vStatus = res[1] || {};
var configs = res[2] || {};
renderCleanupPage(accounts, vStatus, configs);
});
}
function renderCleanupPage(accounts, vStatus, configs){
var lastVerify = vStatus.last_run ? new Date(vStatus.last_run).toLocaleString() : 'never';
var lastCleanup = configs.cleanup_last_run || 'never';
var h = '';
h += '<div class="card"><div class="card-title">Storage Cleanup & Management</div>';
h += '<p style="color:var(--sub);font-size:12px">Cloud verification, space refresh, scheduled cleanup</p></div>';
// Quick Actions
h += '<div class="card"><div class="card-title">Quick Actions</div>';
h += '<div style="display:flex;flex-wrap:wrap;gap:10px;margin-bottom:12px">';
h += '<button class="btn btn-pri" onclick="runVerifyAll()">Verify All</button>';
h += '<button class="btn btn-pri" onclick="runStorageRefresh()">Refresh All Space</button>';
h += '<button class="btn btn-warn" onclick="runFullCleanup()">Full Cleanup</button>';
h += '<button class="btn btn-outline" onclick="runEmptyTrash()">Empty Trash</button>';
h += '</div>';
h += '<div id="actionStatus" style="font-size:12px;color:var(--sub);margin-top:8px"></div>';
h += '<div style="font-size:12px;color:var(--sub);margin-top:4px">Last verify: ' + lastVerify + ' | Last cleanup: ' + lastCleanup + '</div></div>';
// Account Table
h += '<div class="card"><div class="card-title">Account Verification</div>';
h += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">';
h += '<thead><tr style="background:var(--bg);border-bottom:2px solid var(--border);text-align:left">';
h += '<th style="padding:8px 10px">Type</th><th style="padding:8px 10px">Nickname</th>';
h += '<th style="padding:8px 10px">Status</th><th style="padding:8px 10px">Space</th>';
h += '<th style="padding:8px 10px">Action</th></tr></thead><tbody>';
accounts.forEach(function(cfg){
var v = cfg.verification_status;
var vIcon = v==='valid'?'OK':(v==='invalid'?'FAIL':'PENDING');
var space = cfg.storage_used ? (cfg.storage_used + ' / ' + (cfg.storage_total||'?')) : '-';
h += '<tr style="border-bottom:1px solid var(--border)">';
h += '<td style="padding:8px 10px">' + (cfg.cloud_type||'?') + '</td>';
h += '<td style="padding:8px 10px">' + (cfg.nickname||'-') + '</td>';
h += '<td style="padding:8px 10px">' + vIcon + '</td>';
h += '<td style="padding:8px 10px;font-size:12px;color:var(--sub)">' + space + '</td>';
h += '<td style="padding:8px 10px"><button class="btn btn-sm btn-outline" onclick="runSingleVerify('+cfg.id+',this)">Verify</button></td></tr>';
});
if(accounts.length===0){
h += '<tr><td colspan="5" style="padding:20px;text-align:center;color:var(--sub)">No accounts found</td></tr>';
}
h += '</tbody></table></div></div>';
// Cleanup Config
h += '<div class="card"><div class="card-title">Cleanup Config</div>';
h += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:13px">';
h += cfgRow('Cleanup Enabled', 'cleanup_enabled', configs, 'checkbox');
h += cfgRow('File Retention (days)', 'cleanup_file_retention_days', configs, 'number', '7');
h += cfgRow('Log Retention (days)', 'cleanup_log_retention_days', configs, 'number', '30');
h += cfgRow('Empty Trash After', 'cleanup_empty_trash', configs, 'checkbox');
h += cfgRow('Space Threshold Cleanup', 'cleanup_space_threshold_enabled', configs, 'checkbox');
h += cfgRow('Threshold %', 'cleanup_space_threshold_percent', configs, 'number', '90');
h += cfgRow('Delete %', 'cleanup_space_threshold_delete_percent', configs, 'number', '10');
h += '</div>';
h += '<button class="btn btn-pri" style="margin-top:12px" onclick="saveCleanupConfig()">Save Config</button></div>';
document.getElementById('cleanupRoot').innerHTML = h;
}
function cfgRow(label, key, configs, type, placeholder){
var val = configs[key] || '';
if(type==='checkbox'){
var chk = val==='true'?'checked':'';
return '<div><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" class="cleanupCfg" data-key="'+key+'" '+chk+'><span>'+label+'</span></label></div>';
}
return '<div><label style="font-size:12px;color:var(--sub)">'+label+'</label><input class="form-control cleanupCfg" data-key="'+key+'" value="'+val+'" placeholder="'+placeholder+'" style="max-width:120px"></div>';
}
function runVerifyAll(){
var el = document.getElementById('actionStatus');
el.innerHTML = 'Verifying all accounts...';
api('/api/admin/verify/run', {method:'POST'}).then(function(r){
el.innerHTML = 'Done: '+(r.ok||0)+'/'+r.total+' OK, '+r.failed+' failed';
setTimeout(loadCleanupPage, 2000);
}).catch(function(e){
el.innerHTML = 'Verify failed: '+(e.message||'network error');
});
}
function runSingleVerify(id, btn){
btn.disabled = true; btn.textContent = '...';
api('/api/admin/verify/single/'+id, {method:'POST'}).then(function(r){
btn.textContent = r.success ? 'OK' : 'FAIL';
}).catch(function(){ btn.textContent = 'ERR'; })
.finally(function(){ setTimeout(loadCleanupPage, 1500); });
}
function runStorageRefresh(){
var el = document.getElementById('actionStatus');
el.innerHTML = 'Refreshing space info...';
api('/api/admin/storage/refresh', {method:'POST'}).then(function(){
el.innerHTML = 'Space refreshed';
setTimeout(loadCleanupPage, 2000);
}).catch(function(e){
el.innerHTML = 'Refresh failed: '+(e.message||'network error');
});
}
function runFullCleanup(){
var el = document.getElementById('actionStatus');
el.innerHTML = 'Running full cleanup...';
api('/api/admin/cleanup/run', {method:'POST'}).then(function(r){
el.innerHTML = (r.message||'Cleanup done');
setTimeout(loadCleanupPage, 2000);
}).catch(function(e){
el.innerHTML = 'Cleanup failed: '+(e.message||'network error');
});
}
function runEmptyTrash(){
var el = document.getElementById('actionStatus');
if(!confirm('Empty all trash? This cannot be undone.')) return;
el.innerHTML = 'Emptying trash...';
api('/api/admin/cleanup/empty-trash', {method:'POST'}).then(function(r){
el.innerHTML = r.emptied ? 'Trash emptied' : (r.message||'Done');
}).catch(function(e){
el.innerHTML = 'Failed: '+(e.message||'network error');
});
}
function saveCleanupConfig(){
var entries = [];
document.querySelectorAll('.cleanupCfg').forEach(function(el){
var key = el.dataset.key;
var val = el.type==='checkbox' ? (el.checked?'true':'false') : el.value.trim();
entries.push({key:key, value:val});
});
api('/api/admin/system-configs', {
method:'PUT', headers:{'Content-Type':'application/json'},
body:JSON.stringify({entries:entries})
}).then(function(){
toast('Config saved');
}).catch(function(e){
toast('Save failed: '+(e.message||''));
});
}
@@ -0,0 +1,39 @@
var TOKEN = localStorage.getItem('admin_token') || '';
var TITLES = {dashboard:'仪表盘','sys-site':'网站设置','sys-services':'外部服务','sys-manage':'服务管理','sys-strategy':'性能配置','sys-password':'修改密码','cloud-config':'网盘设置',cleanup:'存储清理'};
function api(path, opts){
opts = opts || {};
opts.headers = opts.headers || {};
if(TOKEN) opts.headers['Authorization'] = 'Bearer ' + TOKEN;
return fetch(path, opts).then(function(r){
if(r.status === 401){ localStorage.removeItem('admin_token'); location.reload(); throw new Error('unauth'); }
return r.json();
});
}
function toast(msg, type){
type = type || 'success';
var t = document.createElement('div'); t.className = 'toast toast-'+type; t.textContent = msg;
document.body.appendChild(t);
setTimeout(function(){ t.remove(); }, 2500);
}
document.querySelectorAll('.nav-item').forEach(function(el){
el.onclick = function(){ nav(this.dataset.page); };
});
function nav(page){
document.querySelectorAll('.nav-item').forEach(function(e){ e.classList.remove('active'); });
var target = document.querySelector('[data-page="'+page+'"]');
if(target) target.classList.add('active');
document.getElementById('pageTitle').textContent = TITLES[page] || page;
var c = document.getElementById('content');
c.innerHTML = '<div class="loading">加载中...</div>';
try{ window['page_'+page.replace(/-/g,'_')](c); }
catch(e){ c.innerHTML = '<div class="card"><p>加载出错: '+e.message+'</p></div>'; }
}
function logout(){
localStorage.removeItem('admin_token');
location.reload();
}
@@ -0,0 +1,9 @@
function page_dashboard(c){
api('/api/admin/services').then(function(d){
var h = '<div class="card"><div class="card-title">系统概览</div>'+
'<div class="stats-grid">'+
'<div class="stat-card"><div class="stat-value">'+(d.length||0)+'</div><div class="stat-label">服务数</div></div>'+
'</div></div>';
c.innerHTML = h;
}).catch(function(){ c.innerHTML = '<div class="card">加载失败</div>'; });
}
@@ -0,0 +1 @@
// Helper utilities

Some files were not shown because too many files have changed in this diff Show More