5 Commits

8 changed files with 150 additions and 55 deletions

View File

@@ -52,7 +52,7 @@ func GetToday(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return return
} }
handleImageResponse(c, img) handleImageResponse(c, img, 7200) // 2小时
} }
// GetTodayMeta 获取今日图片元数据 // GetTodayMeta 获取今日图片元数据
@@ -68,6 +68,7 @@ func GetTodayMeta(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return return
} }
c.Header("Cache-Control", "public, max-age=7200") // 2小时
c.JSON(http.StatusOK, formatMeta(img)) c.JSON(http.StatusOK, formatMeta(img))
} }
@@ -86,7 +87,7 @@ func GetRandom(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return return
} }
handleImageResponse(c, img) handleImageResponse(c, img, 0) // 禁用缓存
} }
// GetRandomMeta 获取随机图片元数据 // GetRandomMeta 获取随机图片元数据
@@ -102,6 +103,7 @@ func GetRandomMeta(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return return
} }
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.JSON(http.StatusOK, formatMeta(img)) c.JSON(http.StatusOK, formatMeta(img))
} }
@@ -122,7 +124,7 @@ func GetByDate(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return return
} }
handleImageResponse(c, img) handleImageResponse(c, img, 604800) // 7天
} }
// GetByDateMeta 获取指定日期图片元数据 // GetByDateMeta 获取指定日期图片元数据
@@ -140,6 +142,7 @@ func GetByDateMeta(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return return
} }
c.Header("Cache-Control", "public, max-age=604800") // 7天
c.JSON(http.StatusOK, formatMeta(img)) c.JSON(http.StatusOK, formatMeta(img))
} }
@@ -203,7 +206,7 @@ func ListImages(c *gin.Context) {
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, result)
} }
func handleImageResponse(c *gin.Context, img *model.Image) { func handleImageResponse(c *gin.Context, img *model.Image, maxAge int) {
variant := c.DefaultQuery("variant", "UHD") variant := c.DefaultQuery("variant", "UHD")
format := c.DefaultQuery("format", "jpg") format := c.DefaultQuery("format", "jpg")
@@ -228,22 +231,30 @@ func handleImageResponse(c *gin.Context, img *model.Image) {
mode := config.GetConfig().API.Mode mode := config.GetConfig().API.Mode
if mode == "redirect" { if mode == "redirect" {
if selected.PublicURL != "" { if selected.PublicURL != "" {
c.Header("Cache-Control", "public, max-age=604800") // 7天 if maxAge > 0 {
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
} else {
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
}
c.Redirect(http.StatusFound, selected.PublicURL) c.Redirect(http.StatusFound, selected.PublicURL)
} else if img.URLBase != "" { } else if img.URLBase != "" {
// 兜底重定向到原始 Bing // 兜底重定向到原始 Bing
bingURL := fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, selected.Variant) bingURL := fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, selected.Variant)
c.Header("Cache-Control", "public, max-age=604800") // 7天 if maxAge > 0 {
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
} else {
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
}
c.Redirect(http.StatusFound, bingURL) c.Redirect(http.StatusFound, bingURL)
} else { } else {
serveLocal(c, selected.StorageKey, img.Date) serveLocal(c, selected.StorageKey, img.Date, maxAge)
} }
} else { } else {
serveLocal(c, selected.StorageKey, img.Date) serveLocal(c, selected.StorageKey, img.Date, maxAge)
} }
} }
func serveLocal(c *gin.Context, key string, etag string) { func serveLocal(c *gin.Context, key string, etag string, maxAge int) {
if etag != "" { if etag != "" {
c.Header("ETag", fmt.Sprintf("\"%s\"", etag)) c.Header("ETag", fmt.Sprintf("\"%s\"", etag))
if c.GetHeader("If-None-Match") == fmt.Sprintf("\"%s\"", etag) { if c.GetHeader("If-None-Match") == fmt.Sprintf("\"%s\"", etag) {
@@ -263,7 +274,12 @@ func serveLocal(c *gin.Context, key string, etag string) {
if contentType != "" { if contentType != "" {
c.Header("Content-Type", contentType) c.Header("Content-Type", contentType)
} }
c.Header("Cache-Control", "public, max-age=604800") // 7天
if maxAge > 0 {
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
} else {
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
}
io.Copy(c.Writer, reader) io.Copy(c.Writer, reader)
} }

View File

@@ -38,7 +38,7 @@ func TestHandleImageResponseRedirect(t *testing.T) {
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
c.Request, _ = http.NewRequest("GET", "/api/v1/image/today?variant=UHD", nil) c.Request, _ = http.NewRequest("GET", "/api/v1/image/today?variant=UHD", nil)
handleImageResponse(c, img) handleImageResponse(c, img, 0)
assert.Equal(t, http.StatusFound, w.Code) assert.Equal(t, http.StatusFound, w.Code)
assert.Contains(t, w.Header().Get("Location"), "bing.com") assert.Contains(t, w.Header().Get("Location"), "bing.com")

View File

@@ -80,7 +80,7 @@ func SetupRouter(webFS embed.FS) *gin.Engine {
path := c.Request.URL.Path path := c.Request.URL.Path
// 如果请求的是 API 或 Swagger则不处理静态资源 (让其返回 404) // 如果请求的是 API 或 Swagger则不处理静态资源 (让其返回 404)
if strings.HasPrefix(path, "/api") || strings.HasPrefix(path, "/swagger") { if strings.HasPrefix(path, "/api/v1") || strings.HasPrefix(path, "/swagger") {
return return
} }

View File

@@ -27,8 +27,8 @@ set PLATFORMS=linux/amd64 linux/arm64 windows/amd64 windows/arm64 darwin/amd64 d
for %%p in (%PLATFORMS%) do ( for %%p in (%PLATFORMS%) do (
for /f "tokens=1,2 delims=/" %%a in ("%%p") do ( for /f "tokens=1,2 delims=/" %%a in ("%%p") do (
set OUTPUT_NAME=%APP_NAME%-%%a-%%b set OUTPUT_NAME=%APP_NAME%-%%a-%%b
set BINARY_NAME=!OUTPUT_NAME! set BINARY_NAME=%APP_NAME%
if "%%a"=="windows" set BINARY_NAME=!OUTPUT_NAME!.exe if "%%a"=="windows" set BINARY_NAME=%APP_NAME%.exe
echo 正在编译 %%a/%%b... echo 正在编译 %%a/%%b...
@@ -47,10 +47,10 @@ for %%p in (%PLATFORMS%) do (
copy /y config.example.yaml !PACKAGE_DIR!\ >nul copy /y config.example.yaml !PACKAGE_DIR!\ >nul
copy /y README.md !PACKAGE_DIR!\ >nul copy /y README.md !PACKAGE_DIR!\ >nul
pushd %OUTPUT_DIR% pushd !PACKAGE_DIR!
tar -czf !OUTPUT_NAME!.tar.gz !OUTPUT_NAME! tar -czf ..\!OUTPUT_NAME!.tar.gz .
rd /s /q !OUTPUT_NAME!
popd popd
rd /s /q !PACKAGE_DIR!
echo %%a/%%b 打包完成: !OUTPUT_NAME!.tar.gz echo %%a/%%b 打包完成: !OUTPUT_NAME!.tar.gz
) else ( ) else (

View File

@@ -38,9 +38,9 @@ foreach ($Platform in $Platforms) {
$Arch = $parts[1] $Arch = $parts[1]
$OutputName = "$AppName-$OS-$Arch" $OutputName = "$AppName-$OS-$Arch"
$BinaryName = $OutputName $BinaryName = $AppName
if ($OS -eq "windows") { if ($OS -eq "windows") {
$BinaryName = "$OutputName.exe" $BinaryName = "$AppName.exe"
} }
Write-Host "正在编译 $OS/$Arch..." Write-Host "正在编译 $OS/$Arch..."
@@ -63,10 +63,10 @@ foreach ($Platform in $Platforms) {
Copy-Item "README.md" $PackageDir\ Copy-Item "README.md" $PackageDir\
$CurrentDir = Get-Location $CurrentDir = Get-Location
Set-Location $OutputDir Set-Location $PackageDir
tar -czf "${OutputName}.tar.gz" $OutputName tar -czf "../${OutputName}.tar.gz" .
Remove-Item -Recurse -Force $OutputName
Set-Location $CurrentDir Set-Location $CurrentDir
Remove-Item -Recurse -Force $PackageDir
Write-Host " $OS/$Arch 打包完成: ${OutputName}.tar.gz" Write-Host " $OS/$Arch 打包完成: ${OutputName}.tar.gz"
} else { } else {

View File

@@ -45,9 +45,9 @@ for PLATFORM in "${PLATFORMS[@]}"; do
# 设置输出名称 # 设置输出名称
OUTPUT_NAME="${APP_NAME}-${OS}-${ARCH}" OUTPUT_NAME="${APP_NAME}-${OS}-${ARCH}"
if [ "$OS" = "windows" ]; then if [ "$OS" = "windows" ]; then
BINARY_NAME="${OUTPUT_NAME}.exe" BINARY_NAME="${APP_NAME}.exe"
else else
BINARY_NAME="${OUTPUT_NAME}" BINARY_NAME="${APP_NAME}"
fi fi
echo "正在编译 ${OS}/${ARCH}..." echo "正在编译 ${OS}/${ARCH}..."
@@ -71,7 +71,7 @@ for PLATFORM in "${PLATFORMS[@]}"; do
done done
# 压缩为 tar.gz # 压缩为 tar.gz
tar -czf "${OUTPUT_DIR}/${OUTPUT_NAME}.tar.gz" -C "${OUTPUT_DIR}" "${OUTPUT_NAME}" tar -czf "${OUTPUT_DIR}/${OUTPUT_NAME}.tar.gz" -C "${PACKAGE_DIR}" .
# 删除临时打包目录 # 删除临时打包目录
rm -rf "$PACKAGE_DIR" rm -rf "$PACKAGE_DIR"

View File

@@ -173,13 +173,18 @@
<span>加载中...</span> <span>加载中...</span>
</div> </div>
<button <div
v-else-if="hasMore" v-else-if="hasMore"
@click="loadMore" ref="loadMoreTrigger"
class="px-8 py-3 bg-white/10 backdrop-blur-md text-white rounded-lg font-semibold hover:bg-white/20 transition-all border border-white/30" class="inline-block"
> >
加载更多 <button
</button> @click="loadMore"
class="px-8 py-3 bg-white/10 backdrop-blur-md text-white rounded-lg font-semibold hover:bg-white/20 transition-all border border-white/30"
>
加载更多
</button>
</div>
<p v-else class="text-white/40"> <p v-else class="text-white/40">
已加载全部图片 已加载全部图片
@@ -246,8 +251,8 @@ const router = useRouter()
// 获取今日图片 // 获取今日图片
const { image: todayImage, loading: todayLoading } = useTodayImage() const { image: todayImage, loading: todayLoading } = useTodayImage()
// 获取图片列表(使用服务端分页和筛选) // 获取图片列表(使用服务端分页和筛选每页15张
const { images, loading, hasMore, loadMore, filterByMonth } = useImageList(30) const { images, loading, hasMore, loadMore, filterByMonth } = useImageList(15)
// 筛选相关状态 // 筛选相关状态
const selectedYear = ref('') const selectedYear = ref('')
@@ -258,6 +263,10 @@ const imageRefs = ref<(HTMLElement | null)[]>([])
const imageVisibility = ref<boolean[]>([]) const imageVisibility = ref<boolean[]>([])
let observer: IntersectionObserver | null = null let observer: IntersectionObserver | null = null
// 无限滚动加载
const loadMoreTrigger = ref<HTMLElement | null>(null)
let loadMoreObserver: IntersectionObserver | null = null
// 计算可用的年份列表基于当前日期生成从2020年到当前年份 // 计算可用的年份列表基于当前日期生成从2020年到当前年份
const availableYears = computed(() => { const availableYears = computed(() => {
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
@@ -360,16 +369,6 @@ const setupObserver = () => {
}) })
} }
// 初始化懒加载状态
onMounted(() => {
// 初始化时设置
if (images.value.length > 0) {
imageVisibility.value = new Array(images.value.length).fill(false)
setTimeout(() => {
setupObserver()
}, 100)
}
})
// 监听 images 变化,动态更新 imageVisibility // 监听 images 变化,动态更新 imageVisibility
watch(() => images.value.length, (newLength, oldLength) => { watch(() => images.value.length, (newLength, oldLength) => {
@@ -399,11 +398,51 @@ watch(() => images.value.length, (newLength, oldLength) => {
} }
}) })
// 设置无限滚动 Observer
const setupLoadMoreObserver = () => {
if (loadMoreObserver) {
loadMoreObserver.disconnect()
}
loadMoreObserver = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !loading.value && hasMore.value) {
loadMore()
}
})
},
{
root: null,
rootMargin: '100px',
threshold: 0.1
}
)
if (loadMoreTrigger.value) {
loadMoreObserver.observe(loadMoreTrigger.value)
}
}
// 初始化时设置无限滚动
onMounted(() => {
if (images.value.length > 0) {
imageVisibility.value = new Array(images.value.length).fill(false)
setTimeout(() => {
setupObserver()
setupLoadMoreObserver()
}, 100)
}
})
// 清理 // 清理
onUnmounted(() => { onUnmounted(() => {
if (observer) { if (observer) {
observer.disconnect() observer.disconnect()
} }
if (loadMoreObserver) {
loadMoreObserver.disconnect()
}
}) })
// 格式化日期 // 格式化日期

View File

@@ -89,7 +89,7 @@
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<button <button
@click="previousDay" @click="previousDay"
:disabled="navigating" :disabled="navigating || !hasPreviousDay"
class="flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-md text-white rounded-lg hover:bg-white/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed" class="flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-md text-white rounded-lg hover:bg-white/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -100,7 +100,7 @@
<button <button
@click="nextDay" @click="nextDay"
:disabled="navigating || isToday" :disabled="navigating || !hasNextDay"
class="flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-md text-white rounded-lg hover:bg-white/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed" class="flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-md text-white rounded-lg hover:bg-white/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
> >
<span class="hidden sm:inline">后一天</span> <span class="hidden sm:inline">后一天</span>
@@ -141,7 +141,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useImageByDate } from '@/composables/useImages' import { useImageByDate } from '@/composables/useImages'
import { bingPaperApi } from '@/lib/api-service' import { bingPaperApi } from '@/lib/api-service'
@@ -153,6 +153,11 @@ const currentDate = ref(route.params.date as string)
const showInfo = ref(true) const showInfo = ref(true)
const navigating = ref(false) const navigating = ref(false)
// 前后日期可用性
const hasPreviousDay = ref(true)
const hasNextDay = ref(true)
const checkingDates = ref(false)
// 拖动相关状态 // 拖动相关状态
const infoPanel = ref<HTMLElement | null>(null) const infoPanel = ref<HTMLElement | null>(null)
const infoPanelPos = ref({ x: 0, y: 0 }) const infoPanelPos = ref({ x: 0, y: 0 })
@@ -230,9 +235,50 @@ const stopDrag = () => {
// 使用 composable 获取图片数据(传递 ref自动响应日期变化 // 使用 composable 获取图片数据(传递 ref自动响应日期变化
const { image, loading, error } = useImageByDate(currentDate) const { image, loading, error } = useImageByDate(currentDate)
// 检测指定日期是否有数据
const checkDateAvailability = async (dateStr: string): Promise<boolean> => {
try {
await bingPaperApi.getImageMetaByDate(dateStr)
return true
} catch (e) {
return false
}
}
// 检测前后日期可用性
const checkAdjacentDates = async () => {
if (checkingDates.value) return
checkingDates.value = true
const date = new Date(currentDate.value)
// 检测前一天
const prevDate = new Date(date)
prevDate.setDate(prevDate.getDate() - 1)
hasPreviousDay.value = await checkDateAvailability(prevDate.toISOString().split('T')[0])
// 检测后一天(不能超过今天)
const nextDate = new Date(date)
nextDate.setDate(nextDate.getDate() + 1)
const today = new Date().toISOString().split('T')[0]
if (nextDate.toISOString().split('T')[0] > today) {
hasNextDay.value = false
} else {
hasNextDay.value = await checkDateAvailability(nextDate.toISOString().split('T')[0])
}
checkingDates.value = false
}
// 初始化位置 // 初始化位置
initPanelPosition() initPanelPosition()
// 监听日期变化,检测前后日期可用性
import { watch } from 'vue'
watch(currentDate, () => {
checkAdjacentDates()
}, { immediate: true })
// 格式化日期 // 格式化日期
const formatDate = (dateStr?: string) => { const formatDate = (dateStr?: string) => {
if (!dateStr) return '' if (!dateStr) return ''
@@ -245,12 +291,6 @@ const formatDate = (dateStr?: string) => {
}) })
} }
// 判断是否是今天
const isToday = computed(() => {
const today = new Date().toISOString().split('T')[0]
return currentDate.value === today
})
// 获取完整图片 URL // 获取完整图片 URL
const getFullImageUrl = () => { const getFullImageUrl = () => {
return bingPaperApi.getImageUrlByDate(currentDate.value, 'UHD', 'jpg') return bingPaperApi.getImageUrlByDate(currentDate.value, 'UHD', 'jpg')
@@ -265,7 +305,7 @@ const goBack = () => {
// 前一天 // 前一天
const previousDay = () => { const previousDay = () => {
if (navigating.value) return if (navigating.value || !hasPreviousDay.value) return
navigating.value = true navigating.value = true
const date = new Date(currentDate.value) const date = new Date(currentDate.value)
@@ -282,7 +322,7 @@ const previousDay = () => {
// 后一天 // 后一天
const nextDay = () => { const nextDay = () => {
if (navigating.value || isToday.value) return if (navigating.value || !hasNextDay.value) return
navigating.value = true navigating.value = true
const date = new Date(currentDate.value) const date = new Date(currentDate.value)
@@ -299,9 +339,9 @@ const nextDay = () => {
// 键盘导航 // 键盘导航
const handleKeydown = (e: KeyboardEvent) => { const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') { if (e.key === 'ArrowLeft' && hasPreviousDay.value) {
previousDay() previousDay()
} else if (e.key === 'ArrowRight' && !isToday.value) { } else if (e.key === 'ArrowRight' && hasNextDay.value) {
nextDay() nextDay()
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
goBack() goBack()