Add authenticated global economic calendar with Jin10 fallback
Collect macroeconomic releases, events and holidays through the official browser SDK with resumable history from 2025. Preserve evidence, revisions and observation times, and merge verified series with official fields taking priority. Add category filters, field provenance, coverage and connection status. Verify calendar persistence and deployment recovery in isolated acceptance checks.
This commit is contained in:
1 parent
57424fd661
commit
e840557d22
35 files changed
+6828
-94
No files matched your search
@@ -9,10 +9,10 @@ export interface CalendarItem {
|
||||
period: string | null
|
||||
stage: string | null
|
||||
source: string
|
||||
unit: string
|
||||
unit: string | null
|
||||
importance: number
|
||||
scheduled_at: string
|
||||
status: "scheduled" | "waiting" | "delayed" | "released" | "withdrawn" | "error" | "historical" | "unavailable"
|
||||
status: "scheduled" | "waiting" | "delayed" | "released" | "withdrawn" | "error" | "historical" | "unavailable" | "recorded"
|
||||
actual: string | null
|
||||
previous: string | null
|
||||
forecast: string | null
|
||||
@@ -26,6 +26,18 @@ export interface CalendarItem {
|
||||
last_checked_at: string | null
|
||||
error: string | null
|
||||
revision_no: number
|
||||
kind: "data" | "event" | "holiday"
|
||||
calendar_date: string
|
||||
time_precision: "minute" | "day" | "tentative"
|
||||
period_label: string | null
|
||||
importance_source: "project" | "jin10"
|
||||
previous_original: string | null
|
||||
previous_revised: string | null
|
||||
forecast_before_release: boolean
|
||||
forecast_observed_at: string | null
|
||||
field_sources: Record<string, { source: string, observed_at: string | null, url: string }>
|
||||
related_release_id: number | null
|
||||
related_revision_no: number | null
|
||||
}
|
||||
|
||||
export interface CalendarData {
|
||||
@@ -33,15 +45,21 @@ export interface CalendarData {
|
||||
countries: { code: string, name: string }[]
|
||||
worker: { at: string, sources: Record<string, { source: string, checked_at: string, error: string | null }> } | null
|
||||
server_time: string
|
||||
jin10: { at: string, state: string, error: string | null, history_next?: string | null, last_success_at: string | null } | null
|
||||
coverage: { date: string, captured_at: string, counts: Record<"data" | "event" | "holiday", number> }[]
|
||||
}
|
||||
|
||||
export interface CalendarRevision {
|
||||
source: string
|
||||
revision_no: number
|
||||
captured_at: string
|
||||
payload: { title: string, scheduled_at: string, schedule_status: string, values: Record<string, { actual: string | null, previous: string | null }> }
|
||||
payload: { title: string, scheduled_at: string, schedule_status: string, values: Record<string, { actual?: string | null, previous?: string | null, forecast?: string | null, previous_revised?: string | null, precision?: string, description?: string | null, indicator?: string }> }
|
||||
}
|
||||
|
||||
export interface CalendarHistory {
|
||||
period_label?: string | null
|
||||
forecast_before_release?: boolean
|
||||
forecast_observed_at?: string | null
|
||||
release_id: number
|
||||
scheduled_at: string
|
||||
title: string
|
||||
|
||||
@@ -2,7 +2,9 @@ export interface Jin10Status {
|
||||
connected: boolean
|
||||
connected_at: string | null
|
||||
error: string | null
|
||||
sync_state: "awaiting_validation" | "disconnected"
|
||||
sync_state: "awaiting_validation" | "disconnected" | "connecting" | "syncing" | "ready" | "error"
|
||||
last_success_at: string | null
|
||||
history_next: string | null
|
||||
}
|
||||
export interface Jin10Qr {
|
||||
task_id: string
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { CalendarHistory, CalendarItem, CalendarRevision, CalendarSeriesValue } from "@@/apis/calendar/type"
|
||||
import { calendarHistoryApi, calendarRevisionsApi, calendarSeriesApi } from "@@/apis/calendar"
|
||||
import { useSessionScope } from "@@/composables/useSessionScope"
|
||||
import dayjs from "dayjs"
|
||||
import timezone from "dayjs/plugin/timezone"
|
||||
import utc from "dayjs/plugin/utc"
|
||||
import { flagUrl, sources, timeLabel, value } from "../presentation"
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
const zone = "Asia/Shanghai"
|
||||
const formatted = (time: string) => dayjs(time).tz(zone).format("MM-DD HH:mm:ss")
|
||||
const value = (v: string | null, unit: string) => v === null ? "—" : `${v}${unit}`
|
||||
const scope = useSessionScope()
|
||||
const detail = defineModel<CalendarItem | null>({ required: true })
|
||||
const revisions = ref<CalendarRevision[]>([])
|
||||
const revisionsLoading = ref(false)
|
||||
@@ -21,9 +23,11 @@ const series = ref<CalendarSeriesValue[]>([])
|
||||
const seriesLoading = ref(false)
|
||||
const seriesError = ref(false)
|
||||
const detailTab = ref("history")
|
||||
const revisionValue = (revision: CalendarRevision, item: CalendarItem) => revision.payload.values[revision.payload.values._jin10?.indicator ?? item.indicator]
|
||||
let detailSeq = 0
|
||||
let disposed = false
|
||||
async function load(item: CalendarItem) {
|
||||
if (!scope.isCurrent()) return
|
||||
const current = ++detailSeq
|
||||
revisions.value = []
|
||||
history.value = []
|
||||
@@ -35,34 +39,34 @@ async function load(item: CalendarItem) {
|
||||
revisionError.value = false
|
||||
historyError.value = false
|
||||
await Promise.allSettled([
|
||||
calendarRevisionsApi(item.release_id).then((result) => {
|
||||
if (!disposed && current === detailSeq) revisions.value = result
|
||||
Promise.all([item.release_id, ...(item.related_release_id ? [item.related_release_id] : [])].map(id => calendarRevisionsApi(id))).then((result) => {
|
||||
if (!disposed && scope.isCurrent() && current === detailSeq) revisions.value = result.flat().sort((a, b) => Date.parse(b.captured_at) - Date.parse(a.captured_at))
|
||||
}).catch(() => {
|
||||
if (!disposed && current === detailSeq) revisionError.value = true
|
||||
if (!disposed && scope.isCurrent() && current === detailSeq) revisionError.value = true
|
||||
}).finally(() => {
|
||||
if (!disposed && current === detailSeq) revisionsLoading.value = false
|
||||
if (!disposed && scope.isCurrent() && current === detailSeq) revisionsLoading.value = false
|
||||
}),
|
||||
calendarHistoryApi(item.release_id, item.indicator).then((result) => {
|
||||
if (!disposed && current === detailSeq) history.value = result
|
||||
(item.kind === "data" ? calendarHistoryApi(item.release_id, item.indicator) : Promise.resolve([])).then((result) => {
|
||||
if (!disposed && scope.isCurrent() && current === detailSeq) history.value = result
|
||||
}).catch(() => {
|
||||
if (!disposed && current === detailSeq) historyError.value = true
|
||||
if (!disposed && scope.isCurrent() && current === detailSeq) historyError.value = true
|
||||
}).finally(() => {
|
||||
if (!disposed && current === detailSeq) historyLoading.value = false
|
||||
if (!disposed && scope.isCurrent() && current === detailSeq) historyLoading.value = false
|
||||
}),
|
||||
calendarSeriesApi(item.release_id, item.indicator).then((result) => {
|
||||
if (!disposed && current === detailSeq) series.value = result
|
||||
(item.kind === "data" ? calendarSeriesApi(item.release_id, item.indicator) : Promise.resolve([])).then((result) => {
|
||||
if (!disposed && scope.isCurrent() && current === detailSeq) series.value = result
|
||||
}).catch(() => {
|
||||
if (!disposed && current === detailSeq) seriesError.value = true
|
||||
if (!disposed && scope.isCurrent() && current === detailSeq) seriesError.value = true
|
||||
}).finally(() => {
|
||||
if (!disposed && current === detailSeq) seriesLoading.value = false
|
||||
if (!disposed && scope.isCurrent() && current === detailSeq) seriesLoading.value = false
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
watch(() => detail.value ? `${detail.value.release_id}:${detail.value.indicator}:${detail.value.revision_no}` : "", (key, old) => {
|
||||
watch(() => detail.value ? `${detail.value.release_id}:${detail.value.indicator}:${detail.value.revision_no}:${detail.value.related_revision_no ?? ""}` : "", (key, old) => {
|
||||
++detailSeq
|
||||
if (!detail.value) return
|
||||
if (key.split(":").slice(0, 2).join(":") !== old?.split(":").slice(0, 2).join(":")) detailTab.value = "history"
|
||||
if (key.split(":").slice(0, 2).join(":") !== old?.split(":").slice(0, 2).join(":")) detailTab.value = detail.value.kind === "data" ? "history" : "revisions"
|
||||
void load(detail.value)
|
||||
}, { immediate: true })
|
||||
onBeforeUnmount(() => {
|
||||
@@ -75,29 +79,55 @@ onBeforeUnmount(() => {
|
||||
<el-drawer :model-value="!!detail" title="事件详情" size="min(820px, 100vw)" @close="detail = null">
|
||||
<template v-if="detail">
|
||||
<h2 class="detail-name">
|
||||
<img class="country-flag" :src="`/flags/${detail.country_code.toLowerCase()}.svg`" :alt="detail.country_name"> {{ detail.event_name }}
|
||||
<img v-if="flagUrl(detail)" class="country-flag" :src="flagUrl(detail)!" :alt="detail.country_name"> {{ detail.event_name }}
|
||||
</h2>
|
||||
<p>{{ detail.title }}</p>
|
||||
<p v-if="detail.period">
|
||||
统计期:{{ detail.period }}
|
||||
</p>
|
||||
<p>预定公布:{{ formatted(detail.scheduled_at) }}</p>
|
||||
<p>首次获取:{{ detail.first_received_at ? formatted(detail.first_received_at) : "尚未获取" }}</p>
|
||||
<p>日期与时间:{{ detail.calendar_date }} {{ timeLabel(detail) }}(北京时间)</p>
|
||||
<p v-if="detail.period_label">
|
||||
统计期原文:{{ detail.period_label }}
|
||||
</p>
|
||||
<template v-if="detail.kind === 'data'">
|
||||
<p>公布值:{{ value(detail.actual, detail.unit) }} · 前值:{{ value(detail.previous, detail.unit) }} · 预期:{{ value(detail.forecast, detail.unit) }}</p>
|
||||
<p v-if="detail.unit === null">
|
||||
来源未标明单位,未自行推定。
|
||||
</p>
|
||||
<p v-if="detail.previous_revised !== null">
|
||||
金十原前值:{{ value(detail.previous_original, detail.unit) }} · 修订前值:{{ value(detail.previous_revised, detail.unit) }}(不代表当期公布值被修订)
|
||||
</p>
|
||||
<el-alert v-if="detail.forecast !== null && !detail.forecast_before_release" :closable="false" type="warning" title="预期未在发布前有效记录,不能当作公布前已知信息或用于当时的超预期判断。" />
|
||||
<p v-if="detail.forecast_observed_at">
|
||||
预期首次记录:{{ formatted(detail.forecast_observed_at) }}
|
||||
</p>
|
||||
</template>
|
||||
<p v-for="[field, label] in [['actual', '公布值来源'], ['previous', '前值来源'], ['forecast', '预期来源'], ['scheduled_at', '时间来源']]" :key="field">
|
||||
<template v-if="detail.field_sources[field]">
|
||||
{{ label }}:<a :href="detail.field_sources[field].url" target="_blank" rel="noopener noreferrer">{{ sources[detail.field_sources[field].source] }}</a>
|
||||
<span v-if="detail.field_sources[field].observed_at"> · 获取于 {{ formatted(detail.field_sources[field].observed_at!) }}</span>
|
||||
</template>
|
||||
</p>
|
||||
<p>首次收录:{{ detail.first_received_at ? formatted(detail.first_received_at) : "尚未获取" }}</p>
|
||||
<p>最近检查:{{ detail.last_checked_at ? formatted(detail.last_checked_at) : "尚未检查" }}</p>
|
||||
<p v-if="detail.previous_basis">
|
||||
前值口径:{{ detail.previous_basis }}
|
||||
</p>
|
||||
<el-alert v-if="detail.error" :closable="false" type="error" :title="detail.error" />
|
||||
<el-alert :closable="false" type="info" title="影响简评" :description="detail.explanation" />
|
||||
<p><a :href="detail.release_url ?? detail.schedule_url" target="_blank" rel="noopener noreferrer">查看官方来源 ↗</a></p>
|
||||
<p><a :href="detail.release_url ?? detail.schedule_url" target="_blank" rel="noopener noreferrer">查看来源 ↗</a></p>
|
||||
<el-tabs v-model="detailTab">
|
||||
<el-tab-pane label="历次公布值" name="history">
|
||||
<el-tab-pane v-if="detail.kind === 'data'" label="历次公布值" name="history">
|
||||
<p class="calendar-note">
|
||||
同一指标、同一发布阶段的当期公告值(含本期)。首次记录值不保证是原始初值,缺失历史留空;当前官方修订序列在下方独立展示,不能用于还原历史公布时点。
|
||||
同一指标、同一发布阶段的已采集记录(含本期)。金十补取值不保证是最初公布版本。首次记录值不保证是原始初值,缺失历史留空;当前官方修订序列在下方独立展示,不能用于还原历史公布时点。
|
||||
</p>
|
||||
<el-alert v-if="historyError" :closable="false" type="error" title="历史公布值获取失败" />
|
||||
<el-table v-loading="historyLoading" :data="history" stripe empty-text="尚无已采集的历史公布值">
|
||||
<el-table-column prop="period" label="统计期" min-width="100" />
|
||||
<el-table-column label="统计期" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.period ?? row.period_label ?? "—" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="公布时间" min-width="155">
|
||||
<template #default="{ row }">
|
||||
{{ dayjs(row.scheduled_at).tz(zone).format("YYYY-MM-DD HH:mm") }}
|
||||
@@ -108,12 +138,18 @@ onBeforeUnmount(() => {
|
||||
{{ value(row.previous, detail.unit) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预期记录" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ value(row.forecast, detail.unit) }}
|
||||
<small v-if="row.forecast !== null && !row.forecast_before_release"> · 非事前记录</small>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="首次记录值" width="110">
|
||||
<template #default="{ row }">
|
||||
{{ value(row.first_recorded_actual, detail.unit) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="当期公告值" width="120">
|
||||
<el-table-column label="本次记录值" width="120">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ value(row.actual, detail.unit) }}</strong><el-tag v-if="row.revised" size="small" type="warning">
|
||||
已修订
|
||||
@@ -155,12 +191,14 @@ onBeforeUnmount(() => {
|
||||
<el-tab-pane label="本期修订记录" name="revisions">
|
||||
<el-alert v-if="revisionError" :closable="false" type="error" title="修订记录获取失败" />
|
||||
<div v-loading="revisionsLoading">
|
||||
<div v-for="r in revisions" :key="r.revision_no" class="revision">
|
||||
<strong>版本 {{ r.revision_no }}</strong> · {{ formatted(r.captured_at) }}
|
||||
<p>预定时间 {{ formatted(r.payload.scheduled_at) }}</p>
|
||||
<p v-if="r.payload.values[detail.indicator]">
|
||||
公布值 {{ value(r.payload.values[detail.indicator].actual, detail.unit) }}
|
||||
</p>
|
||||
<div v-for="r in revisions" :key="`${r.source}:${r.revision_no}`" class="revision">
|
||||
<strong>{{ sources[r.source] }} · 版本 {{ r.revision_no }}</strong> · {{ formatted(r.captured_at) }}
|
||||
<p>预定时间 {{ r.payload.values._jin10?.precision === "day" ? "全天" : r.payload.values._jin10?.precision === "tentative" ? "待定" : formatted(r.payload.scheduled_at) }}</p>
|
||||
<p>{{ r.payload.title }}</p>
|
||||
<template v-if="revisionValue(r, detail)">
|
||||
<p>公布值 {{ value(revisionValue(r, detail).actual ?? null, detail.unit) }} · 前值 {{ value(revisionValue(r, detail).previous ?? null, detail.unit) }}</p>
|
||||
<p>预期 {{ value(revisionValue(r, detail).forecast ?? null, detail.unit) }} · 修订前值 {{ value(revisionValue(r, detail).previous_revised ?? null, detail.unit) }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CalendarData, CalendarItem } from "@@/apis/calendar/type"
|
||||
import { listCalendarApi } from "@@/apis/calendar"
|
||||
import { useSessionScope } from "@@/composables/useSessionScope"
|
||||
import { ArrowLeft, ArrowRight, Refresh } from "@element-plus/icons-vue"
|
||||
import dayjs from "dayjs"
|
||||
import timezone from "dayjs/plugin/timezone"
|
||||
import utc from "dayjs/plugin/utc"
|
||||
import CalendarDetail from "./components/CalendarDetail.vue"
|
||||
import { flagUrl, forecastNote, sources, timeLabel, value } from "./presentation"
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
const zone = "Asia/Shanghai"
|
||||
const scope = useSessionScope()
|
||||
const kind = ref<CalendarItem["kind"]>("data")
|
||||
const today = () => dayjs().tz(zone).format("YYYY-MM-DD")
|
||||
const selected = ref(today())
|
||||
const days = ref(7)
|
||||
@@ -22,15 +26,13 @@ const detail = ref<CalendarItem | null>(null)
|
||||
const country = ref("")
|
||||
const impactLabels = { pending: "待公布", bullish: "利多", bearish: "利空", neutral: "影响较小", uncertain: "待判断" }
|
||||
const countries = computed(() => data.value?.countries ?? [])
|
||||
const labels = { scheduled: "待公布", waiting: "获取中", delayed: "取数延迟", released: "已公布", withdrawn: "已撤回", error: "采集异常", historical: "仅有日程", unavailable: "官方未公布" }
|
||||
const sources: Record<string, string> = { bls: "BLS", bea: "BEA", fed: "美联储", umich: "密歇根大学", ecb: "欧洲央行", eurostat: "Eurostat", boe: "英国央行", ons: "ONS", destatis: "Destatis", insee: "INSEE", dol: "美国劳工部 DOL", eia: "美国能源信息署 EIA", adp: "ADP" }
|
||||
const labels = { scheduled: "待公布", waiting: "获取中", delayed: "取数延迟", released: "已公布", withdrawn: "已撤回", error: "采集异常", historical: "仅有日程", unavailable: "官方未公布", recorded: "已收录" }
|
||||
const formatted = (time: string) => dayjs(time).tz(zone).format("MM-DD HH:mm:ss")
|
||||
const value = (v: string | null, unit: string) => v === null ? "—" : `${v}${unit}`
|
||||
const groups = computed(() => {
|
||||
const result = new Map<string, CalendarItem[]>()
|
||||
for (const item of data.value?.items ?? []) {
|
||||
if (country.value && item.country_code !== country.value) continue
|
||||
const key = dayjs(item.scheduled_at).tz(zone).format("YYYY-MM-DD")
|
||||
if (item.kind !== kind.value || (country.value && item.country_code !== country.value)) continue
|
||||
const key = item.calendar_date
|
||||
const rows = result.get(key) ?? []
|
||||
rows.push(item)
|
||||
result.set(key, rows)
|
||||
@@ -40,7 +42,9 @@ const groups = computed(() => {
|
||||
const online = computed(() => !!data.value?.worker && now.value - Date.parse(data.value.worker.at) < 30000)
|
||||
const scheduleErrors = computed(() => Object.values(data.value?.worker?.sources ?? {}).filter(s => s.error || now.value - Date.parse(s.checked_at) > 7200000))
|
||||
const hasSchedule = computed(() => Object.keys(data.value?.worker?.sources ?? {}).length > 0)
|
||||
const upcoming = computed(() => data.value?.items.find(i => (!country.value || i.country_code === country.value) && i.status === "scheduled" && Date.parse(i.scheduled_at) > now.value))
|
||||
const jin10Online = computed(() => !!data.value?.jin10 && now.value - Date.parse(data.value.jin10.at) < 30000 && ["ready", "syncing"].includes(data.value.jin10.state))
|
||||
const coveredDays = computed(() => data.value?.coverage.length ?? 0)
|
||||
const upcoming = computed(() => data.value?.items.find(i => i.kind === kind.value && i.time_precision === "minute" && (!country.value || i.country_code === country.value) && i.status === "scheduled" && Date.parse(i.scheduled_at) > now.value))
|
||||
const countdown = computed(() => {
|
||||
if (!upcoming.value) return ""
|
||||
const seconds = Math.max(0, Math.floor((Date.parse(upcoming.value.scheduled_at) - now.value) / 1000))
|
||||
@@ -52,21 +56,22 @@ let clock: ReturnType<typeof setInterval> | undefined
|
||||
let disposed = false
|
||||
|
||||
async function load() {
|
||||
if (!scope.isCurrent()) return
|
||||
const current = ++seq
|
||||
clearTimeout(timer)
|
||||
loading.value = data.value === null
|
||||
try {
|
||||
const result = await listCalendarApi(selected.value, days.value)
|
||||
if (disposed || current !== seq) return
|
||||
if (disposed || !scope.isCurrent() || current !== seq) return
|
||||
data.value = result
|
||||
failed.value = false
|
||||
} catch {
|
||||
// HTTP 层已提示具体错误;保留上次数据并标为失联,不显示为实时成功。
|
||||
if (current === seq) failed.value = true
|
||||
if (!disposed && scope.isCurrent() && current === seq) failed.value = true
|
||||
} finally {
|
||||
if (current === seq && !disposed) {
|
||||
if (scope.isCurrent() && current === seq && !disposed) {
|
||||
loading.value = false
|
||||
const due = data.value?.items.some(i => ["scheduled", "waiting", "delayed"].includes(i.status) && Math.abs(Date.parse(i.scheduled_at) - Date.now()) < 900000)
|
||||
const due = data.value?.items.some(i => i.time_precision === "minute" && ["scheduled", "waiting", "delayed"].includes(i.status) && Math.abs(Date.parse(i.scheduled_at) - Date.now()) < 900000)
|
||||
timer = setTimeout(load, failed.value ? 30000 : due ? 3000 : 30000)
|
||||
}
|
||||
}
|
||||
@@ -110,7 +115,7 @@ onUnmounted(() => {
|
||||
<div class="calendar-heading">
|
||||
<div>
|
||||
<h1>经济日历</h1>
|
||||
<p>北京时间 UTC+8 · 各国经济数据与重要事件</p>
|
||||
<p>北京时间 UTC+8 · 全球宏观数据、重要事件与假期</p>
|
||||
</div>
|
||||
<el-tag :type="online && !failed ? 'success' : 'danger'">
|
||||
{{ online && !failed ? "采集在线" : "采集或连接离线" }}
|
||||
@@ -118,7 +123,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<el-alert v-if="failed || !online || !hasSchedule || scheduleErrors.length" :closable="false" type="warning" show-icon title="日历同步未就绪或来源异常,当前列表不能用于排除重大事件。" />
|
||||
<div v-if="upcoming" class="next-event">
|
||||
<span>下个事件</span><strong><img class="country-flag" :src="`/flags/${upcoming.country_code.toLowerCase()}.svg`" :alt="upcoming.country_name"> {{ upcoming.event_name }}</strong>
|
||||
<span>下个事件</span><strong><img v-if="flagUrl(upcoming)" class="country-flag" :src="flagUrl(upcoming)!" :alt="upcoming.country_name"> {{ upcoming.event_name }}</strong>
|
||||
<span>{{ formatted(upcoming.scheduled_at) }}</span><b>{{ countdown }}</b>
|
||||
</div>
|
||||
<el-card shadow="never" class="calendar-card">
|
||||
@@ -145,25 +150,43 @@ onUnmounted(() => {
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
<el-radio-group v-model="kind" aria-label="日历类别">
|
||||
<el-radio-button value="data">
|
||||
经济数据
|
||||
</el-radio-button>
|
||||
<el-radio-button value="event">
|
||||
重要事件
|
||||
</el-radio-button>
|
||||
<el-radio-button value="holiday">
|
||||
假期休市
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
<p class="calendar-range" aria-live="polite">
|
||||
查询范围:{{ selected }} 至 {{ rangeEnd }}(北京时间,含首尾日期)
|
||||
</p>
|
||||
<el-alert
|
||||
v-if="!jin10Online || coveredDays < days" :closable="false" type="warning" show-icon
|
||||
:title="`金十${jin10Online ? '在线' : '同步未就绪'} · 所选 ${days} 天中 ${coveredDays} 天已完整收录三类日历;缺失日期不表示没有事件。`"
|
||||
/>
|
||||
<p v-if="data?.jin10?.error" class="calendar-note">
|
||||
{{ data.jin10.error }}
|
||||
</p>
|
||||
<p class="calendar-note">
|
||||
三星标红,四星及以上为主要事件。影响针对 BTC/ETH:优先比较预期,无预期时按前值及利率通道给参考倾向,依据不足显示待判断。
|
||||
三星标红,四星及以上为主要事件。影响针对 BTC/ETH:优先比较预期,无预期时按前值及利率通道给参考倾向,依据不足显示待判断。金十星级与项目评级分别标注;历史补取预期不视为当时已知。
|
||||
</p>
|
||||
<div v-loading="loading" class="calendar-results">
|
||||
<section v-for="[date, items] in groups" :key="date">
|
||||
<h2>{{ date }} <small>{{ items.length }} 项指标</small></h2>
|
||||
<h2>{{ date }} <small>{{ items.length }} 项记录</small></h2>
|
||||
<el-table :data="items" stripe @row-click="openDetail">
|
||||
<el-table-column label="时间" width="90">
|
||||
<template #default="{ row }">
|
||||
{{ dayjs(row.scheduled_at).tz(zone).format("HH:mm") }}
|
||||
{{ timeLabel(row as CalendarItem) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数据" min-width="230">
|
||||
<el-table-column :label="kind === 'data' ? '数据' : kind === 'event' ? '事件' : '假期与休市说明'" min-width="230">
|
||||
<template #default="{ row }">
|
||||
<div class="event-name" :class="{ 'event-important': row.importance === 3, 'event-major': row.importance >= 4 }">
|
||||
<img class="country-flag" :src="`/flags/${row.country_code.toLowerCase()}.svg`" :alt="row.country_name">
|
||||
<img v-if="flagUrl(row as CalendarItem)" class="country-flag" :src="flagUrl(row as CalendarItem)!" :alt="row.country_name">
|
||||
<span>{{ row.event_name }}</span>
|
||||
<span v-if="row.importance >= 4" class="major-label">主要</span>
|
||||
</div><div class="row-subtitle">
|
||||
@@ -173,20 +196,22 @@ onUnmounted(() => {
|
||||
</el-table-column>
|
||||
<el-table-column label="重要性" width="125">
|
||||
<template #default="{ row }">
|
||||
<span class="stars" :class="{ 'stars-important': row.importance >= 3 }">{{ "★".repeat(row.importance) }}</span>
|
||||
<span class="stars" :class="{ 'stars-important': row.importance >= 3 }">{{ "★".repeat(row.importance) || "—" }}</span>
|
||||
<small class="row-subtitle">{{ row.importance_source === "jin10" ? "金十" : "项目评级" }}</small>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="前值" width="100">
|
||||
<el-table-column v-if="kind === 'data'" label="前值" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ value(row.previous, row.unit) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预期值" width="90">
|
||||
<el-table-column v-if="kind === 'data'" label="预期值" width="90">
|
||||
<template #default="{ row }">
|
||||
{{ value(row.forecast, row.unit) }}
|
||||
<small v-if="row.forecast !== null && !row.forecast_before_release" class="row-subtitle">{{ forecastNote(row as CalendarItem) }}</small>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="公布值" width="110">
|
||||
<el-table-column v-if="kind === 'data'" label="公布值" width="110">
|
||||
<template #default="{ row }">
|
||||
<strong class="actual-value">{{ value(row.actual, row.unit) }}</strong>
|
||||
</template>
|
||||
@@ -198,7 +223,7 @@ onUnmounted(() => {
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="影响 · BTC/ETH" min-width="155">
|
||||
<el-table-column v-if="kind === 'data'" label="影响 · BTC/ETH" min-width="155">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="row.explanation" placement="top">
|
||||
<span class="impact-badge" :class="`impact-${row.impact}`">{{ impactLabels[row.impact as CalendarItem['impact']] }}</span>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CalendarItem } from "@@/apis/calendar/type"
|
||||
import dayjs from "dayjs"
|
||||
import timezone from "dayjs/plugin/timezone"
|
||||
import utc from "dayjs/plugin/utc"
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
export const zone = "Asia/Shanghai"
|
||||
export const sources: Record<string, string> = { bls: "BLS", bea: "BEA", fed: "美联储", umich: "密歇根大学", ecb: "欧洲央行", eurostat: "Eurostat", boe: "英国央行", ons: "ONS", destatis: "Destatis", insee: "INSEE", dol: "美国劳工部 DOL", eia: "美国能源信息署 EIA", adp: "ADP", jin10: "金十" }
|
||||
export function flagUrl(item: CalendarItem) {
|
||||
if (["US", "EA", "GB", "DE", "FR"].includes(item.country_code)) return `/flags/${item.country_code.toLowerCase()}.svg`
|
||||
return /^[A-Z]{2}$/.test(item.country_code) ? `https://cdn.jin10.com/assets/img/commons/flag/${encodeURIComponent(item.country_name)}.png` : null
|
||||
}
|
||||
export function timeLabel(item: CalendarItem) {
|
||||
return item.time_precision === "day" ? "全天" : item.time_precision === "tentative" ? "待定" : dayjs(item.scheduled_at).tz(zone).format("HH:mm")
|
||||
}
|
||||
export const value = (v: string | null, unit: string | null) => v === null ? "—" : `${v}${unit ?? ""}`
|
||||
export function forecastNote(item: Pick<CalendarItem, "forecast_observed_at" | "scheduled_at">) {
|
||||
return item.forecast_observed_at && Date.parse(item.forecast_observed_at) >= Date.parse(item.scheduled_at) ? "发布后补取" : "时点未核实"
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const visible = ref(false)
|
||||
const busy = ref(false)
|
||||
const state = ref("idle")
|
||||
const failed = ref(false)
|
||||
const syncLabels = { awaiting_validation: "同步尚未就绪,账号连接不代表日历已完整收录", disconnected: "尚未连接", connecting: "正在验证日历连接", syncing: "正在同步日历", ready: "日历连接已验证,持续同步中", error: "日历同步失败,请查看下方原因" }
|
||||
let generation = 0
|
||||
let statusRequest = 0
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -118,7 +119,13 @@ watch(visible, (value) => {
|
||||
</p>
|
||||
<p>全球宏观数据、重要事件和假期。官方同口径数据优先,金十补充缺项。</p>
|
||||
<p v-if="status.connected">
|
||||
日历读取权限待核验;账号连接成功不代表日历同步已就绪。
|
||||
{{ syncLabels[status.sync_state] }}
|
||||
</p>
|
||||
<p v-if="status.last_success_at">
|
||||
最近成功:{{ status.last_success_at }}
|
||||
</p>
|
||||
<p v-if="status.history_next">
|
||||
历史回补下一日期:{{ status.history_next }}(北京时间,2025-01-01 起)
|
||||
</p>
|
||||
<el-alert v-if="status.error" :title="status.error" type="warning" :closable="false" />
|
||||
<el-button type="primary" :loading="busy" @click="start">
|
||||
@@ -127,6 +134,9 @@ watch(visible, (value) => {
|
||||
<el-button v-if="status.connected" @click="disconnect">
|
||||
断开连接
|
||||
</el-button>
|
||||
<el-button @click="load">
|
||||
刷新状态
|
||||
</el-button>
|
||||
</template>
|
||||
<el-button v-if="failed" @click="load">
|
||||
重试
|
||||
|
||||
Reference in new issue
Block a user