Add map timeline controls
This commit is contained in:
+359
-32
@@ -1,12 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||||
import QuickUploadModal from '@/components/cloud/QuickUploadModal.vue'
|
import QuickUploadModal from '@/components/cloud/QuickUploadModal.vue'
|
||||||
import { supabase } from '@/lib/supabase'
|
import { supabase } from '@/lib/supabase'
|
||||||
import { loadAMap } from '@/lib/amap'
|
import { loadAMap } from '@/lib/amap'
|
||||||
import { NIcon } from 'naive-ui'
|
import { NIcon } from 'naive-ui'
|
||||||
import { CloudUpload, Refresh, Map, Satellite } from '@vicons/tabler'
|
import { Adjustments, Calendar, CloudUpload, Refresh, Map, Satellite, X } from '@vicons/tabler'
|
||||||
|
|
||||||
interface CloudMarkerData {
|
interface CloudMarkerData {
|
||||||
id: string
|
id: string
|
||||||
@@ -28,6 +28,15 @@ const previewCloud = ref<CloudMarkerData | null>(null)
|
|||||||
const satelliteOn = ref(false)
|
const satelliteOn = ref(false)
|
||||||
const statusText = ref('加载中...')
|
const statusText = ref('加载中...')
|
||||||
const quickUploadOpen = ref(false)
|
const quickUploadOpen = ref(false)
|
||||||
|
const mapMode = ref<'realtime' | 'archive'>('realtime')
|
||||||
|
const timelineControlsOpen = ref(false)
|
||||||
|
const archivePanelOpen = ref(false)
|
||||||
|
const archiveKind = ref<'day' | 'month'>('day')
|
||||||
|
const archiveDay = ref(formatDateInput(new Date()))
|
||||||
|
const archiveMonth = ref(formatMonthInput(new Date()))
|
||||||
|
const currentMinuteOfDay = ref(getMinuteOfDay(new Date()))
|
||||||
|
const selectedMinuteOfDay = ref(currentMinuteOfDay.value)
|
||||||
|
const sliderDragging = ref(false)
|
||||||
|
|
||||||
const VISIBLE_WINDOW_MS = 2 * 60 * 60 * 1000
|
const VISIBLE_WINDOW_MS = 2 * 60 * 60 * 1000
|
||||||
const MIN_MARKER_OPACITY = 0.3
|
const MIN_MARKER_OPACITY = 0.3
|
||||||
@@ -54,6 +63,61 @@ const rarityMeta = {
|
|||||||
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
|
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
const selectedTimeLabel = computed(() => formatMinuteOfDay(selectedMinuteOfDay.value))
|
||||||
|
const currentMinutePercent = computed(() => Math.min(100, Math.max(0, currentMinuteOfDay.value / 1439 * 100)))
|
||||||
|
const selectedMinutePercent = computed(() => Math.min(100, Math.max(0, selectedMinuteOfDay.value / 1439 * 100)))
|
||||||
|
const archiveTitle = computed(() => {
|
||||||
|
if (mapMode.value === 'realtime') return '实时'
|
||||||
|
return archiveKind.value === 'day' ? archiveDay.value : archiveMonth.value
|
||||||
|
})
|
||||||
|
|
||||||
|
function getMinuteOfDay(date: Date) {
|
||||||
|
return date.getHours() * 60 + date.getMinutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMinuteOfDay(minute: number) {
|
||||||
|
const hours = Math.floor(minute / 60)
|
||||||
|
const minutes = minute % 60
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateInput(date: Date) {
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonthInput(date: Date) {
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${month}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalDateRange(dateValue: string) {
|
||||||
|
const [year, month, day] = dateValue.split('-').map(Number)
|
||||||
|
const start = new Date(year, month - 1, day, 0, 0, 0, 0)
|
||||||
|
const end = new Date(year, month - 1, day + 1, 0, 0, 0, 0)
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalMonthRange(monthValue: string) {
|
||||||
|
const [year, month] = monthValue.split('-').map(Number)
|
||||||
|
const start = new Date(year, month - 1, 1, 0, 0, 0, 0)
|
||||||
|
const end = new Date(year, month, 1, 0, 0, 0, 0)
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedRealtimeDate() {
|
||||||
|
const { start } = getLocalDateRange(formatDateInput(new Date()))
|
||||||
|
return new Date(start.getTime() + selectedMinuteOfDay.value * 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCurrentMinute() {
|
||||||
|
currentMinuteOfDay.value = getMinuteOfDay(new Date())
|
||||||
|
if (selectedMinuteOfDay.value > currentMinuteOfDay.value) {
|
||||||
|
selectedMinuteOfDay.value = currentMinuteOfDay.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function timeAgo(iso: string): string {
|
function timeAgo(iso: string): string {
|
||||||
const diff = Date.now() - new Date(iso).getTime()
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
const m = Math.floor(diff / 60000)
|
const m = Math.floor(diff / 60000)
|
||||||
@@ -85,13 +149,15 @@ function getCloudAgeMs(cloud: CloudMarkerData) {
|
|||||||
const capturedTime = new Date(cloud.capturedAt).getTime()
|
const capturedTime = new Date(cloud.capturedAt).getTime()
|
||||||
const fallbackTime = new Date(cloud.createdAt).getTime()
|
const fallbackTime = new Date(cloud.createdAt).getTime()
|
||||||
const targetTime = Number.isNaN(capturedTime) ? fallbackTime : capturedTime
|
const targetTime = Number.isNaN(capturedTime) ? fallbackTime : capturedTime
|
||||||
return Date.now() - targetTime
|
return getSelectedRealtimeDate().getTime() - targetTime
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMarkerOpacity(cloud: CloudMarkerData) {
|
function getMarkerOpacity(cloud: CloudMarkerData) {
|
||||||
|
if (mapMode.value === 'archive') return 1
|
||||||
|
|
||||||
const ageMs = getCloudAgeMs(cloud)
|
const ageMs = getCloudAgeMs(cloud)
|
||||||
|
if (ageMs < 0) return null
|
||||||
if (ageMs >= VISIBLE_WINDOW_MS) return null
|
if (ageMs >= VISIBLE_WINDOW_MS) return null
|
||||||
if (ageMs <= 0) return 1
|
|
||||||
|
|
||||||
const progress = ageMs / VISIBLE_WINDOW_MS
|
const progress = ageMs / VISIBLE_WINDOW_MS
|
||||||
return 1 - progress * (1 - MIN_MARKER_OPACITY)
|
return 1 - progress * (1 - MIN_MARKER_OPACITY)
|
||||||
@@ -145,7 +211,26 @@ function hideHeader() {
|
|||||||
window.dispatchEvent(new CustomEvent(HIDE_HEADER_EVENT))
|
window.dispatchEvent(new CustomEvent(HIDE_HEADER_EVENT))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadClouds(): Promise<CloudMarkerData[]> {
|
function toCloudMarker(row: Record<string, unknown>): CloudMarkerData {
|
||||||
|
const ct = row.cloud_types as Record<string, unknown> | null
|
||||||
|
const pf = row.profiles as Record<string, unknown> | null
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
latitude: row.latitude as number,
|
||||||
|
longitude: row.longitude as number,
|
||||||
|
imageUrl: row.image_url as string,
|
||||||
|
thumbnailUrl: (row.thumbnail_url as string | null) ?? null,
|
||||||
|
locationName: (row.location_name as string | null) ?? null,
|
||||||
|
description: (row.description as string | null) ?? null,
|
||||||
|
cloudTypeName: (ct?.name as string) || (row.custom_cloud_type as string) || '未知',
|
||||||
|
rarity: (ct?.rarity as 'common' | 'uncommon' | 'rare') || 'common',
|
||||||
|
username: (pf?.username as string) || '匿名',
|
||||||
|
capturedAt: (row.captured_at as string) || (row.created_at as string),
|
||||||
|
createdAt: row.created_at as string,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCloudsByRange(field: 'captured_at' | 'created_at', start: Date, end: Date): Promise<CloudMarkerData[]> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('clouds')
|
.from('clouds')
|
||||||
.select('id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,custom_cloud_type,cloud_types(name,rarity),profiles(username)')
|
.select('id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,custom_cloud_type,cloud_types(name,rarity),profiles(username)')
|
||||||
@@ -153,39 +238,34 @@ async function loadClouds(): Promise<CloudMarkerData[]> {
|
|||||||
.eq('is_hidden', false)
|
.eq('is_hidden', false)
|
||||||
.not('latitude', 'is', null)
|
.not('latitude', 'is', null)
|
||||||
.not('longitude', 'is', null)
|
.not('longitude', 'is', null)
|
||||||
.order('created_at', { ascending: false })
|
.gte(field, start.toISOString())
|
||||||
.limit(500)
|
.lt(field, end.toISOString())
|
||||||
|
.order(field, { ascending: true })
|
||||||
|
.limit(1000)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
statusText.value = `查询失败: ${error.message}`
|
statusText.value = `查询失败: ${error.message}`
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = data as Array<Record<string, unknown>> || []
|
return ((data || []) as Array<Record<string, unknown>>).map(toCloudMarker)
|
||||||
statusText.value = `${rows.length} 朵云`
|
}
|
||||||
const result: CloudMarkerData[] = []
|
|
||||||
for (const r of rows) {
|
async function loadRealtimeClouds() {
|
||||||
const ct = r.cloud_types as Record<string, unknown> | null
|
const { start, end } = getLocalDateRange(formatDateInput(new Date()))
|
||||||
const pf = r.profiles as Record<string, unknown> | null
|
return fetchCloudsByRange('captured_at', start, end)
|
||||||
result.push({
|
}
|
||||||
id: r.id as string,
|
|
||||||
latitude: r.latitude as number,
|
async function loadArchiveClouds() {
|
||||||
longitude: r.longitude as number,
|
const range = archiveKind.value === 'day'
|
||||||
imageUrl: r.image_url as string,
|
? getLocalDateRange(archiveDay.value)
|
||||||
thumbnailUrl: (r.thumbnail_url as string | null) ?? null,
|
: getLocalMonthRange(archiveMonth.value)
|
||||||
locationName: (r.location_name as string | null) ?? null,
|
return fetchCloudsByRange('captured_at', range.start, range.end)
|
||||||
description: (r.description as string | null) ?? null,
|
|
||||||
cloudTypeName: (ct?.name as string) || (r.custom_cloud_type as string) || '未知',
|
|
||||||
rarity: (ct?.rarity as 'common' | 'uncommon' | 'rare') || 'common',
|
|
||||||
username: (pf?.username as string) || '匿名',
|
|
||||||
capturedAt: (r.captured_at as string) || (r.created_at as string),
|
|
||||||
createdAt: r.created_at as string,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVisibleClouds(clouds: CloudMarkerData[]) {
|
function getVisibleClouds(clouds: CloudMarkerData[]) {
|
||||||
|
if (mapMode.value === 'archive') return clouds
|
||||||
|
|
||||||
return clouds.filter(cloud => {
|
return clouds.filter(cloud => {
|
||||||
const opacity = getMarkerOpacity(cloud)
|
const opacity = getMarkerOpacity(cloud)
|
||||||
return opacity !== null
|
return opacity !== null
|
||||||
@@ -202,7 +282,9 @@ function drawMarkers(clouds: CloudMarkerData[]) {
|
|||||||
if (!AMapLib) return
|
if (!AMapLib) return
|
||||||
clearMarkers()
|
clearMarkers()
|
||||||
const visibleClouds = getVisibleClouds(clouds)
|
const visibleClouds = getVisibleClouds(clouds)
|
||||||
statusText.value = `${visibleClouds.length} 朵云(近 2 小时)`
|
statusText.value = mapMode.value === 'realtime'
|
||||||
|
? `${visibleClouds.length} 朵云(${selectedTimeLabel.value} 前 2 小时)`
|
||||||
|
: `${visibleClouds.length} 朵云(${archiveTitle.value} 拍摄)`
|
||||||
|
|
||||||
if (previewCloud.value && !visibleClouds.some(item => item.id === previewCloud.value?.id)) {
|
if (previewCloud.value && !visibleClouds.some(item => item.id === previewCloud.value?.id)) {
|
||||||
previewCloud.value = null
|
previewCloud.value = null
|
||||||
@@ -226,14 +308,22 @@ function drawMarkers(clouds: CloudMarkerData[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
|
if (mapMode.value === 'realtime') {
|
||||||
|
syncCurrentMinute()
|
||||||
|
}
|
||||||
statusText.value = '加载中...'
|
statusText.value = '加载中...'
|
||||||
allClouds = await loadClouds()
|
allClouds = mapMode.value === 'archive'
|
||||||
|
? await loadArchiveClouds()
|
||||||
|
: await loadRealtimeClouds()
|
||||||
drawMarkers(allClouds)
|
drawMarkers(allClouds)
|
||||||
}
|
}
|
||||||
|
|
||||||
function startMarkerDecayTimer() {
|
function startMarkerDecayTimer() {
|
||||||
if (redrawTimer !== null) window.clearInterval(redrawTimer)
|
if (redrawTimer !== null) window.clearInterval(redrawTimer)
|
||||||
redrawTimer = window.setInterval(() => {
|
redrawTimer = window.setInterval(() => {
|
||||||
|
if (mapMode.value === 'realtime') {
|
||||||
|
syncCurrentMinute()
|
||||||
|
}
|
||||||
drawMarkers(allClouds)
|
drawMarkers(allClouds)
|
||||||
}, 60 * 1000)
|
}, 60 * 1000)
|
||||||
}
|
}
|
||||||
@@ -265,6 +355,42 @@ async function handleQuickUploaded() {
|
|||||||
await refresh()
|
await refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTimeSliderInput(event: Event) {
|
||||||
|
syncCurrentMinute()
|
||||||
|
selectedMinuteOfDay.value = Math.min(Number((event.target as HTMLInputElement).value), currentMinuteOfDay.value)
|
||||||
|
mapMode.value = 'realtime'
|
||||||
|
archivePanelOpen.value = false
|
||||||
|
drawMarkers(allClouds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSliderPointerDown() {
|
||||||
|
sliderDragging.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSliderPointerUp() {
|
||||||
|
sliderDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTimelineControls() {
|
||||||
|
timelineControlsOpen.value = !timelineControlsOpen.value
|
||||||
|
if (!timelineControlsOpen.value) {
|
||||||
|
archivePanelOpen.value = false
|
||||||
|
sliderDragging.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyArchiveFilter() {
|
||||||
|
mapMode.value = 'archive'
|
||||||
|
archivePanelOpen.value = false
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function returnRealtime() {
|
||||||
|
mapMode.value = 'realtime'
|
||||||
|
archivePanelOpen.value = false
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
AMapLib = await loadAMap()
|
AMapLib = await loadAMap()
|
||||||
@@ -308,7 +434,7 @@ onUnmounted(() => {
|
|||||||
<div class="relative h-[100dvh] min-h-screen">
|
<div class="relative h-[100dvh] min-h-screen">
|
||||||
<div ref="mapEl" class="w-full h-full"></div>
|
<div ref="mapEl" class="w-full h-full"></div>
|
||||||
|
|
||||||
<div class="absolute bottom-6 right-4 flex flex-col items-end gap-2 z-10">
|
<div class="absolute bottom-6 right-4 flex flex-col items-end gap-2 z-20">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="w-10 h-10 bg-white rounded-lg shadow-md flex items-center justify-center hover:bg-gray-50"
|
class="w-10 h-10 bg-white rounded-lg shadow-md flex items-center justify-center hover:bg-gray-50"
|
||||||
@@ -333,6 +459,124 @@ onUnmounted(() => {
|
|||||||
<Satellite v-else />
|
<Satellite v-else />
|
||||||
</NIcon>
|
</NIcon>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-10 h-10 bg-white rounded-lg shadow-md flex items-center justify-center hover:bg-gray-50"
|
||||||
|
:title="timelineControlsOpen ? '收起时间控件' : '展开时间控件'"
|
||||||
|
:aria-label="timelineControlsOpen ? '收起时间控件' : '展开时间控件'"
|
||||||
|
@click="toggleTimelineControls"
|
||||||
|
>
|
||||||
|
<NIcon size="20" style="display: inline-flex; vertical-align: middle;">
|
||||||
|
<X v-if="timelineControlsOpen" />
|
||||||
|
<Adjustments v-else />
|
||||||
|
</NIcon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute bottom-6 right-16 z-10 flex justify-end pointer-events-none">
|
||||||
|
<div class="pointer-events-auto flex items-center justify-end gap-3">
|
||||||
|
<Transition name="map-timeline-controls">
|
||||||
|
<div v-if="timelineControlsOpen" class="flex items-center gap-3">
|
||||||
|
<div class="relative w-[min(42vw,420px)] min-w-[220px]">
|
||||||
|
<div
|
||||||
|
v-if="sliderDragging"
|
||||||
|
class="absolute bottom-8 border border-slate-200 bg-white/95 px-3 py-1.5 text-xs font-medium text-slate-800 shadow-md backdrop-blur"
|
||||||
|
:style="{ left: `${selectedMinutePercent}%`, transform: 'translateX(-50%)' }"
|
||||||
|
>
|
||||||
|
{{ selectedTimeLabel }}
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1439"
|
||||||
|
step="10"
|
||||||
|
:value="selectedMinuteOfDay"
|
||||||
|
class="map-time-slider block w-full accent-sky-500"
|
||||||
|
:disabled="mapMode === 'archive'"
|
||||||
|
@pointerdown="handleSliderPointerDown"
|
||||||
|
@pointerup="handleSliderPointerUp"
|
||||||
|
@pointercancel="handleSliderPointerUp"
|
||||||
|
@blur="handleSliderPointerUp"
|
||||||
|
@input="handleTimeSliderInput"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="mapMode === 'realtime'"
|
||||||
|
class="absolute bottom-0 top-0 z-10"
|
||||||
|
:style="{ left: `${currentMinutePercent}%`, right: '0' }"
|
||||||
|
@pointerdown.prevent
|
||||||
|
@click.prevent
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-10 h-10 bg-white rounded-lg shadow-md flex items-center justify-center hover:bg-gray-50"
|
||||||
|
title="按日期或月份查看"
|
||||||
|
aria-label="按日期或月份查看"
|
||||||
|
@click="archivePanelOpen = !archivePanelOpen"
|
||||||
|
>
|
||||||
|
<NIcon size="20" style="display: inline-flex; vertical-align: middle;">
|
||||||
|
<Calendar />
|
||||||
|
</NIcon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="archivePanelOpen"
|
||||||
|
class="absolute bottom-16 right-0 w-72 border border-slate-200 bg-white p-4 shadow-[8px_8px_0_0_rgba(15,23,42,0.08)]"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<p class="text-sm font-semibold text-slate-900">历史位置</p>
|
||||||
|
<button type="button" class="text-xs font-medium text-sky-700 hover:text-sky-900" @click="returnRealtime">
|
||||||
|
返回实时
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border px-3 py-2 text-sm font-medium transition-colors"
|
||||||
|
:class="archiveKind === 'day' ? 'border-teal-200 bg-teal-50 text-teal-800' : 'border-slate-200 bg-white text-slate-600 hover:bg-slate-50'"
|
||||||
|
@click="archiveKind = 'day'"
|
||||||
|
>
|
||||||
|
某天
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border px-3 py-2 text-sm font-medium transition-colors"
|
||||||
|
:class="archiveKind === 'month' ? 'border-teal-200 bg-teal-50 text-teal-800' : 'border-slate-200 bg-white text-slate-600 hover:bg-slate-50'"
|
||||||
|
@click="archiveKind = 'month'"
|
||||||
|
>
|
||||||
|
某月
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-if="archiveKind === 'day'"
|
||||||
|
v-model="archiveDay"
|
||||||
|
type="date"
|
||||||
|
class="mt-3 w-full border border-slate-300 px-3 py-2 text-sm text-slate-800 outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
v-model="archiveMonth"
|
||||||
|
type="month"
|
||||||
|
class="mt-3 w-full border border-slate-300 px-3 py-2 text-sm text-slate-800 outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-3 w-full border border-sky-200 bg-sky-100 px-3 py-2 text-sm font-medium text-sky-800 shadow-[4px_4px_0_0_rgba(14,165,233,0.14)] transition-colors hover:bg-sky-50"
|
||||||
|
@click="applyArchiveFilter"
|
||||||
|
>
|
||||||
|
查看拍摄位置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ImageDetailModal
|
<ImageDetailModal
|
||||||
@@ -388,3 +632,86 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.map-archive-button.map-archive-button {
|
||||||
|
border-radius: 9999px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-time-slider:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-time-slider {
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-time-slider::-webkit-slider-runnable-track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid rgba(186, 230, 253, 0.85);
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
box-shadow: 0 3px 12px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-time-slider::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-top: -10px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 9999px;
|
||||||
|
background-color: #e0f2fe;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='%230ea5e9' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6.5 18a4.5 4.5 0 1 1 .5-8.97A6 6 0 0 1 18.35 11.5A3.5 3.5 0 1 1 18 18Z'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: 18px 18px;
|
||||||
|
box-shadow: 0 2px 10px rgba(14, 165, 233, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-time-slider::-moz-range-track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid rgba(186, 230, 253, 0.85);
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
box-shadow: 0 3px 12px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-time-slider::-moz-range-thumb {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 9999px;
|
||||||
|
background-color: #e0f2fe;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='%230ea5e9' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6.5 18a4.5 4.5 0 1 1 .5-8.97A6 6 0 0 1 18.35 11.5A3.5 3.5 0 1 1 18 18Z'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: 18px 18px;
|
||||||
|
box-shadow: 0 2px 10px rgba(14, 165, 233, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-timeline-controls-enter-active,
|
||||||
|
.map-timeline-controls-leave-active {
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
opacity 180ms ease,
|
||||||
|
transform 180ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
max-width 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-timeline-controls-enter-from,
|
||||||
|
.map-timeline-controls-leave-to {
|
||||||
|
max-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-timeline-controls-enter-to,
|
||||||
|
.map-timeline-controls-leave-from {
|
||||||
|
max-width: 560px;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user