rebuild(stage-5): establish market data gateway and charts
This commit is contained in:
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
import SystemManagementView from "./views/SystemManagementView.vue";
|
||||
import WorkspaceView from "./views/WorkspaceView.vue";
|
||||
import EntityDetailView from "./views/EntityDetailView.vue";
|
||||
import { findWorkspace } from "./workspaceRegistry";
|
||||
|
||||
export default createRouter({
|
||||
@@ -15,6 +16,11 @@ export default createRouter({
|
||||
beforeEnter: (to) => (findWorkspace(String(to.params.workspace)) ? true : "/workspace/emotion"),
|
||||
},
|
||||
{ path: "/system", name: "system", component: SystemManagementView },
|
||||
{
|
||||
path: "/market/:entityType/:identifier",
|
||||
name: "entity-detail",
|
||||
component: EntityDetailView,
|
||||
},
|
||||
{ path: "/:pathMatch(.*)*", redirect: "/workspace/emotion" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { onBeforeUnmount, onMounted } from "vue";
|
||||
import DialogHost from "../../shared/components/DialogHost.vue";
|
||||
import ToastHost from "../../shared/components/ToastHost.vue";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import DesktopSidebar from "./DesktopSidebar.vue";
|
||||
import MarketStrip from "./MarketStrip.vue";
|
||||
import MobileNav from "./MobileNav.vue";
|
||||
@@ -11,6 +12,7 @@ import StatusBar from "./StatusBar.vue";
|
||||
import TopBar from "./TopBar.vue";
|
||||
|
||||
const ui = useUiStore();
|
||||
const market = useMarketStore();
|
||||
|
||||
function globalShortcut(event: KeyboardEvent): void {
|
||||
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "k") return;
|
||||
@@ -22,7 +24,10 @@ function globalShortcut(event: KeyboardEvent): void {
|
||||
ui.openDialog("search");
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener("keydown", globalShortcut));
|
||||
onMounted(() => {
|
||||
window.addEventListener("keydown", globalShortcut);
|
||||
void market.load();
|
||||
});
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", globalShortcut));
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,28 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
|
||||
const expanded = ref(false);
|
||||
const cells = ["市场情绪", "涨停", "跌停", "炸板", "封板率", "两市成交", "数据日期"];
|
||||
const market = useMarketStore();
|
||||
const values = computed(() => market.summary?.values ?? {});
|
||||
const cells = computed(() => [
|
||||
["市场情绪", display("temperature")],
|
||||
["涨停", display("limit_up")],
|
||||
["跌停", display("limit_down")],
|
||||
["炸板", display("broken")],
|
||||
["封板率", percent("seal_rate")],
|
||||
["两市成交", amount("amount")],
|
||||
["数据日期", observedTime()],
|
||||
]);
|
||||
|
||||
function display(key: string): string {
|
||||
const value = values.value[key];
|
||||
return typeof value === "number" || typeof value === "string" ? String(value) : "";
|
||||
}
|
||||
|
||||
function percent(key: string): string {
|
||||
const value = values.value[key];
|
||||
return typeof value === "number" ? `${value.toFixed(1)}%` : "";
|
||||
}
|
||||
|
||||
function amount(key: string): string {
|
||||
const value = values.value[key];
|
||||
return typeof value === "number" ? `${(value / 100_000_000).toFixed(2)} 亿` : "";
|
||||
}
|
||||
|
||||
function observedTime(): string {
|
||||
const observed = market.summary?.context.observed_at;
|
||||
if (!observed) return "";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date(observed));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="market-strip" aria-label="市场摘要">
|
||||
<div class="market-strip-row">
|
||||
<span class="market-emotion"><span class="emotion-dot" aria-hidden="true"></span>市场情绪</span>
|
||||
<span>涨停</span>
|
||||
<span>跌停</span>
|
||||
<span>炸板</span>
|
||||
<span>封板率</span>
|
||||
<span>两市成交</span>
|
||||
<span class="faint">等待最新行情</span>
|
||||
<span>涨停 {{ display("limit_up") }}</span>
|
||||
<span>跌停 {{ display("limit_down") }}</span>
|
||||
<span>炸板 {{ display("broken") }}</span>
|
||||
<span>封板率 {{ percent("seal_rate") }}</span>
|
||||
<span>两市成交 {{ amount("amount") }}</span>
|
||||
<span class="faint">{{ market.loading ? "正在读取行情" : observedTime() || market.summary?.context.message || market.error }}</span>
|
||||
<button class="market-toggle" type="button" :aria-expanded="expanded" @click="expanded = !expanded">
|
||||
{{ expanded ? "收起详情" : "展开详情" }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="expanded" class="market-details">
|
||||
<div v-for="cell in cells" :key="cell" class="market-cell">
|
||||
<div class="market-cell-label">{{ cell }}</div>
|
||||
<div class="market-cell-value"></div>
|
||||
<div v-for="cell in cells" :key="cell[0]" class="market-cell">
|
||||
<div class="market-cell-label">{{ cell[0] }}</div>
|
||||
<div class="market-cell-value">{{ cell[1] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -17,6 +17,7 @@ const menuRoot = ref<HTMLElement | null>(null);
|
||||
|
||||
const title = computed(() => {
|
||||
if (route.name === "system") return "系统管理";
|
||||
if (route.name === "entity-detail") return "行情详情";
|
||||
return findWorkspace(String(route.params.workspace ?? "emotion"))?.title ?? "小白复盘";
|
||||
});
|
||||
|
||||
@@ -38,7 +39,7 @@ function changeDate(event: Event): void {
|
||||
!Number.isNaN(parsed.valueOf()) &&
|
||||
parsed.toISOString().slice(0, 10) === value
|
||||
) {
|
||||
market.selectedDate = value;
|
||||
void market.selectDate(value);
|
||||
return;
|
||||
}
|
||||
input.value = market.selectedDate;
|
||||
|
||||
@@ -16,6 +16,9 @@ const statuses = ref<CredentialStatus[]>([]);
|
||||
const values = reactive<Record<string, string>>({});
|
||||
const loading = ref(true);
|
||||
const errorMessage = ref("");
|
||||
const syncing = ref(false);
|
||||
const syncResult = ref("");
|
||||
const syncError = ref("");
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
@@ -45,6 +48,23 @@ async function save(name: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncReference(): Promise<void> {
|
||||
syncing.value = true;
|
||||
syncError.value = "";
|
||||
syncResult.value = "";
|
||||
try {
|
||||
const result = await api.post<{ calendar_days: number; entities: number }>(
|
||||
"/market/reference-sync",
|
||||
);
|
||||
syncResult.value = `已同步 ${result.calendar_days} 个日历日期、${result.entities} 个股票条目`;
|
||||
ui.showToast("基础行情资料已同步");
|
||||
} catch (error) {
|
||||
syncError.value = error instanceof Error ? error.message : "同步失败,请稍后重试。";
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
@@ -65,6 +85,17 @@ onMounted(load);
|
||||
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<header class="card-header"><h2>基础行情资料</h2><span class="faint">交易日历与股票目录</span></header>
|
||||
<div class="card-body disabled-row">
|
||||
<p class="muted">保存有效行情凭据后执行;同步结果用于日期判断和全局搜索。</p>
|
||||
<span v-if="syncResult" class="up">{{ syncResult }}</span>
|
||||
<span v-if="syncError" class="field-error" role="alert">{{ syncError }}</span>
|
||||
<button class="btn" type="button" :disabled="syncing" @click="syncReference">
|
||||
{{ syncing ? "正在同步" : "同步基础资料" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<header class="card-header"><h2>历史数据回补</h2><span class="tag">暂不可用</span></header>
|
||||
<div class="card-body disabled-row">
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import type { MarketEntity } from "../../shared/api/market";
|
||||
import MarketPreviewPanel from "../../shared/market/MarketPreviewPanel.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const entity = computed<MarketEntity>(() => ({
|
||||
entity_type: String(route.params.entityType) as MarketEntity["entity_type"],
|
||||
identifier: String(route.params.identifier),
|
||||
code: String(route.query.code ?? String(route.params.identifier).split(".")[0]),
|
||||
name: String(route.query.name ?? route.params.identifier),
|
||||
sector: route.query.sector ? String(route.query.sector) : null,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page-frame entity-detail-page">
|
||||
<header class="page-header entity-heading">
|
||||
<div><h1>{{ entity.name }}</h1><p class="page-subtitle">{{ entity.code }}<template v-if="entity.sector"> · {{ entity.sector }}</template></p></div>
|
||||
<span class="tag">最新真实行情</span>
|
||||
</header>
|
||||
<MarketPreviewPanel :entity="entity" class="card entity-chart" />
|
||||
</main>
|
||||
</template>
|
||||
Reference in New Issue
Block a user