mirror of
https://github.com/ctwj/urldb.git
synced 2025-11-25 03:15:04 +08:00
Compare commits
2 Commits
3370f75d5e
...
9e6b5a58c4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e6b5a58c4 | ||
|
|
040e6bc6bf |
@@ -56,22 +56,18 @@ func LoggingMiddleware(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// logRequest 记录请求日志 - 优化后仅记录异常和关键请求
|
// logRequest 记录请求日志 - 恢复正常请求日志记录
|
||||||
func logRequest(r *http.Request, rw *responseWriter, duration time.Duration, requestBody []byte) {
|
func logRequest(r *http.Request, rw *responseWriter, duration time.Duration, requestBody []byte) {
|
||||||
// 获取客户端IP
|
// 获取客户端IP
|
||||||
clientIP := getClientIP(r)
|
clientIP := getClientIP(r)
|
||||||
|
|
||||||
// 判断是否需要记录日志的条件
|
// 判断是否需要详细记录日志的条件
|
||||||
shouldLog := rw.statusCode >= 400 || // 错误状态码
|
shouldDetailLog := rw.statusCode >= 400 || // 错误状态码
|
||||||
duration > 5*time.Second || // 耗时过长
|
duration > 5*time.Second || // 耗时过长
|
||||||
shouldLogPath(r.URL.Path) || // 关键路径
|
shouldLogPath(r.URL.Path) || // 关键路径
|
||||||
isAdminPath(r.URL.Path) // 管理员路径
|
isAdminPath(r.URL.Path) // 管理员路径
|
||||||
|
|
||||||
if !shouldLog {
|
// 所有API请求都记录基本信息,但详细日志只记录重要请求
|
||||||
return // 正常请求不记录日志,减少日志噪音
|
|
||||||
}
|
|
||||||
|
|
||||||
// 简化的日志格式,移除User-Agent以减少噪音
|
|
||||||
if rw.statusCode >= 400 {
|
if rw.statusCode >= 400 {
|
||||||
// 错误请求记录详细信息
|
// 错误请求记录详细信息
|
||||||
utils.Error("HTTP异常 - %s %s - IP: %s - 状态码: %d - 耗时: %v",
|
utils.Error("HTTP异常 - %s %s - IP: %s - 状态码: %d - 耗时: %v",
|
||||||
@@ -85,10 +81,14 @@ func logRequest(r *http.Request, rw *responseWriter, duration time.Duration, req
|
|||||||
// 慢请求警告
|
// 慢请求警告
|
||||||
utils.Warn("HTTP慢请求 - %s %s - IP: %s - 耗时: %v",
|
utils.Warn("HTTP慢请求 - %s %s - IP: %s - 耗时: %v",
|
||||||
r.Method, r.URL.Path, clientIP, duration)
|
r.Method, r.URL.Path, clientIP, duration)
|
||||||
} else {
|
} else if shouldDetailLog {
|
||||||
// 关键路径的正常请求
|
// 关键路径的正常请求
|
||||||
utils.Info("HTTP关键请求 - %s %s - IP: %s - 状态码: %d - 耗时: %v",
|
utils.Info("HTTP关键请求 - %s %s - IP: %s - 状态码: %d - 耗时: %v",
|
||||||
r.Method, r.URL.Path, clientIP, rw.statusCode, duration)
|
r.Method, r.URL.Path, clientIP, rw.statusCode, duration)
|
||||||
|
} else {
|
||||||
|
// 普通API请求记录简化日志 - 使用Info级别确保能被看到
|
||||||
|
// utils.Info("HTTP请求 - %s %s - 状态码: %d - 耗时: %v",
|
||||||
|
// r.Method, r.URL.Path, rw.statusCode, duration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +100,13 @@ func shouldLogPath(path string) bool {
|
|||||||
"/api/admin/config",
|
"/api/admin/config",
|
||||||
"/api/admin/users",
|
"/api/admin/users",
|
||||||
"/telegram/webhook",
|
"/telegram/webhook",
|
||||||
|
"/api/resources",
|
||||||
|
"/api/version",
|
||||||
|
"/api/cks",
|
||||||
|
"/api/pans",
|
||||||
|
"/api/categories",
|
||||||
|
"/api/tags",
|
||||||
|
"/api/tasks",
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, keyPath := range keyPaths {
|
for _, keyPath := range keyPaths {
|
||||||
@@ -113,7 +120,7 @@ func shouldLogPath(path string) bool {
|
|||||||
// isAdminPath 判断是否为管理员路径
|
// isAdminPath 判断是否为管理员路径
|
||||||
func isAdminPath(path string) bool {
|
func isAdminPath(path string) bool {
|
||||||
return strings.HasPrefix(path, "/api/admin/") ||
|
return strings.HasPrefix(path, "/api/admin/") ||
|
||||||
strings.HasPrefix(path, "/admin/")
|
strings.HasPrefix(path, "/admin/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// getClientIP 获取客户端真实IP地址
|
// getClientIP 获取客户端真实IP地址
|
||||||
|
|||||||
@@ -28,23 +28,40 @@ func GetTelegramLogs(startTime *time.Time, endTime *time.Time, limit int) ([]Tel
|
|||||||
return []TelegramLogEntry{}, nil
|
return []TelegramLogEntry{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
files, err := filepath.Glob(filepath.Join(logDir, "app_*.log"))
|
// 查找所有日志文件,包括当前的app.log和历史日志文件
|
||||||
|
allFiles, err := filepath.Glob(filepath.Join(logDir, "*.log"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("查找日志文件失败: %v", err)
|
return nil, fmt.Errorf("查找日志文件失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(files) == 0 {
|
if len(allFiles) == 0 {
|
||||||
return []TelegramLogEntry{}, nil
|
return []TelegramLogEntry{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按时间排序,最近的在前面
|
// 将app.log放在最前面,其他文件按时间排序
|
||||||
sort.Sort(sort.Reverse(sort.StringSlice(files)))
|
var files []string
|
||||||
|
var otherFiles []string
|
||||||
|
|
||||||
|
for _, file := range allFiles {
|
||||||
|
if filepath.Base(file) == "app.log" {
|
||||||
|
files = append(files, file) // 当前日志文件优先
|
||||||
|
} else {
|
||||||
|
otherFiles = append(otherFiles, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他文件按时间排序,最近的在前面
|
||||||
|
sort.Sort(sort.Reverse(sort.StringSlice(otherFiles)))
|
||||||
|
files = append(files, otherFiles...)
|
||||||
|
|
||||||
|
// files现在已经是app.log优先,然后是其他文件按时间倒序排列
|
||||||
|
|
||||||
var allEntries []TelegramLogEntry
|
var allEntries []TelegramLogEntry
|
||||||
|
|
||||||
// 编译Telegram相关的正则表达式
|
// 编译Telegram相关的正则表达式
|
||||||
telegramRegex := regexp.MustCompile(`(?i)(\[TELEGRAM.*?\])`)
|
telegramRegex := regexp.MustCompile(`(?i)(\[TELEGRAM.*?\])`)
|
||||||
messageRegex := regexp.MustCompile(`\[(\w+)\]\s+(\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[.*?\]\s+(.*)`)
|
// 修正正则表达式以匹配实际的日志格式: 2025/01/20 14:30:15 [INFO] [file:line] [TELEGRAM] message
|
||||||
|
messageRegex := regexp.MustCompile(`(\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[(\w+)\]\s+\[.*?:\d+\]\s+\[TELEGRAM.*?\]\s+(.*)`)
|
||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
entries, err := parseTelegramLogsFromFile(file, telegramRegex, messageRegex, startTime, endTime)
|
entries, err := parseTelegramLogsFromFile(file, telegramRegex, messageRegex, startTime, endTime)
|
||||||
@@ -119,18 +136,23 @@ func parseTelegramLogsFromFile(filePath string, telegramRegex, messageRegex *reg
|
|||||||
|
|
||||||
// parseLogLine 解析单行日志
|
// parseLogLine 解析单行日志
|
||||||
func parseLogLine(line string, messageRegex *regexp.Regexp) (TelegramLogEntry, error) {
|
func parseLogLine(line string, messageRegex *regexp.Regexp) (TelegramLogEntry, error) {
|
||||||
// 匹配日志格式: [LEVEL] 2006/01/02 15:04:05 [file:line] message
|
// 匹配日志格式: 2006/01/02 15:04:05 [LEVEL] [file:line] [TELEGRAM] message
|
||||||
matches := messageRegex.FindStringSubmatch(line)
|
matches := messageRegex.FindStringSubmatch(line)
|
||||||
if len(matches) < 4 {
|
if len(matches) < 4 {
|
||||||
return TelegramLogEntry{}, fmt.Errorf("无法解析日志行: %s", line)
|
return TelegramLogEntry{}, fmt.Errorf("无法解析日志行: %s", line)
|
||||||
}
|
}
|
||||||
|
|
||||||
level := matches[1]
|
timeStr := matches[1]
|
||||||
timeStr := matches[2]
|
level := matches[2]
|
||||||
message := matches[3]
|
message := matches[3]
|
||||||
|
|
||||||
// 解析时间
|
// 解析时间(使用本地时区)
|
||||||
timestamp, err := time.Parse("2006/01/02 15:04:05", timeStr)
|
location, err := time.LoadLocation("Asia/Shanghai")
|
||||||
|
if err != nil {
|
||||||
|
return TelegramLogEntry{}, fmt.Errorf("加载时区失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp, err := time.ParseInLocation("2006/01/02 15:04:05", timeStr, location)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TelegramLogEntry{}, fmt.Errorf("时间解析失败: %v", err)
|
return TelegramLogEntry{}, fmt.Errorf("时间解析失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -203,7 +225,7 @@ func ClearOldTelegramLogs(daysToKeep int) error {
|
|||||||
return nil // 日志目录不存在,无需清理
|
return nil // 日志目录不存在,无需清理
|
||||||
}
|
}
|
||||||
|
|
||||||
files, err := filepath.Glob(filepath.Join(logDir, "app_*.log"))
|
files, err := filepath.Glob(filepath.Join(logDir, "*.log"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("查找日志文件失败: %v", err)
|
return fmt.Errorf("查找日志文件失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
46
web/components/SearchButton.vue
Normal file
46
web/components/SearchButton.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 搜索按钮组件 -->
|
||||||
|
<div class="search-button-container">
|
||||||
|
<!-- 搜索按钮 -->
|
||||||
|
<n-button
|
||||||
|
size="tiny"
|
||||||
|
type="tertiary"
|
||||||
|
round
|
||||||
|
ghost
|
||||||
|
class="!px-2 !py-1 !text-xs !text-white dark:!text-white !border-white/30 hover:!border-white"
|
||||||
|
@click="openSearch"
|
||||||
|
>
|
||||||
|
<i class="fas fa-search text-xs"></i>
|
||||||
|
<span class="ml-1 hidden sm:inline">搜索</span>
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<!-- 完整的搜索弹窗组件 -->
|
||||||
|
<SearchModal ref="searchModalRef" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import SearchModal from './SearchModal.vue'
|
||||||
|
|
||||||
|
// 搜索弹窗的引用
|
||||||
|
const searchModalRef = ref()
|
||||||
|
|
||||||
|
// 打开搜索弹窗
|
||||||
|
const openSearch = () => {
|
||||||
|
searchModalRef.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露给父组件的方法
|
||||||
|
defineExpose({
|
||||||
|
openSearch,
|
||||||
|
closeSearch: () => searchModalRef.value?.hide(),
|
||||||
|
toggleSearch: () => searchModalRef.value?.toggle()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.search-button-container {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
423
web/components/SearchModal.vue
Normal file
423
web/components/SearchModal.vue
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
<template>
|
||||||
|
<ClientOnly>
|
||||||
|
<!-- 自定义背景遮罩 -->
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"
|
||||||
|
@click="handleBackdropClick"
|
||||||
|
>
|
||||||
|
<!-- 背景模糊遮罩 -->
|
||||||
|
<div class="absolute inset-0 bg-black/20 backdrop-blur-sm"></div>
|
||||||
|
|
||||||
|
<!-- 搜索弹窗 -->
|
||||||
|
<div
|
||||||
|
class="relative w-full max-w-2xl mx-4 transform transition-all duration-200 ease-out"
|
||||||
|
:class="visible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<!-- 搜索输入区域 -->
|
||||||
|
<div class="relative bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<!-- 顶部装饰条 -->
|
||||||
|
<div class="h-1 bg-gradient-to-r from-green-500 via-emerald-500 to-teal-500"></div>
|
||||||
|
|
||||||
|
<!-- 搜索输入框 -->
|
||||||
|
<div class="relative px-6 py-5">
|
||||||
|
<div class="relative flex items-center">
|
||||||
|
<!-- 搜索图标 -->
|
||||||
|
<div class="absolute left-4 flex items-center pointer-events-none">
|
||||||
|
<div class="w-5 h-5 rounded-full bg-gradient-to-r from-green-500 to-emerald-500 flex items-center justify-center">
|
||||||
|
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 输入框 -->
|
||||||
|
<input
|
||||||
|
ref="searchInput"
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索资源..."
|
||||||
|
class="w-full pl-12 pr-32 py-4 bg-transparent border-0 text-lg text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-0"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
@input="handleInputChange"
|
||||||
|
@keydown.escape="handleClose"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- 搜索按钮 -->
|
||||||
|
<div class="absolute right-2 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
v-if="searchQuery.trim()"
|
||||||
|
type="button"
|
||||||
|
@click="clearSearch"
|
||||||
|
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="handleSearch"
|
||||||
|
:disabled="!searchQuery.trim()"
|
||||||
|
:loading="searching"
|
||||||
|
class="px-4 py-2 bg-gradient-to-r from-green-500 to-emerald-500 text-white text-sm font-medium rounded-lg hover:from-green-600 hover:to-emerald-600 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 transform hover:scale-105"
|
||||||
|
>
|
||||||
|
<span v-if="!searching" class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
搜索
|
||||||
|
</span>
|
||||||
|
<span v-else class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
搜索中
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索建议下拉 -->
|
||||||
|
<div v-if="showSuggestions && suggestions.length > 0" class="border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="max-h-60 overflow-y-auto">
|
||||||
|
<div class="px-6 py-3">
|
||||||
|
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">搜索建议</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<button
|
||||||
|
v-for="(suggestion, index) in suggestions"
|
||||||
|
:key="index"
|
||||||
|
@click="selectSuggestion(suggestion)"
|
||||||
|
class="w-full flex items-center gap-3 px-3 py-2.5 text-left rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors group"
|
||||||
|
>
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||||
|
<svg class="w-4 h-4 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ suggestion }}</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">点击搜索 "{{ suggestion }}"</div>
|
||||||
|
</div>
|
||||||
|
<svg class="w-4 h-4 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索历史 -->
|
||||||
|
<div v-if="searchHistory.length > 0" class="border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-6 h-6 rounded-lg bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||||
|
<svg class="w-3 h-3 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">最近搜索</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="clearHistory"
|
||||||
|
class="text-xs text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="(item, index) in searchHistory.slice(0, 8)"
|
||||||
|
:key="index"
|
||||||
|
@click="selectHistory(item)"
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-1.5 text-sm bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 group"
|
||||||
|
>
|
||||||
|
<svg class="w-3 h-3 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
{{ item }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索提示 -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 bg-gradient-to-r from-green-50 via-emerald-50 to-teal-50 dark:from-green-900/20 dark:via-emerald-900/20 dark:to-teal-900/20">
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-white dark:bg-gray-800 shadow-sm flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">搜索技巧</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||||
|
<span>支持多关键词搜索,用空格分隔</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>
|
||||||
|
<span>按 <kbd class="px-1.5 py-0.5 text-xs bg-white dark:bg-gray-700 rounded border border-gray-300 dark:border-gray-600 font-mono">Ctrl+K</kbd> 快速打开</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full bg-teal-400"></span>
|
||||||
|
<span>按 <kbd class="px-1.5 py-0.5 text-xs bg-white dark:bg-gray-700 rounded border border-gray-300 dark:border-gray-600 font-mono">Esc</kbd> 关闭弹窗</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||||
|
<span>搜索历史自动保存,方便下次使用</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ClientOnly>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
// 组件状态 - 完全内部管理
|
||||||
|
const visible = ref(false)
|
||||||
|
const searchInput = ref<any>(null)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const searching = ref(false)
|
||||||
|
const showSuggestions = ref(false)
|
||||||
|
const searchHistory = ref<string[]>([])
|
||||||
|
|
||||||
|
// 路由器
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const suggestions = computed(() => {
|
||||||
|
if (!searchQuery.value.trim()) return []
|
||||||
|
|
||||||
|
const query = searchQuery.value.toLowerCase().trim()
|
||||||
|
|
||||||
|
return searchHistory.value
|
||||||
|
.filter(item => item.toLowerCase().includes(query))
|
||||||
|
.filter(item => item.toLowerCase() !== query)
|
||||||
|
.slice(0, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 初始化搜索历史
|
||||||
|
const initSearchHistory = () => {
|
||||||
|
if (process.client && typeof localStorage !== 'undefined') {
|
||||||
|
const history = localStorage.getItem('searchHistory')
|
||||||
|
if (history) {
|
||||||
|
try {
|
||||||
|
searchHistory.value = JSON.parse(history)
|
||||||
|
} catch (e) {
|
||||||
|
searchHistory.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存搜索历史
|
||||||
|
const saveSearchHistory = () => {
|
||||||
|
if (process.client && typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem('searchHistory', JSON.stringify(searchHistory.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理输入变化
|
||||||
|
const handleInputChange = () => {
|
||||||
|
showSuggestions.value = searchQuery.value.trim().length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
const query = searchQuery.value.trim()
|
||||||
|
if (!query) return
|
||||||
|
|
||||||
|
searching.value = true
|
||||||
|
|
||||||
|
// 添加到搜索历史
|
||||||
|
if (!searchHistory.value.includes(query)) {
|
||||||
|
searchHistory.value.unshift(query)
|
||||||
|
if (searchHistory.value.length > 10) {
|
||||||
|
searchHistory.value = searchHistory.value.slice(0, 10)
|
||||||
|
}
|
||||||
|
saveSearchHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭弹窗
|
||||||
|
visible.value = false
|
||||||
|
|
||||||
|
// 跳转到搜索页面
|
||||||
|
nextTick(() => {
|
||||||
|
router.push(`/?search=${encodeURIComponent(query)}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
searching.value = false
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空搜索
|
||||||
|
const clearSearch = () => {
|
||||||
|
searchQuery.value = ''
|
||||||
|
showSuggestions.value = false
|
||||||
|
nextTick(() => {
|
||||||
|
searchInput.value?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择搜索建议
|
||||||
|
const selectSuggestion = (suggestion: string) => {
|
||||||
|
searchQuery.value = suggestion
|
||||||
|
showSuggestions.value = false
|
||||||
|
nextTick(() => {
|
||||||
|
searchInput.value?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择历史记录
|
||||||
|
const selectHistory = (item: string) => {
|
||||||
|
searchQuery.value = item
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空历史
|
||||||
|
const clearHistory = () => {
|
||||||
|
searchHistory.value = []
|
||||||
|
saveSearchHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理背景点击
|
||||||
|
const handleBackdropClick = () => {
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理关闭
|
||||||
|
const handleClose = () => {
|
||||||
|
visible.value = false
|
||||||
|
searchQuery.value = ''
|
||||||
|
showSuggestions.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听弹窗显示状态
|
||||||
|
watch(visible, (newValue) => {
|
||||||
|
if (newValue && process.client) {
|
||||||
|
nextTick(() => {
|
||||||
|
searchInput.value?.focus()
|
||||||
|
initSearchHistory()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
searchQuery.value = ''
|
||||||
|
showSuggestions.value = false
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 键盘事件监听
|
||||||
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!visible.value) {
|
||||||
|
visible.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape' && visible.value) {
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件挂载时添加键盘事件监听器
|
||||||
|
onMounted(() => {
|
||||||
|
if (process.client && typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件卸载时清理事件监听器
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (process.client && typeof document !== 'undefined') {
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 暴露给父组件的方法
|
||||||
|
defineExpose({
|
||||||
|
show: () => { visible.value = true },
|
||||||
|
hide: () => { handleClose() },
|
||||||
|
toggle: () => { visible.value = !visible.value }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 自定义动画 */
|
||||||
|
.transform {
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条样式 */
|
||||||
|
.overflow-y-auto::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(156, 163, 175, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(156, 163, 175, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 深色模式滚动条 */
|
||||||
|
.dark .overflow-y-auto::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(75, 85, 99, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(75, 85, 99, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 键盘快捷键样式 */
|
||||||
|
kbd {
|
||||||
|
box-shadow: 0 1px 0 1px rgba(0, 0, 0, 0.1), 0 1px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮悬停效果 */
|
||||||
|
button {
|
||||||
|
transition: all 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 输入框聚焦效果 */
|
||||||
|
input:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 渐变动画 */
|
||||||
|
@keyframes gradient {
|
||||||
|
0%, 100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gradient-to-r {
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: gradient 3s ease infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="share-container">
|
|
||||||
<!-- 直接显示分享按钮 -->
|
|
||||||
<div
|
|
||||||
ref="socialShareElement"
|
|
||||||
class="social-share-wrapper"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
const props = defineProps({
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
url: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
tags: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
// 响应式数据
|
|
||||||
const socialShareElement = ref(null)
|
|
||||||
|
|
||||||
// 计算属性 - 避免在SSR中访问客户端API
|
|
||||||
const shareTitle = computed(() => {
|
|
||||||
return props.title && props.title !== 'undefined' ? props.title : '精彩资源分享'
|
|
||||||
})
|
|
||||||
|
|
||||||
const shareDescription = computed(() => {
|
|
||||||
return props.description && props.description !== 'undefined' ? props.description : '发现更多优质资源,尽在urlDB'
|
|
||||||
})
|
|
||||||
|
|
||||||
const shareTags = computed(() => {
|
|
||||||
if (props.tags && Array.isArray(props.tags) && props.tags.length > 0) {
|
|
||||||
return props.tags.filter(tag => tag && tag !== 'undefined').slice(0, 3).join(',') || '资源分享,网盘,urldb'
|
|
||||||
}
|
|
||||||
return '资源分享,网盘,urldb'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 获取完整URL - 使用运行时配置
|
|
||||||
const getFullUrl = () => {
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
|
|
||||||
if (props.url) {
|
|
||||||
// 如果props.url已经是完整URL,则直接返回
|
|
||||||
if (props.url.startsWith('http://') || props.url.startsWith('https://')) {
|
|
||||||
return props.url
|
|
||||||
}
|
|
||||||
// 否则拼接站点URL
|
|
||||||
let siteUrl = config.public.siteUrl
|
|
||||||
if (!siteUrl || siteUrl === 'https://yourdomain.com') {
|
|
||||||
// 优先在客户端使用当前页面的origin
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
siteUrl = window.location.origin
|
|
||||||
} else {
|
|
||||||
// 在服务端渲染时使用默认值
|
|
||||||
siteUrl = process.env.NUXT_PUBLIC_SITE_URL || 'https://yourdomain.com'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `${siteUrl}${props.url.startsWith('/') ? props.url : '/' + props.url}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
return `${window.location.origin}${route.fullPath}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return route.fullPath
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化 social-share - 仅在客户端调用
|
|
||||||
const initSocialShare = () => {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
|
|
||||||
if (socialShareElement.value) {
|
|
||||||
// 清空容器
|
|
||||||
socialShareElement.value.innerHTML = ''
|
|
||||||
|
|
||||||
// 创建 social-share 元素
|
|
||||||
const shareElement = document.createElement('div')
|
|
||||||
shareElement.className = 'social-share'
|
|
||||||
shareElement.setAttribute('data-sites', 'facebook,twitter,reddit')
|
|
||||||
shareElement.setAttribute('data-title', shareTitle.value)
|
|
||||||
shareElement.setAttribute('data-description', shareDescription.value)
|
|
||||||
shareElement.setAttribute('data-url', getFullUrl())
|
|
||||||
shareElement.setAttribute('data-image', '') // 设置默认图片
|
|
||||||
shareElement.setAttribute('data-pics', '') // 设置图片(QQ空间使用)
|
|
||||||
shareElement.setAttribute('data-via', '') // Twitter via
|
|
||||||
shareElement.setAttribute('data-wechat-qrcode-title', '微信扫一扫:分享')
|
|
||||||
shareElement.setAttribute('data-wechat-qrcode-helper', '<p>微信里点"发现",扫一下</p><p>二维码便可将本文分享至朋友圈。</p>')
|
|
||||||
|
|
||||||
socialShareElement.value.appendChild(shareElement)
|
|
||||||
|
|
||||||
// 初始化 social-share - 等待一段时间确保库已完全加载
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log('检查 SocialShare 对象:', window.SocialShare)
|
|
||||||
console.log('检查 social-share 元素:', shareElement)
|
|
||||||
|
|
||||||
// 尝试使用 social-share.js 的正确初始化方式
|
|
||||||
if (window.socialShare) {
|
|
||||||
try {
|
|
||||||
// 传入选择器来初始化
|
|
||||||
window.socialShare('.social-share')
|
|
||||||
console.log('socialShare() 函数调用成功')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('socialShare 初始化失败:', error)
|
|
||||||
// 如果上面失败,尝试另一种方式
|
|
||||||
try {
|
|
||||||
if (typeof window.socialShare === 'function') {
|
|
||||||
window.socialShare()
|
|
||||||
console.log('socialShare 全局调用成功')
|
|
||||||
}
|
|
||||||
} catch (error2) {
|
|
||||||
console.error('socialShare 全局调用也失败:', error2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (window.SocialShare) {
|
|
||||||
try {
|
|
||||||
window.SocialShare.init()
|
|
||||||
console.log('SocialShare.init() 调用成功')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('SocialShare 初始化失败:', error)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('SocialShare 对象不存在,库可能未正确加载')
|
|
||||||
}
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 动态加载 social-share.js 和 CSS - 仅在客户端调用
|
|
||||||
const loadSocialShare = () => {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
|
|
||||||
// 加载 CSS 文件
|
|
||||||
if (!document.querySelector('link[href*="social-share.min.css"]')) {
|
|
||||||
const link = document.createElement('link')
|
|
||||||
link.rel = 'stylesheet'
|
|
||||||
link.href = 'https://cdn.jsdelivr.net/npm/social-share.js@1.0.16/dist/css/share.min.css'
|
|
||||||
link.onload = () => {
|
|
||||||
console.log('social-share.css 加载完成')
|
|
||||||
}
|
|
||||||
link.onerror = () => {
|
|
||||||
console.error('social-share.css 加载失败')
|
|
||||||
// 如果CDN加载失败,尝试备用链接
|
|
||||||
const backupLink = document.createElement('link')
|
|
||||||
backupLink.rel = 'stylesheet'
|
|
||||||
backupLink.href = 'https://unpkg.com/social-share.js@1.0.16/dist/css/share.min.css'
|
|
||||||
backupLink.onload = () => {
|
|
||||||
console.log('备用 social-share.css 加载完成')
|
|
||||||
}
|
|
||||||
backupLink.onerror = () => {
|
|
||||||
console.error('备用 social-share.css 也加载失败')
|
|
||||||
}
|
|
||||||
document.head.appendChild(backupLink)
|
|
||||||
}
|
|
||||||
document.head.appendChild(link)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!window.socialShare) {
|
|
||||||
console.log('开始加载 social-share.js...')
|
|
||||||
const script = document.createElement('script')
|
|
||||||
script.src = 'https://cdn.jsdelivr.net/npm/social-share.js@1.0.16/dist/js/social-share.min.js'
|
|
||||||
script.onload = () => {
|
|
||||||
console.log('social-share.js 加载完成,检查全局对象:', window.socialShare)
|
|
||||||
// 加载完成后初始化
|
|
||||||
nextTick(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
initSocialShare()
|
|
||||||
}, 300) // 稍微增加等待时间确保CSS和JS都完全加载
|
|
||||||
})
|
|
||||||
}
|
|
||||||
script.onerror = () => {
|
|
||||||
console.error('social-share.js 加载失败')
|
|
||||||
// 如果CDN加载失败,尝试备用链接
|
|
||||||
const backupScript = document.createElement('script')
|
|
||||||
backupScript.src = 'https://unpkg.com/social-share.js@1.0.16/dist/js/social-share.min.js'
|
|
||||||
backupScript.onload = () => {
|
|
||||||
console.log('备用 social-share.js 加载完成,检查全局对象:', window.socialShare)
|
|
||||||
nextTick(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
initSocialShare()
|
|
||||||
}, 300)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
backupScript.onerror = () => {
|
|
||||||
console.error('备用 social-share.js 也加载失败')
|
|
||||||
// 如果无法加载外部库,创建基本分享按钮
|
|
||||||
createFallbackShareButtons()
|
|
||||||
}
|
|
||||||
document.head.appendChild(backupScript)
|
|
||||||
}
|
|
||||||
document.head.appendChild(script)
|
|
||||||
} else {
|
|
||||||
// 如果已经加载过,直接初始化
|
|
||||||
console.log('socialShare 已存在,直接初始化')
|
|
||||||
initSocialShare()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建备选分享按钮,当social-share.js无法加载时使用
|
|
||||||
const createFallbackShareButtons = () => {
|
|
||||||
if (typeof window === 'undefined' || !socialShareElement.value) return
|
|
||||||
|
|
||||||
// 清空容器
|
|
||||||
socialShareElement.value.innerHTML = ''
|
|
||||||
|
|
||||||
// 创建包含基本分享功能的按钮
|
|
||||||
const shareContainer = document.createElement('div')
|
|
||||||
shareContainer.className = 'fallback-share-buttons'
|
|
||||||
|
|
||||||
const fullUrl = getFullUrl()
|
|
||||||
const encodedUrl = encodeURIComponent(fullUrl)
|
|
||||||
const encodedTitle = encodeURIComponent(shareTitle.value)
|
|
||||||
const encodedDesc = encodeURIComponent(shareDescription.value)
|
|
||||||
|
|
||||||
// Facebook分享链接
|
|
||||||
const facebookLink = document.createElement('a')
|
|
||||||
facebookLink.href = `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}&t=${encodedTitle}`
|
|
||||||
facebookLink.target = '_blank'
|
|
||||||
facebookLink.innerHTML = '<i class="fa fa-facebook" style="font-size: 20px; color: #1877f2;"></i>'
|
|
||||||
facebookLink.style.display = 'inline-block'
|
|
||||||
facebookLink.style.margin = '0 3px'
|
|
||||||
facebookLink.title = '分享到Facebook'
|
|
||||||
|
|
||||||
// Twitter分享链接
|
|
||||||
const twitterLink = document.createElement('a')
|
|
||||||
twitterLink.href = `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`
|
|
||||||
twitterLink.target = '_blank'
|
|
||||||
twitterLink.innerHTML = '<i class="fa fa-twitter" style="font-size: 20px; color: #1da1f2;"></i>'
|
|
||||||
twitterLink.style.display = 'inline-block'
|
|
||||||
twitterLink.style.margin = '0 3px'
|
|
||||||
twitterLink.title = '分享到Twitter'
|
|
||||||
|
|
||||||
// Reddit分享链接
|
|
||||||
const redditLink = document.createElement('a')
|
|
||||||
redditLink.href = `https://www.reddit.com/submit?url=${encodedUrl}&title=${encodedTitle}`
|
|
||||||
redditLink.target = '_blank'
|
|
||||||
redditLink.innerHTML = '<i class="fa fa-reddit" style="font-size: 20px; color: #ff4500;"></i>'
|
|
||||||
redditLink.style.display = 'inline-block'
|
|
||||||
redditLink.style.margin = '0 3px'
|
|
||||||
redditLink.title = '分享到Reddit'
|
|
||||||
|
|
||||||
// 添加到容器
|
|
||||||
shareContainer.appendChild(facebookLink)
|
|
||||||
shareContainer.appendChild(twitterLink)
|
|
||||||
shareContainer.appendChild(redditLink)
|
|
||||||
|
|
||||||
socialShareElement.value.appendChild(shareContainer)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 组件挂载时直接初始化 - 仅在客户端执行
|
|
||||||
onMounted(() => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
// 页面加载完成后直接初始化 social-share
|
|
||||||
nextTick(() => {
|
|
||||||
loadSocialShare()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.share-container {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* social-share.js 样式适配 */
|
|
||||||
.social-share-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* social-share.js 默认样式覆盖 */
|
|
||||||
.social-share-wrapper .social-share {
|
|
||||||
display: flex !important;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-share-wrapper .social-share-icon {
|
|
||||||
width: 28px !important;
|
|
||||||
height: 28px !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-share-wrapper .social-share-icon:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 暗色模式下的 social-share 图标 */
|
|
||||||
.dark .social-share-wrapper .social-share-icon {
|
|
||||||
filter: brightness(0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 备选分享按钮样式 */
|
|
||||||
.fallback-share-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fallback-share-buttons a {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fallback-share-buttons a:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 响应式设计 */
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.social-share-wrapper .social-share-icon,
|
|
||||||
.fallback-share-buttons a {
|
|
||||||
width: 26px !important;
|
|
||||||
height: 26px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -36,8 +36,7 @@
|
|||||||
"qr-code-styling": "^1.9.2",
|
"qr-code-styling": "^1.9.2",
|
||||||
"vfonts": "^0.0.3",
|
"vfonts": "^0.0.3",
|
||||||
"vue": "^3.3.0",
|
"vue": "^3.3.0",
|
||||||
"vue-router": "^4.2.0",
|
"vue-router": "^4.2.0"
|
||||||
"vue-social-share": "^0.0.3"
|
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.13.0+sha512.beb9e2a803db336c10c9af682b58ad7181ca0fbd0d4119f2b33d5f2582e96d6c0d93c85b23869295b765170fbdaa92890c0da6ada457415039769edf3c959efe"
|
"packageManager": "pnpm@9.13.0+sha512.beb9e2a803db336c10c9af682b58ad7181ca0fbd0d4119f2b33d5f2582e96d6c0d93c85b23869295b765170fbdaa92890c0da6ada457415039769edf3c959efe"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,15 @@
|
|||||||
|
|
||||||
<!-- 右侧导航按钮 -->
|
<!-- 右侧导航按钮 -->
|
||||||
<nav class="mt-4 flex flex-col sm:flex-row justify-center gap-2 sm:gap-2 right-4 top-0 absolute">
|
<nav class="mt-4 flex flex-col sm:flex-row justify-center gap-2 sm:gap-2 right-4 top-0 absolute">
|
||||||
|
<!-- 返回首页按钮 -->
|
||||||
<NuxtLink to="/" class="hidden sm:flex">
|
<NuxtLink to="/" class="hidden sm:flex">
|
||||||
<n-button size="tiny" type="tertiary" round ghost class="!px-2 !py-1 !text-xs !text-white dark:!text-white !border-white/30 hover:!border-white">
|
<n-button size="tiny" type="tertiary" round ghost class="!px-2 !py-1 !text-xs !text-white dark:!text-white !border-white/30 hover:!border-white">
|
||||||
<i class="fas fa-arrow-left text-xs"></i>
|
<i class="fas fa-arrow-left text-xs"></i>
|
||||||
<span class="ml-1">返回首页</span>
|
<span class="ml-1">返回首页</span>
|
||||||
</n-button>
|
</n-button>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<!-- 搜索按钮 -->
|
||||||
|
<SearchButton />
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -154,14 +157,7 @@
|
|||||||
<i class="fas" :class="isDetecting ? 'fa-spinner fa-spin' : 'fa-sync-alt'"></i>
|
<i class="fas" :class="isDetecting ? 'fa-spinner fa-spin' : 'fa-sync-alt'"></i>
|
||||||
<span class="hidden sm:inline">{{ isDetecting ? '检测中' : '链接检测' }}</span>
|
<span class="hidden sm:inline">{{ isDetecting ? '检测中' : '链接检测' }}</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- 分享按钮 -->
|
</div>
|
||||||
<ShareButtons
|
|
||||||
:title="mainResource?.title"
|
|
||||||
:description="mainResource?.description"
|
|
||||||
:url="getResourceUrl"
|
|
||||||
:tags="mainResource?.tags?.map(tag => tag.name)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@@ -463,6 +459,7 @@
|
|||||||
@submitted="handleCopyrightSubmitted"
|
@submitted="handleCopyrightSubmitted"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
<!-- 页脚 -->
|
<!-- 页脚 -->
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
|
|
||||||
@@ -489,18 +486,27 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 获取运行时配置
|
// 导入必要的 Vue 函数
|
||||||
const config = useRuntimeConfig()
|
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { navigateTo } from '#app/composables'
|
||||||
|
import { useAsyncData } from '#app/composables'
|
||||||
|
import { useNotification } from 'naive-ui'
|
||||||
|
|
||||||
|
// 导入API
|
||||||
|
import { useResourceApi } from '~/composables/useApi'
|
||||||
|
|
||||||
|
// 导入组件
|
||||||
|
import SearchButton from '~/components/SearchButton.vue'
|
||||||
|
|
||||||
|
|
||||||
|
// 运行时配置已移除,因为未使用
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
// 获取资源key参数
|
// 获取资源key参数
|
||||||
const resourceKey = computed(() => route.params.key as string)
|
const resourceKey = computed(() => route.params.key as string)
|
||||||
|
|
||||||
// 导入API
|
|
||||||
import { useResourceApi, usePublicSystemConfigApi } from '~/composables/useApi'
|
|
||||||
import { useNotification } from 'naive-ui'
|
|
||||||
|
|
||||||
const resourceApi = useResourceApi()
|
const resourceApi = useResourceApi()
|
||||||
const publicSystemConfigApi = usePublicSystemConfigApi()
|
const publicSystemConfigApi = usePublicSystemConfigApi()
|
||||||
|
|
||||||
@@ -937,6 +943,7 @@ const handleCopyrightSubmitted = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 获取相关资源(客户端更新,用于交互优化)
|
// 获取相关资源(客户端更新,用于交互优化)
|
||||||
const fetchRelatedResources = async () => {
|
const fetchRelatedResources = async () => {
|
||||||
if (!mainResource.value) return
|
if (!mainResource.value) return
|
||||||
@@ -1158,7 +1165,8 @@ onMounted(() => {
|
|||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
smartDetectResourceValidity(false)
|
smartDetectResourceValidity(false)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
})
|
||||||
|
|
||||||
// 设置页面SEO
|
// 设置页面SEO
|
||||||
const { initSystemConfig, setPageSeo } = useGlobalSeo()
|
const { initSystemConfig, setPageSeo } = useGlobalSeo()
|
||||||
|
|||||||
21
web/pnpm-lock.yaml
generated
21
web/pnpm-lock.yaml
generated
@@ -50,9 +50,6 @@ importers:
|
|||||||
vue-router:
|
vue-router:
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.5.1(vue@3.5.18(typescript@5.8.3))
|
version: 4.5.1(vue@3.5.18(typescript@5.8.3))
|
||||||
vue-social-share:
|
|
||||||
specifier: ^0.0.3
|
|
||||||
version: 0.0.3
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@nuxt/devtools':
|
'@nuxt/devtools':
|
||||||
specifier: latest
|
specifier: latest
|
||||||
@@ -3916,9 +3913,6 @@ packages:
|
|||||||
smob@1.5.0:
|
smob@1.5.0:
|
||||||
resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==}
|
resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==}
|
||||||
|
|
||||||
social-share.js@1.0.16:
|
|
||||||
resolution: {integrity: sha512-NSV6fYFft/U0fEbjXdumZGU3c2oTbnJ6Ha5eNMEEBGsJpD+nu+nbg3LiRygO5GnoNgUa/dOmJyVHb/kM4dJa6g==}
|
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -4524,17 +4518,11 @@ packages:
|
|||||||
vue-devtools-stub@0.1.0:
|
vue-devtools-stub@0.1.0:
|
||||||
resolution: {integrity: sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==}
|
resolution: {integrity: sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==}
|
||||||
|
|
||||||
vue-github-badge@1.0.1:
|
|
||||||
resolution: {integrity: sha512-8X+FUWapnnDfs6cRUg3mCfHUf2r5arUfCSRdvbIn860oj9us3Rz3VOtioUgmfzh6EhaaYTs0Oh78EzJ+Z6uqAA==}
|
|
||||||
|
|
||||||
vue-router@4.5.1:
|
vue-router@4.5.1:
|
||||||
resolution: {integrity: sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==}
|
resolution: {integrity: sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^3.2.0
|
vue: ^3.2.0
|
||||||
|
|
||||||
vue-social-share@0.0.3:
|
|
||||||
resolution: {integrity: sha512-zzZGloWVTE/OrEFT0oVfVxzWBvak9KLWiIRWWkPWag10PlGgxTI4o1oN+kXIT+8U3MkRVA8cQLPf5CPqDGmfqw==}
|
|
||||||
|
|
||||||
vue@3.5.18:
|
vue@3.5.18:
|
||||||
resolution: {integrity: sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==}
|
resolution: {integrity: sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -8947,8 +8935,6 @@ snapshots:
|
|||||||
|
|
||||||
smob@1.5.0: {}
|
smob@1.5.0: {}
|
||||||
|
|
||||||
social-share.js@1.0.16: {}
|
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
source-map-support@0.5.21:
|
source-map-support@0.5.21:
|
||||||
@@ -9549,18 +9535,11 @@ snapshots:
|
|||||||
|
|
||||||
vue-devtools-stub@0.1.0: {}
|
vue-devtools-stub@0.1.0: {}
|
||||||
|
|
||||||
vue-github-badge@1.0.1: {}
|
|
||||||
|
|
||||||
vue-router@4.5.1(vue@3.5.18(typescript@5.8.3)):
|
vue-router@4.5.1(vue@3.5.18(typescript@5.8.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/devtools-api': 6.6.4
|
'@vue/devtools-api': 6.6.4
|
||||||
vue: 3.5.18(typescript@5.8.3)
|
vue: 3.5.18(typescript@5.8.3)
|
||||||
|
|
||||||
vue-social-share@0.0.3:
|
|
||||||
dependencies:
|
|
||||||
social-share.js: 1.0.16
|
|
||||||
vue-github-badge: 1.0.1
|
|
||||||
|
|
||||||
vue@3.5.18(typescript@5.8.3):
|
vue@3.5.18(typescript@5.8.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/compiler-dom': 3.5.18
|
'@vue/compiler-dom': 3.5.18
|
||||||
|
|||||||
Reference in New Issue
Block a user