增加多地区每日图片抓取能力

This commit is contained in:
2026-01-30 13:33:40 +08:00
parent b69db53f0a
commit 93690e10d3
24 changed files with 980 additions and 149 deletions

View File

@@ -102,6 +102,16 @@
local: 直接返回图片流; redirect: 重定向到存储位置
</p>
</div>
<div class="flex items-center gap-2">
<Label for="api-fallback">启用地区不存在时兜底</Label>
<Switch
id="api-fallback"
v-model="config.API.EnableMktFallback"
/>
</div>
<p class="text-xs text-gray-500">
如果请求的地区无数据自动回退到默认地区
</p>
</CardContent>
</Card>
@@ -372,6 +382,31 @@
</CardContent>
</Card>
<!-- 抓取配置 -->
<Card>
<CardHeader>
<CardTitle>抓取配置</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label>抓取地区</Label>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-2">
<div v-for="region in allRegions" :key="region.value" class="flex items-center space-x-2">
<Checkbox
:id="'region-'+region.value"
:checked="config.Fetcher.Regions.includes(region.value)"
@update:checked="(checked: any) => toggleRegion(region.value, !!checked)"
/>
<Label :for="'region-'+region.value" class="text-sm font-normal cursor-pointer">{{ region.label }}</Label>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">
勾选需要定期抓取壁纸的地区如果不勾选任何地区默认将只抓取 zh-CN
</p>
</div>
</CardContent>
</Card>
<!-- 功能特性配置 -->
<Card>
<CardHeader>
@@ -414,6 +449,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { apiService } from '@/lib/api-service'
import type { Config } from '@/lib/api-types'
@@ -424,9 +460,12 @@ const loadError = ref('')
const saveLoading = ref(false)
const dsnError = ref('')
// 所有可选地区列表
const allRegions = ref<any[]>([])
const config = ref<Config>({
Admin: { PasswordBcrypt: '' },
API: { Mode: 'local' },
API: { Mode: 'local', EnableMktFallback: true },
Cron: { Enabled: true, DailySpec: '0 9 * * *' },
DB: { Type: 'sqlite', DSN: '' },
Feature: { WriteDailyFiles: true },
@@ -464,12 +503,37 @@ const config = ref<Config>({
}
},
Token: { DefaultTTL: '168h' },
Web: { Path: './webapp/dist' }
Web: { Path: './webapp/dist' },
Fetcher: { Regions: [] }
})
const configJson = ref('')
const jsonError = ref('')
// 获取所有地区
const fetchRegions = async () => {
try {
const data = await apiService.getRegions()
allRegions.value = data
} catch (err) {
console.error('获取地区列表失败:', err)
}
}
const toggleRegion = (regionValue: string, checked: boolean) => {
if (!config.value.Fetcher.Regions) {
config.value.Fetcher.Regions = []
}
if (checked) {
if (!config.value.Fetcher.Regions.includes(regionValue)) {
config.value.Fetcher.Regions.push(regionValue)
}
} else {
config.value.Fetcher.Regions = config.value.Fetcher.Regions.filter(r => r !== regionValue)
}
}
// DSN 示例
const dsnExamples = computed(() => {
switch (config.value.DB.Type) {
@@ -602,6 +666,7 @@ const handleSaveConfig = async () => {
}
onMounted(() => {
fetchRegions()
fetchConfig()
})
</script>

View File

@@ -103,6 +103,10 @@
<code class="text-yellow-400 min-w-24">format</code>
<span class="text-white/50">格式: jpg (默认: jpg)</span>
</div>
<div class="flex gap-4 text-sm">
<code class="text-yellow-400 min-w-24">mkt</code>
<span class="text-white/50">地区编码 (如 zh-CN, en-US, ja-JP),默认由服务器自动探测</span>
</div>
</div>
</div>
@@ -182,6 +186,10 @@
<code class="text-yellow-400 min-w-24">format</code>
<span class="text-white/50">格式 (默认: jpg)</span>
</div>
<div class="flex gap-4 text-sm">
<code class="text-yellow-400 min-w-24">mkt</code>
<span class="text-white/50">地区编码 (如 zh-CN, en-US, ja-JP)</span>
</div>
</div>
</div>
@@ -254,6 +262,10 @@
<code class="text-yellow-400 min-w-24">format</code>
<span class="text-white/50">格式 (默认: jpg)</span>
</div>
<div class="flex gap-4 text-sm">
<code class="text-yellow-400 min-w-24">mkt</code>
<span class="text-white/50">地区编码 (如 zh-CN, en-US, ja-JP)</span>
</div>
</div>
</div>
@@ -334,6 +346,10 @@
<code class="text-yellow-400 min-w-32">date</code>
<span class="text-white/60">图片日期格式YYYY-MM-DD</span>
</div>
<div class="flex gap-4">
<code class="text-yellow-400 min-w-32">mkt</code>
<span class="text-white/60">地区编码(如 zh-CN, en-US</span>
</div>
<div class="flex gap-4">
<code class="text-yellow-400 min-w-32">title</code>
<span class="text-white/60">图片标题</span>
@@ -458,24 +474,26 @@
<script setup lang="ts">
import { ref } from 'vue'
import { API_BASE_URL } from '@/lib/api-config'
import { getDefaultMkt } from '@/lib/mkt-utils'
const baseURL = ref(API_BASE_URL)
const previewImage = ref<string | null>(null)
const defaultMkt = getDefaultMkt()
// 获取今日图片示例
const getTodayImageExample = () => {
return `${baseURL.value}/image/today?variant=UHD&format=jpg`
return `${baseURL.value}/image/today?variant=UHD&format=jpg&mkt=${defaultMkt}`
}
// 获取指定日期图片示例
const getDateImageExample = () => {
const today = new Date().toISOString().split('T')[0]
return `${baseURL.value}/image/date/${today}?variant=1920x1080&format=jpg`
return `${baseURL.value}/image/date/${today}?variant=1920x1080&format=jpg&mkt=${defaultMkt}`
}
// 获取随机图片示例
const getRandomImageExample = () => {
return `${baseURL.value}/image/random?variant=UHD&format=jpg`
return `${baseURL.value}/image/random?variant=UHD&format=jpg&mkt=${defaultMkt}`
}
// 复制到剪贴板

View File

@@ -79,6 +79,23 @@
<!-- 筛选器 -->
<div class="flex flex-wrap items-center gap-3">
<!-- 地区选择 -->
<Select v-model="selectedMkt" @update:model-value="onMktChange">
<SelectTrigger class="w-[180px] bg-white/10 backdrop-blur-md text-white border-white/20 hover:bg-white/15 hover:border-white/30 focus:ring-white/50 shadow-lg">
<SelectValue placeholder="选择地区" />
</SelectTrigger>
<SelectContent class="bg-gray-900/95 backdrop-blur-xl border-white/20 text-white">
<SelectItem
v-for="region in regions"
:key="region.value"
:value="region.value"
class="focus:bg-white/10 focus:text-white cursor-pointer"
>
{{ region.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- 年份选择 -->
<Select v-model="selectedYear" @update:model-value="onYearChange">
<SelectTrigger class="w-[180px] bg-white/10 backdrop-blur-md text-white border-white/20 hover:bg-white/15 hover:border-white/30 focus:ring-white/50 shadow-lg">
@@ -153,7 +170,7 @@
</div>
<img
v-else
:src="getImageUrl(image.date!)"
:src="getImageUrl(image)"
:alt="image.title || 'Bing Image'"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
loading="lazy"
@@ -248,6 +265,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useImageList } from '@/composables/useImages'
import { bingPaperApi } from '@/lib/api-service'
import { useRouter } from 'vue-router'
import { getDefaultMkt, setSavedMkt, SUPPORTED_REGIONS, setSupportedRegions } from '@/lib/mkt-utils'
import {
Select,
SelectContent,
@@ -258,18 +276,21 @@ import {
const router = useRouter()
// 地区列表
const regions = ref(SUPPORTED_REGIONS)
// 顶部最新图片(独立加载,不受筛选影响)
const latestImage = ref<any>(null)
const todayLoading = ref(false)
// 历史图片列表使用服务端分页和筛选每页15张
const { images, loading, hasMore, loadMore, filterByMonth } = useImageList(15)
const { images, loading, hasMore, loadMore, filterByMonth, filterByMkt } = useImageList(15)
// 加载顶部最新图片
const loadLatestImage = async () => {
todayLoading.value = true
try {
const params = { page: 1, page_size: 1 }
const params: any = { page: 1, page_size: 1, mkt: selectedMkt.value }
const result = await bingPaperApi.getImages(params)
if (result.length > 0) {
latestImage.value = result[0]
@@ -281,8 +302,17 @@ const loadLatestImage = async () => {
}
}
// 初始化加载顶部图片
onMounted(() => {
// 初始化加载
onMounted(async () => {
try {
const backendRegions = await bingPaperApi.getRegions()
if (backendRegions && backendRegions.length > 0) {
regions.value = backendRegions
setSupportedRegions(backendRegions)
}
} catch (error) {
console.error('Failed to fetch regions:', error)
}
loadLatestImage()
})
@@ -315,9 +345,22 @@ const nextUpdateTime = computed(() => {
})
// 筛选相关状态
const selectedMkt = ref(getDefaultMkt())
const selectedYear = ref('')
const selectedMonth = ref('')
const onMktChange = () => {
setSavedMkt(selectedMkt.value)
filterByMkt(selectedMkt.value)
loadLatestImage()
// 重置懒加载状态
imageVisibility.value = []
setTimeout(() => {
setupObserver()
}, 100)
}
// 懒加载相关
const imageRefs = ref<(HTMLElement | null)[]>([])
const imageVisibility = ref<boolean[]>([])
@@ -374,11 +417,14 @@ const onFilterChange = () => {
// 重置筛选
const resetFilters = () => {
selectedMkt.value = getDefaultMkt()
selectedYear.value = ''
selectedMonth.value = ''
// 重置为加载默认数据
filterByMkt(selectedMkt.value)
filterByMonth(undefined)
loadLatestImage()
// 重置懒加载状态
imageVisibility.value = []
@@ -521,12 +567,12 @@ const formatDate = (dateStr?: string) => {
// 获取最新图片 URL顶部大图使用UHD高清
const getLatestImageUrl = () => {
if (!latestImage.value?.date) return ''
return bingPaperApi.getImageUrlByDate(latestImage.value.date, 'UHD', 'jpg')
return bingPaperApi.getImageUrlByDate(latestImage.value.date, 'UHD', 'jpg', latestImage.value.mkt)
}
// 获取图片 URL缩略图 - 使用较小分辨率节省流量)
const getImageUrl = (date: string) => {
return bingPaperApi.getImageUrlByDate(date, '640x480', 'jpg')
const getImageUrl = (image: any) => {
return bingPaperApi.getImageUrlByDate(image.date!, '640x480', 'jpg', image.mkt)
}
// 查看图片详情

View File

@@ -58,7 +58,7 @@
<!-- 拖动手柄 -->
<div
@mousedown="startDrag"
@touchstart="startDrag"
@touchstart.passive="startDrag"
class="absolute top-2 left-1/2 -translate-x-1/2 w-12 h-1 bg-white/30 rounded-full cursor-move hover:bg-white/50 transition-colors touch-none"
></div>
@@ -178,10 +178,11 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useImageByDate } from '@/composables/useImages'
import { bingPaperApi } from '@/lib/api-service'
import { getDefaultMkt } from '@/lib/mkt-utils'
import Calendar from '@/components/ui/calendar/Calendar.vue'
const route = useRoute()
@@ -202,12 +203,17 @@ const getInitialCalendarState = (): boolean => {
}
const currentDate = ref(route.params.date as string)
const currentMkt = ref(route.query.mkt as string || getDefaultMkt())
const showInfo = ref(true)
const showCalendar = ref(getInitialCalendarState())
const navigating = ref(false)
const imageOpacity = ref(1)
const imageTransitioning = ref(false)
// 响应式窗口大小
const windowSize = ref({ width: window.innerWidth, height: window.innerHeight })
const isMobile = computed(() => windowSize.value.width < 768)
// 前后日期可用性
const hasPreviousDay = ref(true)
const hasNextDay = ref(true)
@@ -222,11 +228,10 @@ let animationFrameId: number | null = null
// 计算图片实际显示区域考虑图片宽高比和object-contain
const getImageDisplayBounds = () => {
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
const windowWidth = windowSize.value.width
const windowHeight = windowSize.value.height
// 必应图片通常是16:9或类似宽高比
// 使用UHD分辨率: 1920x1080 (16:9)
// 必应图片通常是16:9
const imageAspectRatio = 16 / 9
const windowAspectRatio = windowWidth / windowHeight
@@ -259,22 +264,35 @@ const getImageDisplayBounds = () => {
}
}
// 初始化浮窗位置(居中偏下,限制在图片显示区域内)
// 初始化浮窗位置(限制在图片显示区域内,移动端默认展示在底部
const initPanelPosition = () => {
if (typeof window !== 'undefined') {
const bounds = getImageDisplayBounds()
const panelWidth = Math.min(bounds.width * 0.9, 448) // max-w-md = 448px
infoPanelPos.value = {
x: bounds.left + (bounds.width - panelWidth) / 2,
y: Math.max(bounds.top, bounds.bottom - 280) // 距底部280px避免与控制栏重叠
if (isMobile.value) {
// 移动端:默认居中靠下,不严格限制在图片内(因为要求可以不限制)
// 但为了好看,我们还是给它一个默认位置
const panelWidth = windowSize.value.width * 0.9
infoPanelPos.value = {
x: (windowSize.value.width - panelWidth) / 2,
y: windowSize.value.height - 240 // 靠下
}
} else {
// 桌面端:限制在图片区域内
const panelWidth = Math.min(bounds.width * 0.9, 448) // max-w-md = 448px
infoPanelPos.value = {
x: bounds.left + (bounds.width - panelWidth) / 2,
y: Math.max(bounds.top, bounds.bottom - 280) // 距底部280px避免与控制栏重叠
}
}
}
}
// 开始拖动
const startDrag = (e: MouseEvent | TouchEvent) => {
e.preventDefault()
if (e instanceof MouseEvent) {
e.preventDefault()
}
isDragging.value = true
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
@@ -287,7 +305,7 @@ const startDrag = (e: MouseEvent | TouchEvent) => {
document.addEventListener('mousemove', onDrag, { passive: false })
document.addEventListener('mouseup', stopDrag)
document.addEventListener('touchmove', onDrag, { passive: false })
document.addEventListener('touchmove', onDrag, { passive: true })
document.addEventListener('touchend', stopDrag)
}
@@ -295,7 +313,9 @@ const startDrag = (e: MouseEvent | TouchEvent) => {
const onDrag = (e: MouseEvent | TouchEvent) => {
if (!isDragging.value) return
e.preventDefault()
if (e instanceof MouseEvent) {
e.preventDefault()
}
// 取消之前的动画帧
if (animationFrameId !== null) {
@@ -310,15 +330,26 @@ const onDrag = (e: MouseEvent | TouchEvent) => {
const newX = clientX - dragStart.value.x
const newY = clientY - dragStart.value.y
// 限制在图片实际显示区域内考虑底部控制栏高度约80px
// 限制在有效区域内
if (infoPanel.value) {
const rect = infoPanel.value.getBoundingClientRect()
const bounds = getImageDisplayBounds()
const minX = bounds.left
const maxX = bounds.right - rect.width
const minY = bounds.top
const maxY = bounds.bottom - rect.height - 80 // 预留底部控制栏空间
let minX, maxX, minY, maxY
if (isMobile.value) {
// 移动端:不限制区域,限制在视口内即可
minX = 0
maxX = windowSize.value.width - rect.width
minY = 0
maxY = windowSize.value.height - rect.height
} else {
// 桌面端限制在图片实际显示区域内考虑底部控制栏高度约80px
const bounds = getImageDisplayBounds()
minX = bounds.left
maxX = bounds.right - rect.width
minY = bounds.top
maxY = bounds.bottom - rect.height - 80 // 预留底部控制栏空间
}
infoPanelPos.value = {
x: Math.max(minX, Math.min(newX, maxX)),
@@ -347,12 +378,12 @@ const stopDrag = () => {
}
// 使用 composable 获取图片数据(传递 ref自动响应日期变化
const { image, loading, error } = useImageByDate(currentDate)
const { image, loading, error } = useImageByDate(currentDate, currentMkt)
// 检测指定日期是否有数据
const checkDateAvailability = async (dateStr: string): Promise<boolean> => {
try {
await bingPaperApi.getImageMetaByDate(dateStr)
await bingPaperApi.getImageMetaByDate(dateStr, currentMkt.value)
return true
} catch (e) {
return false
@@ -384,9 +415,6 @@ const checkAdjacentDates = async () => {
checkingDates.value = false
}
// 初始化位置
initPanelPosition()
// 监听showCalendar变化并自动保存到localStorage
watch(showCalendar, (newValue) => {
try {
@@ -401,6 +429,20 @@ watch(currentDate, () => {
checkAdjacentDates()
}, { immediate: true })
// 监听路由变化,支持前进后退
watch(() => route.params.date, (newDate) => {
if (newDate && newDate !== currentDate.value) {
currentDate.value = newDate as string
}
})
watch(() => route.query.mkt, (newMkt) => {
const mkt = (newMkt as string) || getDefaultMkt()
if (mkt !== currentMkt.value) {
currentMkt.value = mkt
}
})
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return ''
@@ -415,7 +457,7 @@ const formatDate = (dateStr?: string) => {
// 获取完整图片 URL
const getFullImageUrl = () => {
return bingPaperApi.getImageUrlByDate(currentDate.value, 'UHD', 'jpg')
return bingPaperApi.getImageUrlByDate(currentDate.value, 'UHD', 'jpg', currentMkt.value)
}
// 预加载图片
@@ -432,10 +474,10 @@ const preloadImage = (url: string): Promise<void> => {
const preloadImageAndData = async (date: string): Promise<void> => {
try {
// 并行预加载图片和数据
const imageUrl = bingPaperApi.getImageUrlByDate(date, 'UHD', 'jpg')
const imageUrl = bingPaperApi.getImageUrlByDate(date, 'UHD', 'jpg', currentMkt.value)
await Promise.all([
preloadImage(imageUrl),
bingPaperApi.getImageMetaByDate(date)
bingPaperApi.getImageMetaByDate(date, currentMkt.value)
])
} catch (error) {
console.warn('Failed to preload image or data:', error)
@@ -461,7 +503,7 @@ const switchToDate = async (newDate: string) => {
// 3. 更新日期(此时图片和数据已经预加载完成)
currentDate.value = newDate
router.replace(`/image/${newDate}`)
router.replace(`/image/${newDate}?mkt=${currentMkt.value}`)
// 4. 等待一个微任务,确保 DOM 更新
await new Promise(resolve => setTimeout(resolve, 50))
@@ -534,18 +576,29 @@ const handleKeydown = (e: KeyboardEvent) => {
}
}
// 添加键盘事件监听
if (typeof window !== 'undefined') {
window.addEventListener('keydown', handleKeydown)
window.addEventListener('resize', initPanelPosition)
// 窗口缩放处理
const handleResize = () => {
windowSize.value = {
width: window.innerWidth,
height: window.innerHeight
}
initPanelPosition()
}
// 清理
import { onUnmounted } from 'vue'
// 生命周期钩子
onMounted(() => {
if (typeof window !== 'undefined') {
window.addEventListener('keydown', handleKeydown)
window.addEventListener('resize', handleResize)
}
// 初始化浮窗位置
initPanelPosition()
})
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', handleKeydown)
window.removeEventListener('resize', initPanelPosition)
window.removeEventListener('resize', handleResize)
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)