merge: 合并官方 upstream/main 的 6 个功能更新
合并内容: 1. feat(gateway): Claude Code 系统提示词智能注入 2. fix: 修复创建账号 schedulable 默认值为 false 的 bug 3. fix(frontend): 修复跨时区日期范围筛选问题 4. feat(proxy): SOCKS5H 代理支持(统一代理配置) 5. fix(oauth): 修复 Claude Cookie 添加账号时会话混淆 6. fix(test): 修复 OAuth 账号测试刷新 token 的 bug 新增文件: - backend/internal/pkg/proxyutil/* (SOCKS5H 支持) - backend/internal/service/gateway_prompt_test.go (测试) 来自 upstream: Wei-Shaw/sub2api commits d9b1587..a527559
This commit is contained in:
@@ -1,444 +1,452 @@
|
||||
<template>
|
||||
<div class="relative" ref="containerRef">
|
||||
<button
|
||||
type="button"
|
||||
@click="toggle"
|
||||
:class="['date-picker-trigger', isOpen && 'date-picker-trigger-open']"
|
||||
>
|
||||
<span class="date-picker-icon">
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="date-picker-value">
|
||||
{{ displayValue }}
|
||||
</span>
|
||||
<span class="date-picker-chevron">
|
||||
<svg
|
||||
:class="['h-4 w-4 transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Transition name="date-picker-dropdown">
|
||||
<div v-if="isOpen" class="date-picker-dropdown">
|
||||
<!-- Quick presets -->
|
||||
<div class="date-picker-presets">
|
||||
<button
|
||||
v-for="preset in presets"
|
||||
:key="preset.value"
|
||||
@click="selectPreset(preset)"
|
||||
:class="['date-picker-preset', isPresetActive(preset) && 'date-picker-preset-active']"
|
||||
>
|
||||
{{ t(preset.labelKey) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="date-picker-divider"></div>
|
||||
|
||||
<!-- Custom date range inputs -->
|
||||
<div class="date-picker-custom">
|
||||
<div class="date-picker-field">
|
||||
<label class="date-picker-label">{{ t('dates.startDate') }}</label>
|
||||
<input
|
||||
type="date"
|
||||
v-model="localStartDate"
|
||||
:max="localEndDate || today"
|
||||
class="date-picker-input"
|
||||
@change="onDateChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="date-picker-separator">
|
||||
<svg
|
||||
class="h-4 w-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="date-picker-field">
|
||||
<label class="date-picker-label">{{ t('dates.endDate') }}</label>
|
||||
<input
|
||||
type="date"
|
||||
v-model="localEndDate"
|
||||
:min="localStartDate"
|
||||
:max="today"
|
||||
class="date-picker-input"
|
||||
@change="onDateChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apply button -->
|
||||
<div class="date-picker-actions">
|
||||
<button @click="apply" class="date-picker-apply">
|
||||
{{ t('dates.apply') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface DatePreset {
|
||||
labelKey: string
|
||||
value: string
|
||||
getRange: () => { start: string; end: string }
|
||||
}
|
||||
|
||||
interface Props {
|
||||
startDate: string
|
||||
endDate: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:startDate', value: string): void
|
||||
(e: 'update:endDate', value: string): void
|
||||
(e: 'change', range: { startDate: string; endDate: string; preset: string | null }): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const localStartDate = ref(props.startDate)
|
||||
const localEndDate = ref(props.endDate)
|
||||
const activePreset = ref<string | null>('7days')
|
||||
|
||||
const today = computed(() => {
|
||||
// Use local timezone to avoid UTC timezone issues
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
})
|
||||
|
||||
// Helper function to format date to YYYY-MM-DD using local timezone
|
||||
const formatDateToString = (date: Date): string => {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const presets: DatePreset[] = [
|
||||
{
|
||||
labelKey: 'dates.today',
|
||||
value: 'today',
|
||||
getRange: () => {
|
||||
const t = today.value
|
||||
return { start: t, end: t }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.yesterday',
|
||||
value: 'yesterday',
|
||||
getRange: () => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 1)
|
||||
const yesterday = formatDateToString(d)
|
||||
return { start: yesterday, end: yesterday }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.last7Days',
|
||||
value: '7days',
|
||||
getRange: () => {
|
||||
const end = today.value
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 6)
|
||||
const start = formatDateToString(d)
|
||||
return { start, end }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.last14Days',
|
||||
value: '14days',
|
||||
getRange: () => {
|
||||
const end = today.value
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 13)
|
||||
const start = formatDateToString(d)
|
||||
return { start, end }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.last30Days',
|
||||
value: '30days',
|
||||
getRange: () => {
|
||||
const end = today.value
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 29)
|
||||
const start = formatDateToString(d)
|
||||
return { start, end }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.thisMonth',
|
||||
value: 'thisMonth',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
const start = formatDateToString(new Date(now.getFullYear(), now.getMonth(), 1))
|
||||
return { start, end: today.value }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.lastMonth',
|
||||
value: 'lastMonth',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
const start = formatDateToString(new Date(now.getFullYear(), now.getMonth() - 1, 1))
|
||||
const end = formatDateToString(new Date(now.getFullYear(), now.getMonth(), 0))
|
||||
return { start, end }
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (activePreset.value) {
|
||||
const preset = presets.find((p) => p.value === activePreset.value)
|
||||
if (preset) return t(preset.labelKey)
|
||||
}
|
||||
|
||||
if (localStartDate.value && localEndDate.value) {
|
||||
if (localStartDate.value === localEndDate.value) {
|
||||
return formatDate(localStartDate.value)
|
||||
}
|
||||
return `${formatDate(localStartDate.value)} - ${formatDate(localEndDate.value)}`
|
||||
}
|
||||
|
||||
return t('dates.selectDateRange')
|
||||
})
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
const date = new Date(dateStr + 'T00:00:00')
|
||||
const dateLocale = locale.value === 'zh' ? 'zh-CN' : 'en-US'
|
||||
return date.toLocaleDateString(dateLocale, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
const isPresetActive = (preset: DatePreset): boolean => {
|
||||
return activePreset.value === preset.value
|
||||
}
|
||||
|
||||
const selectPreset = (preset: DatePreset) => {
|
||||
const range = preset.getRange()
|
||||
localStartDate.value = range.start
|
||||
localEndDate.value = range.end
|
||||
activePreset.value = preset.value
|
||||
}
|
||||
|
||||
const onDateChange = () => {
|
||||
// Check if current dates match any preset
|
||||
activePreset.value = null
|
||||
for (const preset of presets) {
|
||||
const range = preset.getRange()
|
||||
if (range.start === localStartDate.value && range.end === localEndDate.value) {
|
||||
activePreset.value = preset.value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
emit('update:startDate', localStartDate.value)
|
||||
emit('update:endDate', localEndDate.value)
|
||||
emit('change', {
|
||||
startDate: localStartDate.value,
|
||||
endDate: localEndDate.value,
|
||||
preset: activePreset.value
|
||||
})
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen.value) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Sync local state with props
|
||||
watch(
|
||||
() => props.startDate,
|
||||
(val) => {
|
||||
localStartDate.value = val
|
||||
onDateChange()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.endDate,
|
||||
(val) => {
|
||||
localEndDate.value = val
|
||||
onDateChange()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
// Initialize active preset detection
|
||||
onDateChange()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.date-picker-trigger {
|
||||
@apply flex items-center gap-2;
|
||||
@apply rounded-lg px-3 py-2 text-sm;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply border border-gray-200 dark:border-dark-600;
|
||||
@apply text-gray-700 dark:text-gray-300;
|
||||
@apply transition-all duration-200;
|
||||
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
|
||||
@apply hover:border-gray-300 dark:hover:border-dark-500;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.date-picker-trigger-open {
|
||||
@apply border-primary-500 ring-2 ring-primary-500/30;
|
||||
}
|
||||
|
||||
.date-picker-icon {
|
||||
@apply text-gray-400 dark:text-dark-400;
|
||||
}
|
||||
|
||||
.date-picker-value {
|
||||
@apply font-medium;
|
||||
}
|
||||
|
||||
.date-picker-chevron {
|
||||
@apply text-gray-400 dark:text-dark-400;
|
||||
}
|
||||
|
||||
.date-picker-dropdown {
|
||||
@apply absolute left-0 z-[100] mt-2;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply rounded-xl;
|
||||
@apply border border-gray-200 dark:border-dark-700;
|
||||
@apply shadow-lg shadow-black/10 dark:shadow-black/30;
|
||||
@apply overflow-hidden;
|
||||
@apply min-w-[320px];
|
||||
}
|
||||
|
||||
.date-picker-presets {
|
||||
@apply grid grid-cols-2 gap-1 p-2;
|
||||
}
|
||||
|
||||
.date-picker-preset {
|
||||
@apply rounded-md px-3 py-1.5 text-xs font-medium;
|
||||
@apply text-gray-600 dark:text-gray-400;
|
||||
@apply hover:bg-gray-100 dark:hover:bg-dark-700;
|
||||
@apply transition-colors duration-150;
|
||||
}
|
||||
|
||||
.date-picker-preset-active {
|
||||
@apply bg-primary-100 dark:bg-primary-900/30;
|
||||
@apply text-primary-700 dark:text-primary-300;
|
||||
}
|
||||
|
||||
.date-picker-divider {
|
||||
@apply border-t border-gray-100 dark:border-dark-700;
|
||||
}
|
||||
|
||||
.date-picker-custom {
|
||||
@apply flex items-end gap-2 p-3;
|
||||
}
|
||||
|
||||
.date-picker-field {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.date-picker-label {
|
||||
@apply mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.date-picker-input {
|
||||
@apply w-full rounded-md px-2 py-1.5 text-sm;
|
||||
@apply bg-gray-50 dark:bg-dark-700;
|
||||
@apply border border-gray-200 dark:border-dark-600;
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
|
||||
}
|
||||
|
||||
.date-picker-input::-webkit-calendar-picker-indicator {
|
||||
@apply cursor-pointer opacity-60 hover:opacity-100;
|
||||
filter: invert(0.5);
|
||||
}
|
||||
|
||||
.dark .date-picker-input::-webkit-calendar-picker-indicator {
|
||||
filter: invert(0.7);
|
||||
}
|
||||
|
||||
.date-picker-separator {
|
||||
@apply flex items-center justify-center pb-1;
|
||||
}
|
||||
|
||||
.date-picker-actions {
|
||||
@apply flex justify-end p-2 pt-0;
|
||||
}
|
||||
|
||||
.date-picker-apply {
|
||||
@apply rounded-lg px-4 py-1.5 text-sm font-medium;
|
||||
@apply bg-primary-600 text-white;
|
||||
@apply hover:bg-primary-700;
|
||||
@apply transition-colors duration-150;
|
||||
}
|
||||
|
||||
/* Dropdown animation */
|
||||
.date-picker-dropdown-enter-active,
|
||||
.date-picker-dropdown-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.date-picker-dropdown-enter-from,
|
||||
.date-picker-dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="relative" ref="containerRef">
|
||||
<button
|
||||
type="button"
|
||||
@click="toggle"
|
||||
:class="['date-picker-trigger', isOpen && 'date-picker-trigger-open']"
|
||||
>
|
||||
<span class="date-picker-icon">
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="date-picker-value">
|
||||
{{ displayValue }}
|
||||
</span>
|
||||
<span class="date-picker-chevron">
|
||||
<svg
|
||||
:class="['h-4 w-4 transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Transition name="date-picker-dropdown">
|
||||
<div v-if="isOpen" class="date-picker-dropdown">
|
||||
<!-- Quick presets -->
|
||||
<div class="date-picker-presets">
|
||||
<button
|
||||
v-for="preset in presets"
|
||||
:key="preset.value"
|
||||
@click="selectPreset(preset)"
|
||||
:class="['date-picker-preset', isPresetActive(preset) && 'date-picker-preset-active']"
|
||||
>
|
||||
{{ t(preset.labelKey) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="date-picker-divider"></div>
|
||||
|
||||
<!-- Custom date range inputs -->
|
||||
<div class="date-picker-custom">
|
||||
<div class="date-picker-field">
|
||||
<label class="date-picker-label">{{ t('dates.startDate') }}</label>
|
||||
<input
|
||||
type="date"
|
||||
v-model="localStartDate"
|
||||
:max="localEndDate || tomorrow"
|
||||
class="date-picker-input"
|
||||
@change="onDateChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="date-picker-separator">
|
||||
<svg
|
||||
class="h-4 w-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="date-picker-field">
|
||||
<label class="date-picker-label">{{ t('dates.endDate') }}</label>
|
||||
<input
|
||||
type="date"
|
||||
v-model="localEndDate"
|
||||
:min="localStartDate"
|
||||
:max="tomorrow"
|
||||
class="date-picker-input"
|
||||
@change="onDateChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apply button -->
|
||||
<div class="date-picker-actions">
|
||||
<button @click="apply" class="date-picker-apply">
|
||||
{{ t('dates.apply') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface DatePreset {
|
||||
labelKey: string
|
||||
value: string
|
||||
getRange: () => { start: string; end: string }
|
||||
}
|
||||
|
||||
interface Props {
|
||||
startDate: string
|
||||
endDate: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:startDate', value: string): void
|
||||
(e: 'update:endDate', value: string): void
|
||||
(e: 'change', range: { startDate: string; endDate: string; preset: string | null }): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const localStartDate = ref(props.startDate)
|
||||
const localEndDate = ref(props.endDate)
|
||||
const activePreset = ref<string | null>('7days')
|
||||
|
||||
const today = computed(() => {
|
||||
// Use local timezone to avoid UTC timezone issues
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
})
|
||||
|
||||
// Tomorrow's date - used for max date to handle timezone differences
|
||||
// When user is in a timezone behind the server, "today" on server might be "tomorrow" locally
|
||||
const tomorrow = computed(() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + 1)
|
||||
return formatDateToString(d)
|
||||
})
|
||||
|
||||
// Helper function to format date to YYYY-MM-DD using local timezone
|
||||
const formatDateToString = (date: Date): string => {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const presets: DatePreset[] = [
|
||||
{
|
||||
labelKey: 'dates.today',
|
||||
value: 'today',
|
||||
getRange: () => {
|
||||
const t = today.value
|
||||
return { start: t, end: t }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.yesterday',
|
||||
value: 'yesterday',
|
||||
getRange: () => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 1)
|
||||
const yesterday = formatDateToString(d)
|
||||
return { start: yesterday, end: yesterday }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.last7Days',
|
||||
value: '7days',
|
||||
getRange: () => {
|
||||
const end = today.value
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 6)
|
||||
const start = formatDateToString(d)
|
||||
return { start, end }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.last14Days',
|
||||
value: '14days',
|
||||
getRange: () => {
|
||||
const end = today.value
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 13)
|
||||
const start = formatDateToString(d)
|
||||
return { start, end }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.last30Days',
|
||||
value: '30days',
|
||||
getRange: () => {
|
||||
const end = today.value
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 29)
|
||||
const start = formatDateToString(d)
|
||||
return { start, end }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.thisMonth',
|
||||
value: 'thisMonth',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
const start = formatDateToString(new Date(now.getFullYear(), now.getMonth(), 1))
|
||||
return { start, end: today.value }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.lastMonth',
|
||||
value: 'lastMonth',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
const start = formatDateToString(new Date(now.getFullYear(), now.getMonth() - 1, 1))
|
||||
const end = formatDateToString(new Date(now.getFullYear(), now.getMonth(), 0))
|
||||
return { start, end }
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (activePreset.value) {
|
||||
const preset = presets.find((p) => p.value === activePreset.value)
|
||||
if (preset) return t(preset.labelKey)
|
||||
}
|
||||
|
||||
if (localStartDate.value && localEndDate.value) {
|
||||
if (localStartDate.value === localEndDate.value) {
|
||||
return formatDate(localStartDate.value)
|
||||
}
|
||||
return `${formatDate(localStartDate.value)} - ${formatDate(localEndDate.value)}`
|
||||
}
|
||||
|
||||
return t('dates.selectDateRange')
|
||||
})
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
const date = new Date(dateStr + 'T00:00:00')
|
||||
const dateLocale = locale.value === 'zh' ? 'zh-CN' : 'en-US'
|
||||
return date.toLocaleDateString(dateLocale, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
const isPresetActive = (preset: DatePreset): boolean => {
|
||||
return activePreset.value === preset.value
|
||||
}
|
||||
|
||||
const selectPreset = (preset: DatePreset) => {
|
||||
const range = preset.getRange()
|
||||
localStartDate.value = range.start
|
||||
localEndDate.value = range.end
|
||||
activePreset.value = preset.value
|
||||
}
|
||||
|
||||
const onDateChange = () => {
|
||||
// Check if current dates match any preset
|
||||
activePreset.value = null
|
||||
for (const preset of presets) {
|
||||
const range = preset.getRange()
|
||||
if (range.start === localStartDate.value && range.end === localEndDate.value) {
|
||||
activePreset.value = preset.value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
emit('update:startDate', localStartDate.value)
|
||||
emit('update:endDate', localEndDate.value)
|
||||
emit('change', {
|
||||
startDate: localStartDate.value,
|
||||
endDate: localEndDate.value,
|
||||
preset: activePreset.value
|
||||
})
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen.value) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Sync local state with props
|
||||
watch(
|
||||
() => props.startDate,
|
||||
(val) => {
|
||||
localStartDate.value = val
|
||||
onDateChange()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.endDate,
|
||||
(val) => {
|
||||
localEndDate.value = val
|
||||
onDateChange()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
// Initialize active preset detection
|
||||
onDateChange()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.date-picker-trigger {
|
||||
@apply flex items-center gap-2;
|
||||
@apply rounded-lg px-3 py-2 text-sm;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply border border-gray-200 dark:border-dark-600;
|
||||
@apply text-gray-700 dark:text-gray-300;
|
||||
@apply transition-all duration-200;
|
||||
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
|
||||
@apply hover:border-gray-300 dark:hover:border-dark-500;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.date-picker-trigger-open {
|
||||
@apply border-primary-500 ring-2 ring-primary-500/30;
|
||||
}
|
||||
|
||||
.date-picker-icon {
|
||||
@apply text-gray-400 dark:text-dark-400;
|
||||
}
|
||||
|
||||
.date-picker-value {
|
||||
@apply font-medium;
|
||||
}
|
||||
|
||||
.date-picker-chevron {
|
||||
@apply text-gray-400 dark:text-dark-400;
|
||||
}
|
||||
|
||||
.date-picker-dropdown {
|
||||
@apply absolute left-0 z-[100] mt-2;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply rounded-xl;
|
||||
@apply border border-gray-200 dark:border-dark-700;
|
||||
@apply shadow-lg shadow-black/10 dark:shadow-black/30;
|
||||
@apply overflow-hidden;
|
||||
@apply min-w-[320px];
|
||||
}
|
||||
|
||||
.date-picker-presets {
|
||||
@apply grid grid-cols-2 gap-1 p-2;
|
||||
}
|
||||
|
||||
.date-picker-preset {
|
||||
@apply rounded-md px-3 py-1.5 text-xs font-medium;
|
||||
@apply text-gray-600 dark:text-gray-400;
|
||||
@apply hover:bg-gray-100 dark:hover:bg-dark-700;
|
||||
@apply transition-colors duration-150;
|
||||
}
|
||||
|
||||
.date-picker-preset-active {
|
||||
@apply bg-primary-100 dark:bg-primary-900/30;
|
||||
@apply text-primary-700 dark:text-primary-300;
|
||||
}
|
||||
|
||||
.date-picker-divider {
|
||||
@apply border-t border-gray-100 dark:border-dark-700;
|
||||
}
|
||||
|
||||
.date-picker-custom {
|
||||
@apply flex items-end gap-2 p-3;
|
||||
}
|
||||
|
||||
.date-picker-field {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.date-picker-label {
|
||||
@apply mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.date-picker-input {
|
||||
@apply w-full rounded-md px-2 py-1.5 text-sm;
|
||||
@apply bg-gray-50 dark:bg-dark-700;
|
||||
@apply border border-gray-200 dark:border-dark-600;
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
|
||||
}
|
||||
|
||||
.date-picker-input::-webkit-calendar-picker-indicator {
|
||||
@apply cursor-pointer opacity-60 hover:opacity-100;
|
||||
filter: invert(0.5);
|
||||
}
|
||||
|
||||
.dark .date-picker-input::-webkit-calendar-picker-indicator {
|
||||
filter: invert(0.7);
|
||||
}
|
||||
|
||||
.date-picker-separator {
|
||||
@apply flex items-center justify-center pb-1;
|
||||
}
|
||||
|
||||
.date-picker-actions {
|
||||
@apply flex justify-end p-2 pt-0;
|
||||
}
|
||||
|
||||
.date-picker-apply {
|
||||
@apply rounded-lg px-4 py-1.5 text-sm font-medium;
|
||||
@apply bg-primary-600 text-white;
|
||||
@apply hover:bg-primary-700;
|
||||
@apply transition-colors duration-150;
|
||||
}
|
||||
|
||||
/* Dropdown animation */
|
||||
.date-picker-dropdown-enter-active,
|
||||
.date-picker-dropdown-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.date-picker-dropdown-enter-from,
|
||||
.date-picker-dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user