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

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

@@ -2,10 +2,10 @@
<div class="fixed inset-0 z-40">
<div
ref="calendarPanel"
class="fixed bg-gradient-to-br from-black/30 via-black/20 to-black/30 backdrop-blur-xl rounded-3xl p-3 sm:p-4 w-[calc(100%-1rem)] sm:w-full max-w-[95vw] sm:max-w-[420px] shadow-2xl border border-white/10 cursor-move select-none"
class="fixed bg-gradient-to-br from-black/30 via-black/20 to-black/30 backdrop-blur-xl rounded-3xl p-3 sm:p-4 w-[calc(100%-1rem)] sm:w-full max-w-[95vw] sm:max-w-[420px] shadow-2xl border border-white/10 cursor-move select-none touch-none"
:style="{ left: panelPos.x + 'px', top: panelPos.y + 'px' }"
@mousedown="startDrag"
@touchstart="startDrag"
@touchstart.passive="startDrag"
@click.stop
>
<!-- 拖动手柄指示器 -->
@@ -28,7 +28,7 @@
<!-- 年月选择器 -->
<div class="flex items-center justify-center gap-1 sm:gap-1.5 mb-0.5">
<!-- 年份选择 -->
<Select v-model="currentYearString" @update:modelValue="onYearChange">
<Select v-model="currentYearString">
<SelectTrigger
class="w-[90px] sm:w-[105px] h-6 sm:h-7 bg-white/10 text-white border-white/20 hover:bg-white/20 backdrop-blur-md font-bold text-xs sm:text-sm px-1.5 sm:px-2"
@click.stop
@@ -49,7 +49,7 @@
</Select>
<!-- 月份选择 -->
<Select v-model="currentMonthString" @update:modelValue="onMonthChange">
<Select v-model="currentMonthString">
<SelectTrigger
class="w-[65px] sm:w-[75px] h-6 sm:h-7 bg-white/10 text-white border-white/20 hover:bg-white/20 backdrop-blur-md font-bold text-xs sm:text-sm px-1.5 sm:px-2"
@click.stop
@@ -214,7 +214,8 @@ interface CalendarDay {
}
const props = defineProps<{
selectedDate: string // YYYY-MM-DD
selectedDate?: string,
mkt?: string
}>()
const emit = defineEmits<{
@@ -229,10 +230,14 @@ const panelPos = ref({ x: 0, y: 0 })
const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 })
// 响应式窗口大小
const windowSize = ref({ width: window.innerWidth, height: window.innerHeight })
const isMobile = computed(() => windowSize.value.width < 768)
// 计算图片实际显示区域与ImageView保持一致
const getImageDisplayBounds = () => {
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
const windowWidth = windowSize.value.width
const windowHeight = windowSize.value.height
// 必应图片通常是16:9或类似宽高比
const imageAspectRatio = 16 / 9
@@ -271,11 +276,10 @@ const getImageDisplayBounds = () => {
const initPanelPosition = () => {
if (typeof window !== 'undefined') {
const bounds = getImageDisplayBounds()
const isMobile = window.innerWidth < 640 // sm breakpoint
if (isMobile) {
// 移动端:在图片区域内居中显示
const panelWidth = Math.min(bounds.width - 16, window.innerWidth - 16)
if (isMobile.value) {
// 移动端:居中显示,尽量在图片内,但不强求
const panelWidth = Math.min(bounds.width - 16, windowSize.value.width - 16)
const panelHeight = 580 // 估计高度
panelPos.value = {
x: Math.max(bounds.left, bounds.left + (bounds.width - panelWidth) / 2),
@@ -316,20 +320,6 @@ const currentMonthString = computed({
}
})
// 年份改变处理
const onYearChange = (value: any) => {
if (value !== null && value !== undefined) {
currentYear.value = Number(value)
}
}
// 月份改变处理
const onMonthChange = (value: any) => {
if (value !== null && value !== undefined) {
currentMonth.value = Number(value)
}
}
// 生成年份选项从2009年到当前年份+10年
const yearOptions = computed(() => {
const currentYearValue = new Date().getFullYear()
@@ -379,8 +369,20 @@ const loadHolidaysForYear = async (year: number) => {
}
}
// 窗口缩放处理
const handleResize = () => {
windowSize.value = {
width: window.innerWidth,
height: window.innerHeight
}
initPanelPosition()
}
// 组件挂载时加载当前年份的假期数据
onMounted(() => {
if (typeof window !== 'undefined') {
window.addEventListener('resize', handleResize)
}
const currentYearValue = currentYear.value
loadHolidaysForYear(currentYearValue)
// 预加载前后一年的数据
@@ -404,7 +406,9 @@ const startDrag = (e: MouseEvent | TouchEvent) => {
return
}
e.preventDefault()
if (e instanceof MouseEvent) {
e.preventDefault()
}
isDragging.value = true
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
@@ -417,7 +421,7 @@ const startDrag = (e: MouseEvent | TouchEvent) => {
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
document.addEventListener('touchmove', onDrag, { passive: false })
document.addEventListener('touchmove', onDrag, { passive: true })
document.addEventListener('touchend', stopDrag)
}
@@ -425,7 +429,7 @@ const startDrag = (e: MouseEvent | TouchEvent) => {
const onDrag = (e: MouseEvent | TouchEvent) => {
if (!isDragging.value) return
if (e instanceof TouchEvent) {
if (e instanceof MouseEvent) {
e.preventDefault()
}
@@ -435,15 +439,26 @@ const onDrag = (e: MouseEvent | TouchEvent) => {
const newX = clientX - dragStart.value.x
const newY = clientY - dragStart.value.y
// 限制在图片实际显示区域内
// 限制在有效区域内
if (calendarPanel.value) {
const rect = calendarPanel.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
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 {
// 桌面端:限制在图片实际显示区域内
const bounds = getImageDisplayBounds()
minX = bounds.left
maxX = bounds.right - rect.width
minY = bounds.top
maxY = bounds.bottom - rect.height
}
panelPos.value = {
x: Math.max(minX, Math.min(newX, maxX)),
@@ -517,7 +532,7 @@ const createDayObject = (date: Date, isCurrentMonth: boolean): CalendarDay => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const selectedDate = new Date(props.selectedDate)
const selectedDate = new Date(props.selectedDate || new Date())
selectedDate.setHours(0, 0, 0, 0)
// 转换为农历
@@ -626,6 +641,9 @@ const goToToday = () => {
// 清理
import { onUnmounted } from 'vue'
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', handleResize)
}
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)

View File

@@ -2,11 +2,12 @@ import { ref, onMounted, watch } from 'vue'
import type { Ref } from 'vue'
import { bingPaperApi } from '@/lib/api-service'
import type { ImageMeta } from '@/lib/api-types'
import { getDefaultMkt } from '@/lib/mkt-utils'
/**
* 获取今日图片
*/
export function useTodayImage() {
export function useTodayImage(mkt?: string) {
const image = ref<ImageMeta | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
@@ -15,7 +16,7 @@ export function useTodayImage() {
loading.value = true
error.value = null
try {
image.value = await bingPaperApi.getTodayImageMeta()
image.value = await bingPaperApi.getTodayImageMeta(mkt || getDefaultMkt())
} catch (e) {
error.value = e as Error
console.error('Failed to fetch today image:', e)
@@ -46,8 +47,9 @@ export function useImageList(pageSize = 30) {
const hasMore = ref(true)
const currentPage = ref(1)
const currentMonth = ref<string | undefined>(undefined)
const currentMkt = ref<string | undefined>(getDefaultMkt())
const fetchImages = async (page = 1, month?: string) => {
const fetchImages = async (page = 1, month?: string, mkt?: string) => {
if (loading.value) return
loading.value = true
@@ -55,7 +57,8 @@ export function useImageList(pageSize = 30) {
try {
const params: any = {
page,
page_size: pageSize
page_size: pageSize,
mkt: mkt || currentMkt.value || getDefaultMkt()
}
if (month) {
params.month = month
@@ -84,7 +87,7 @@ export function useImageList(pageSize = 30) {
const loadMore = () => {
if (!loading.value && hasMore.value) {
fetchImages(currentPage.value + 1, currentMonth.value)
fetchImages(currentPage.value + 1, currentMonth.value, currentMkt.value)
}
}
@@ -92,7 +95,14 @@ export function useImageList(pageSize = 30) {
currentMonth.value = month
currentPage.value = 1
hasMore.value = true
fetchImages(1, month)
fetchImages(1, month, currentMkt.value)
}
const filterByMkt = (mkt?: string) => {
currentMkt.value = mkt
currentPage.value = 1
hasMore.value = true
fetchImages(1, currentMonth.value, mkt)
}
onMounted(() => {
@@ -106,10 +116,11 @@ export function useImageList(pageSize = 30) {
hasMore,
loadMore,
filterByMonth,
filterByMkt,
refetch: () => {
currentPage.value = 1
hasMore.value = true
fetchImages(1, currentMonth.value)
fetchImages(1, currentMonth.value, currentMkt.value)
}
}
}
@@ -117,7 +128,7 @@ export function useImageList(pageSize = 30) {
/**
* 获取指定日期的图片
*/
export function useImageByDate(dateRef: Ref<string>) {
export function useImageByDate(dateRef: Ref<string>, mktRef?: Ref<string | undefined>) {
const image = ref<ImageMeta | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
@@ -126,7 +137,7 @@ export function useImageByDate(dateRef: Ref<string>) {
loading.value = true
error.value = null
try {
image.value = await bingPaperApi.getImageMetaByDate(dateRef.value)
image.value = await bingPaperApi.getImageMetaByDate(dateRef.value, mktRef?.value || getDefaultMkt())
} catch (e) {
error.value = e as Error
console.error(`Failed to fetch image for date ${dateRef.value}:`, e)
@@ -135,10 +146,16 @@ export function useImageByDate(dateRef: Ref<string>) {
}
}
// 监听日期变化,自动重新获取
watch(dateRef, () => {
fetchImage()
}, { immediate: true })
// 监听日期和地区变化,自动重新获取
if (mktRef) {
watch([dateRef, mktRef], () => {
fetchImage()
}, { immediate: true })
} else {
watch(dateRef, () => {
fetchImage()
}, { immediate: true })
}
return {
image,

View File

@@ -7,6 +7,7 @@ import type {
UpdateTokenRequest,
ChangePasswordRequest,
Config,
Region,
ImageMeta,
ImageListParams,
ManualFetchRequest,
@@ -109,6 +110,7 @@ export class BingPaperApiService {
if (params?.page) searchParams.set('page', params.page.toString())
if (params?.page_size) searchParams.set('page_size', params.page_size.toString())
if (params?.month) searchParams.set('month', params.month)
if (params?.mkt) searchParams.set('mkt', params.mkt)
const queryString = searchParams.toString()
const endpoint = queryString ? `/images?${queryString}` : '/images'
@@ -116,48 +118,61 @@ export class BingPaperApiService {
return apiClient.get<ImageMeta[]>(endpoint)
}
/**
* 获取支持的地区列表
*/
async getRegions(): Promise<Region[]> {
return apiClient.get<Region[]>('/regions')
}
/**
* 获取今日图片元数据
*/
async getTodayImageMeta(): Promise<ImageMeta> {
return apiClient.get<ImageMeta>('/image/today/meta')
async getTodayImageMeta(mkt?: string): Promise<ImageMeta> {
const endpoint = mkt ? `/image/today/meta?mkt=${mkt}` : '/image/today/meta'
return apiClient.get<ImageMeta>(endpoint)
}
/**
* 获取指定日期图片元数据
*/
async getImageMetaByDate(date: string): Promise<ImageMeta> {
return apiClient.get<ImageMeta>(`/image/date/${date}/meta`)
async getImageMetaByDate(date: string, mkt?: string): Promise<ImageMeta> {
const endpoint = mkt ? `/image/date/${date}/meta?mkt=${mkt}` : `/image/date/${date}/meta`
return apiClient.get<ImageMeta>(endpoint)
}
/**
* 获取随机图片元数据
*/
async getRandomImageMeta(): Promise<ImageMeta> {
return apiClient.get<ImageMeta>('/image/random/meta')
async getRandomImageMeta(mkt?: string): Promise<ImageMeta> {
const endpoint = mkt ? `/image/random/meta?mkt=${mkt}` : '/image/random/meta'
return apiClient.get<ImageMeta>(endpoint)
}
/**
* 构建图片 URL
*/
getTodayImageUrl(variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg'): string {
getTodayImageUrl(variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg', mkt?: string): string {
const params = new URLSearchParams({ variant, format })
if (mkt) params.set('mkt', mkt)
return `${apiConfig.baseURL}/image/today?${params.toString()}`
}
/**
* 构建指定日期图片 URL
*/
getImageUrlByDate(date: string, variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg'): string {
getImageUrlByDate(date: string, variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg', mkt?: string): string {
const params = new URLSearchParams({ variant, format })
if (mkt) params.set('mkt', mkt)
return `${apiConfig.baseURL}/image/date/${date}?${params.toString()}`
}
/**
* 构建随机图片 URL
*/
getRandomImageUrl(variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg'): string {
getRandomImageUrl(variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg', mkt?: string): string {
const params = new URLSearchParams({ variant, format })
if (mkt) params.set('mkt', mkt)
return `${apiConfig.baseURL}/image/random?${params.toString()}`
}
@@ -197,6 +212,7 @@ export const {
manualFetch,
manualCleanup,
getImages,
getRegions,
getTodayImageMeta,
getImageMetaByDate,
getRandomImageMeta,

View File

@@ -61,6 +61,11 @@ export interface Config {
Token: TokenConfig
Feature: FeatureConfig
Web: WebConfig
Fetcher: FetcherConfig
}
export interface FetcherConfig {
Regions: string[]
}
export interface AdminConfig {
@@ -69,6 +74,7 @@ export interface AdminConfig {
export interface APIConfig {
Mode: string // 'local' | 'redirect'
EnableMktFallback: boolean
}
export interface CronConfig {
@@ -147,6 +153,7 @@ export interface WebConfig {
export interface ImageMeta {
date?: string
mkt?: string
title?: string
copyright?: string
copyrightlink?: string // 图片的详细版权链接(指向 Bing 搜索页面)
@@ -173,6 +180,12 @@ export interface ImageListParams extends PaginationParams {
page?: number // 页码从1开始
page_size?: number // 每页数量
month?: string // 按月份过滤格式YYYY-MM
mkt?: string // 地区编码
}
export interface Region {
value: string
label: string
}
export interface ManualFetchRequest {

View File

@@ -0,0 +1,75 @@
const MKT_STORAGE_KEY = 'bing_paper_selected_mkt'
const DEFAULT_MKT = 'zh-CN'
/**
* 默认地区列表 (兜底用)
*/
export const DEFAULT_REGIONS = [
{ value: 'zh-CN', label: '中国 (zh-CN)' },
{ value: 'en-US', label: '美国 (en-US)' },
{ value: 'ja-JP', label: '日本 (ja-JP)' },
{ value: 'en-AU', label: '澳大利亚 (en-AU)' },
{ value: 'en-GB', label: '英国 (en-GB)' },
{ value: 'de-DE', label: '德国 (de-DE)' },
{ value: 'en-NZ', label: '新西兰 (en-NZ)' },
{ value: 'en-CA', label: '加拿大 (en-CA)' },
{ value: 'fr-FR', label: '法国 (fr-FR)' },
{ value: 'it-IT', label: '意大利 (it-IT)' },
{ value: 'es-ES', label: '西班牙 (es-ES)' },
{ value: 'pt-BR', label: '巴西 (pt-BR)' },
{ value: 'ko-KR', label: '韩国 (ko-KR)' },
{ value: 'en-IN', label: '印度 (en-IN)' },
{ value: 'ru-RU', label: '俄罗斯 (ru-RU)' },
{ value: 'zh-HK', label: '中国香港 (zh-HK)' },
{ value: 'zh-TW', label: '中国台湾 (zh-TW)' },
]
/**
* 支持的地区列表 (优先使用后端提供的)
*/
export let SUPPORTED_REGIONS = [...DEFAULT_REGIONS]
/**
* 更新支持的地区列表
*/
export function setSupportedRegions(regions: typeof DEFAULT_REGIONS): void {
SUPPORTED_REGIONS = regions
}
/**
* 获取浏览器首选地区
*/
export function getBrowserMkt(): string {
const lang = navigator.language || (navigator as any).userLanguage
if (!lang) return DEFAULT_MKT
// 尝试精确匹配
const exactMatch = SUPPORTED_REGIONS.find(r => r.value.toLowerCase() === lang.toLowerCase())
if (exactMatch) return exactMatch.value
// 尝试模糊匹配 (前两个字符,如 en-GB 匹配 en-US)
const prefix = lang.split('-')[0].toLowerCase()
const prefixMatch = SUPPORTED_REGIONS.find(r => r.value.split('-')[0].toLowerCase() === prefix)
if (prefixMatch) return prefixMatch.value
return DEFAULT_MKT
}
/**
* 获取当前选择的地区 (优先从 localStorage 获取,其次从浏览器获取)
*/
export function getDefaultMkt(): string {
const saved = localStorage.getItem(MKT_STORAGE_KEY)
if (saved && SUPPORTED_REGIONS.some(r => r.value === saved)) {
return saved
}
return getBrowserMkt()
}
/**
* 保存选择的地区
*/
export function setSavedMkt(mkt: string): void {
localStorage.setItem(MKT_STORAGE_KEY, mkt)
}

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)