同步入口移至首页:MarketSyncBar 手动同步A股当日数据并展示最新更新日期;选股页移除数据状态条与同步逻辑
This commit is contained in:
76
frontend/src/components/MarketSyncBar.vue
Normal file
76
frontend/src/components/MarketSyncBar.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted } from 'vue';
|
||||||
|
|
||||||
|
import { useMarketSyncStore } from '@/stores/marketSync';
|
||||||
|
|
||||||
|
const store = useMarketSyncStore();
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.fetchStatus();
|
||||||
|
store.pollIfRunning();
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => store.stopPolling());
|
||||||
|
|
||||||
|
/** "2026-09-01T00:00:00" / "2026-09-01" -> "2026年09月01日";无数据显示 — */
|
||||||
|
function fmtDate(s?: string | null): string {
|
||||||
|
if (!s) return '—';
|
||||||
|
const d = s.slice(0, 10);
|
||||||
|
const [y, m, day] = d.split('-');
|
||||||
|
if (!y || !m || !day) return d;
|
||||||
|
return `${y}年${m}月${day}日`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestDate = computed(() => store.syncStatus?.last_trade_date ?? null);
|
||||||
|
const running = computed(() => !!store.syncStatus?.running);
|
||||||
|
const syncing = computed(() => !!store.syncStatus?.total_days);
|
||||||
|
// 后端任务内错误(如 daily_basic 权限受限)与请求级错误都展示
|
||||||
|
const errText = computed(() => store.error || store.syncStatus?.error || null);
|
||||||
|
const progressPct = computed(() => {
|
||||||
|
const s = store.syncStatus;
|
||||||
|
if (!s?.total_days) return 0;
|
||||||
|
return Math.min(100, ((s.done_days ?? 0) / s.total_days) * 100);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mb-12 flex flex-wrap items-center gap-x-5 gap-y-3 rounded-xl border border-[#26272E] bg-[#101014] px-5 py-4">
|
||||||
|
<!-- 最新更新日期 -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-blue-500/15 text-blue-300">
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||||
|
<path d="M16 2v4M8 2v4M3 10h18" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-[#6B7280]">A股日线数据更新至</div>
|
||||||
|
<div class="text-base font-semibold tabular-nums text-[#E5E7EB]">{{ fmtDate(latestDate) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="hidden flex-1 sm:block"></span>
|
||||||
|
|
||||||
|
<!-- 同步中:进度条 -->
|
||||||
|
<div v-if="running" class="flex min-w-[220px] flex-1 items-center gap-2">
|
||||||
|
<svg class="h-4 w-4 shrink-0 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
|
||||||
|
<span class="whitespace-nowrap text-[13px] text-[#A8AFB8]">
|
||||||
|
{{ store.syncStatus?.step || '同步中…' }}<template v-if="syncing">({{ store.syncStatus?.done_days }}/{{ store.syncStatus?.total_days }})</template>
|
||||||
|
</span>
|
||||||
|
<span v-if="syncing" class="h-1.5 flex-1 overflow-hidden rounded-full bg-[#26272E]">
|
||||||
|
<span class="block h-full rounded-full bg-blue-500 transition-all" :style="{ width: progressPct + '%' }" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空闲:手动同步按钮 -->
|
||||||
|
<button v-else type="button" class="btn-primary shrink-0" @click="store.startSync()">
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-2.6-6.4M21 3v6h-6" /></svg>
|
||||||
|
同步A股当日数据
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 错误提示 -->
|
||||||
|
<div v-if="errText" class="w-full text-sm text-amber-400">
|
||||||
|
<svg class="mr-1 inline h-4 w-4 align-[-3px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
|
||||||
|
{{ errText }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import type { ScreenerSyncStatus } from '@/api/types';
|
|
||||||
|
|
||||||
const props = defineProps<{ status: ScreenerSyncStatus | null }>();
|
|
||||||
const emit = defineEmits<{ (e: 'sync'): void }>();
|
|
||||||
|
|
||||||
function fmtDate(s?: string | null) {
|
|
||||||
if (!s) return '—';
|
|
||||||
return s.slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 数据是否可筛(有日线)。快照缺失则纯指标仍可用,给黄色提示。
|
|
||||||
const freshness = computed(() => {
|
|
||||||
const s = props.status;
|
|
||||||
if (!s) return { tone: 'none', text: '正在检查数据状态…' };
|
|
||||||
if (!s.ready) return { tone: 'warn', text: '全市场数据尚未同步,请先点击右侧「同步市场数据」' };
|
|
||||||
const snapMissing = s.stats.snapshot_rows === 0;
|
|
||||||
const d = s.stats.dates;
|
|
||||||
const base = `数据截至 ${fmtDate(s.last_trade_date)} · 共 ${d} 个交易日 · ${s.stats.stocks} 只股票`;
|
|
||||||
if (snapMissing) return { tone: 'warn', text: `${base}(每日指标暂不可用:市值/市盈率条件不可用,纯指标条件不受影响)` };
|
|
||||||
return { tone: 'ok', text: base };
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3 text-sm text-[#A8AFB8]">
|
|
||||||
<span :class="freshness.tone === 'ok' ? 'text-emerald-400' : freshness.tone === 'warn' ? 'text-amber-400' : 'text-[#9BA3AE]'">
|
|
||||||
<svg class="mr-1 inline h-4 w-4 align-[-3px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<template v-if="freshness.tone === 'ok'">
|
|
||||||
<path d="M22 11.1V12a10 10 0 11-5.9-9.1" />
|
|
||||||
<path d="M22 4L12 14l-3-3-7 7" />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<path d="M12 16v-4M12 8h.01" />
|
|
||||||
</template>
|
|
||||||
</svg>
|
|
||||||
{{ freshness.text }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<template v-if="status && status.running">
|
|
||||||
<span class="flex min-w-[200px] flex-1 items-center gap-2">
|
|
||||||
<svg class="h-4 w-4 shrink-0 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
|
|
||||||
<span class="whitespace-nowrap text-[13px] text-[#A8AFB8]">{{ status.step || '同步中…' }}({{ status.done_days }}/{{ status.total_days }})</span>
|
|
||||||
<span class="h-1.5 flex-1 overflow-hidden rounded-full bg-[#26272E]">
|
|
||||||
<span class="block h-full rounded-full bg-blue-500 transition-all" :style="{ width: (status.total_days ? Math.min(100, (status.done_days / status.total_days) * 100) : 0) + '%' }" />
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span class="flex-1"></span>
|
|
||||||
<button type="button" class="btn-ghost shrink-0 disabled:opacity-50" :disabled="status?.running" @click="emit('sync')">
|
|
||||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-2.6-6.4M21 3v6h-6" /></svg>
|
|
||||||
同步市场数据
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-if="status && status.error" class="w-full text-amber-400">
|
|
||||||
<svg class="mr-1 inline h-4 w-4 align-[-3px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
|
|
||||||
{{ status.error }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
53
frontend/src/stores/marketSync.ts
Normal file
53
frontend/src/stores/marketSync.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { getScreenerSyncStatus, startScreenerSync } from '@/api/client';
|
||||||
|
import type { ScreenerSyncStatus } from '@/api/types';
|
||||||
|
|
||||||
|
/** 全市场 A 股日线同步(首页手动触发)。状态轮询与选股 store 解耦,独立维护。 */
|
||||||
|
export const useMarketSyncStore = defineStore('marketSync', () => {
|
||||||
|
const syncStatus = ref<ScreenerSyncStatus | null>(null);
|
||||||
|
const error = ref<string | null>(null); // 启动同步的请求级错误
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
async function fetchStatus() {
|
||||||
|
try {
|
||||||
|
syncStatus.value = await getScreenerSyncStatus();
|
||||||
|
} catch {
|
||||||
|
/* 静默:状态拉取失败不阻塞页面 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 正在同步时开始 2s 轮询;空闲则停轮询(进入页面时也调用,承接后台遗留任务)。 */
|
||||||
|
function pollIfRunning() {
|
||||||
|
if (syncStatus.value?.running) {
|
||||||
|
if (!pollTimer) {
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
await fetchStatus();
|
||||||
|
if (!syncStatus.value?.running) stopPolling();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSync(days = 90) {
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await startScreenerSync({ days });
|
||||||
|
await fetchStatus();
|
||||||
|
pollIfRunning();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '启动同步失败';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { syncStatus, error, fetchStatus, startSync, stopPolling, pollIfRunning };
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { getScreenerSyncStatus, runScreener, startScreenerSync } from '@/api/client';
|
import { runScreener } from '@/api/client';
|
||||||
import type { ScreenConditions, ScreenerRunResponse, ScreenerSyncStatus } from '@/api/types';
|
import type { ScreenConditions, ScreenerRunResponse } from '@/api/types';
|
||||||
|
|
||||||
export const useScreenerStore = defineStore('screener', () => {
|
export const useScreenerStore = defineStore('screener', () => {
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -9,8 +9,6 @@ export const useScreenerStore = defineStore('screener', () => {
|
|||||||
const note = ref<string | null>(null);
|
const note = ref<string | null>(null);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const result = ref<ScreenerRunResponse | null>(null);
|
const result = ref<ScreenerRunResponse | null>(null);
|
||||||
const syncStatus = ref<ScreenerSyncStatus | null>(null);
|
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
async function run(text: string, conditions?: ScreenConditions | null) {
|
async function run(text: string, conditions?: ScreenConditions | null) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -36,35 +34,5 @@ export const useScreenerStore = defineStore('screener', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchStatus() {
|
return { loading, stage, note, error, result, run };
|
||||||
try {
|
|
||||||
syncStatus.value = await getScreenerSyncStatus();
|
|
||||||
} catch {
|
|
||||||
/* 静默:状态拉取失败不阻塞页面 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPolling() {
|
|
||||||
if (pollTimer) {
|
|
||||||
clearInterval(pollTimer);
|
|
||||||
pollTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startSync(days = 90) {
|
|
||||||
error.value = null;
|
|
||||||
try {
|
|
||||||
await startScreenerSync({ days });
|
|
||||||
await fetchStatus();
|
|
||||||
stopPolling();
|
|
||||||
pollTimer = setInterval(async () => {
|
|
||||||
await fetchStatus();
|
|
||||||
if (syncStatus.value && !syncStatus.value.running) stopPolling();
|
|
||||||
}, 2000);
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e instanceof Error ? e.message : '启动同步失败';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { loading, stage, note, error, result, syncStatus, run, fetchStatus, startSync, stopPolling };
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { RouterLink } from 'vue-router';
|
import { RouterLink } from 'vue-router';
|
||||||
|
|
||||||
import MarketOverview from '@/components/MarketOverview.vue';
|
import MarketOverview from '@/components/MarketOverview.vue';
|
||||||
|
import MarketSyncBar from '@/components/MarketSyncBar.vue';
|
||||||
|
|
||||||
// 首页:大盘行情总览 + 三大功能入口(看股 / 选股 / 回测)
|
// 首页:大盘行情总览 + 三大功能入口(看股 / 选股 / 回测)
|
||||||
const features = [
|
const features = [
|
||||||
@@ -33,6 +34,8 @@ const features = [
|
|||||||
<div class="w-full max-w-5xl">
|
<div class="w-full max-w-5xl">
|
||||||
<MarketOverview />
|
<MarketOverview />
|
||||||
|
|
||||||
|
<MarketSyncBar />
|
||||||
|
|
||||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="f in features"
|
v-for="f in features"
|
||||||
|
|||||||
@@ -1,30 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useScreenerStore } from '@/stores/screener';
|
import { useScreenerStore } from '@/stores/screener';
|
||||||
import ScreenerForm from '@/components/ScreenerForm.vue';
|
import ScreenerForm from '@/components/ScreenerForm.vue';
|
||||||
import ConditionChips from '@/components/ConditionChips.vue';
|
import ConditionChips from '@/components/ConditionChips.vue';
|
||||||
import SyncStatusBar from '@/components/SyncStatusBar.vue';
|
|
||||||
import ScreenerTable from '@/components/ScreenerTable.vue';
|
import ScreenerTable from '@/components/ScreenerTable.vue';
|
||||||
import StockDetailOverlay from '@/components/StockDetailOverlay.vue';
|
import StockDetailOverlay from '@/components/StockDetailOverlay.vue';
|
||||||
|
|
||||||
const store = useScreenerStore();
|
const store = useScreenerStore();
|
||||||
const previewCode = ref<string | null>(null);
|
const previewCode = ref<string | null>(null);
|
||||||
const formRef = ref<InstanceType<typeof ScreenerForm> | null>(null);
|
const formRef = ref<InstanceType<typeof ScreenerForm> | null>(null);
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// 仅查状态;同步由用户点击「同步市场数据」显式触发(避免反复触发接口限频)
|
|
||||||
store.fetchStatus();
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => store.stopPolling());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<ScreenerForm ref="formRef" :loading="store.loading" @run="store.run" @ran="formRef?.refreshHistory()" />
|
<ScreenerForm ref="formRef" :loading="store.loading" @run="store.run" @ran="formRef?.refreshHistory()" />
|
||||||
|
|
||||||
<SyncStatusBar :status="store.syncStatus" @sync="store.startSync(90)" />
|
|
||||||
|
|
||||||
<div v-if="store.error" class="mt-4 flex items-start gap-2 rounded-xl border border-red-500/40 bg-red-500/15 px-4 py-3 text-sm text-red-300">
|
<div v-if="store.error" class="mt-4 flex items-start gap-2 rounded-xl border border-red-500/40 bg-red-500/15 px-4 py-3 text-sm text-red-300">
|
||||||
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
|
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
|
||||||
{{ store.error }}
|
{{ store.error }}
|
||||||
|
|||||||
Reference in New Issue
Block a user