rebuild(stage-5): establish market data gateway and charts

This commit is contained in:
leefer
2026-07-30 02:35:42 +08:00
parent 40ad5d6836
commit cf0ab7026f
45 changed files with 2701 additions and 46 deletions
@@ -1,19 +1,100 @@
<script setup lang="ts">
import { ref } from "vue";
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { marketApi, type MarketEntity, type SearchGroup } from "../api/market";
import MarketPreviewPanel from "../market/MarketPreviewPanel.vue";
import { useUiStore } from "../stores/ui";
const router = useRouter();
const ui = useUiStore();
const query = ref("");
const groups = ref<SearchGroup[]>([]);
const loading = ref(false);
const error = ref("");
const activeIndex = ref(0);
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
let sequence = 0;
const flatItems = computed(() => groups.value.flatMap((group) => group.items));
const active = computed(() => flatItems.value[activeIndex.value] ?? null);
watch(query, (value) => {
if (debounceTimer) clearTimeout(debounceTimer);
const normalized = value.trim();
if (!normalized) {
groups.value = [];
loading.value = false;
error.value = "";
return;
}
debounceTimer = setTimeout(() => void search(normalized), 160);
});
async function search(value: string): Promise<void> {
const current = ++sequence;
loading.value = true;
error.value = "";
try {
const response = await marketApi.search(value);
if (current === sequence) {
groups.value = response.groups;
activeIndex.value = 0;
}
} catch (reason) {
if (current === sequence) error.value = reason instanceof Error ? reason.message : "搜索失败";
} finally {
if (current === sequence) loading.value = false;
}
}
function select(item: MarketEntity): void {
ui.closeDialog();
void router.push({
name: "entity-detail",
params: { entityType: item.entity_type, identifier: item.identifier },
query: { code: item.code, name: item.name, sector: item.sector ?? undefined },
});
}
function keydown(event: KeyboardEvent): void {
if (!flatItems.value.length) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const offset = event.key === "ArrowDown" ? 1 : -1;
activeIndex.value = (activeIndex.value + offset + flatItems.value.length) % flatItems.value.length;
} else if (event.key === "Enter" && active.value) {
event.preventDefault();
select(active.value);
}
}
function itemIndex(item: MarketEntity): number {
return flatItems.value.findIndex((candidate) => candidate.identifier === item.identifier && candidate.entity_type === item.entity_type);
}
onBeforeUnmount(() => debounceTimer && clearTimeout(debounceTimer));
</script>
<template>
<div class="search-panel">
<div class="search-panel" @keydown="keydown">
<label class="sr-only" for="global-search">搜索股票板块题材或指数</label>
<input id="global-search" v-model="query" class="input search-input" autofocus placeholder="搜索股票、板块、题材或指数" />
<div class="search-categories" aria-label="搜索结果分类">
<span>股票</span><span>板块</span><span>题材</span><span>指数</span>
</div>
<div class="search-empty">
<p>{{ query ? "当前没有匹配结果" : "输入代码或名称开始搜索" }}</p>
<span>Ctrl+K</span>
<input id="global-search" v-model="query" class="input search-input" autofocus placeholder="搜索股票、板块、题材或指数" autocomplete="off" />
<div class="search-layout">
<div class="search-results" aria-live="polite">
<div v-if="!query" class="search-empty"><p>输入代码或名称开始搜索</p><span>Ctrl+K</span></div>
<div v-else-if="loading" class="search-empty"><p>正在搜索</p></div>
<div v-else-if="error" class="search-empty"><p>{{ error }}</p></div>
<div v-else-if="!flatItems.length" class="search-empty"><p>当前没有匹配结果</p></div>
<template v-else>
<section v-for="group in groups.filter((item) => item.items.length)" :key="group.entity_type" class="search-group">
<h3>{{ group.label }}</h3>
<button v-for="item in group.items" :key="item.identifier" type="button" class="search-result" :class="{ active: itemIndex(item) === activeIndex }" @mouseenter="activeIndex = itemIndex(item)" @focus="activeIndex = itemIndex(item)" @click="select(item)">
<span class="result-code">{{ item.code }}</span><strong>{{ item.name }}</strong><span>{{ item.sector }}</span>
</button>
</section>
</template>
</div>
<MarketPreviewPanel class="search-preview" :entity="active" />
</div>
</div>
</template>
+72
View File
@@ -0,0 +1,72 @@
import { api } from "./client";
export type TradeContext = {
requested_date: string;
actual_date: string | null;
previous_date: string | null;
observed_at: string | null;
state: "realtime" | "final" | "archive" | null;
carried_forward: boolean;
message: string;
};
export type MarketSummary = {
context: TradeContext;
values: Record<string, unknown> | null;
};
export type MarketEntity = {
entity_type: "stock" | "sector" | "theme" | "index";
identifier: string;
code: string;
name: string;
sector: string | null;
};
export type SearchGroup = {
entity_type: MarketEntity["entity_type"];
label: string;
items: MarketEntity[];
};
export type SearchResults = { query: string; groups: SearchGroup[] };
export type ChartPoint = {
time: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
amount: number;
average: number | null;
};
export type ChartSeries = {
entity_type: MarketEntity["entity_type"];
identifier: string;
code: string;
name: string;
interval: "day" | "minute";
trade_date: string;
observed_at: string;
previous_close: number | null;
range_start: string | null;
range_end: string | null;
points: ChartPoint[];
};
export const marketApi = {
summary(date?: string): Promise<MarketSummary> {
const query = date ? `?date=${encodeURIComponent(date)}` : "";
return api.get<MarketSummary>(`/market/summary${query}`);
},
search(query: string): Promise<SearchResults> {
return api.get<SearchResults>(`/market/search?q=${encodeURIComponent(query)}`);
},
chart(entity: MarketEntity, interval: "day" | "minute"): Promise<ChartSeries> {
const type = encodeURIComponent(entity.entity_type);
const identifier = encodeURIComponent(entity.identifier);
return api.get<ChartSeries>(`/market/entities/${type}/${identifier}/charts/${interval}`);
},
};
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed } from "vue";
import type { ChartSeries } from "../api/market";
const props = defineProps<{ series: ChartSeries }>();
const width = 720;
const height = 260;
const inset = 18;
const values = computed(() => props.series.points.flatMap((point) => [point.high, point.low]));
const minimum = computed(() => Math.min(...values.value));
const maximum = computed(() => Math.max(...values.value));
const span = computed(() => Math.max(maximum.value - minimum.value, maximum.value * 0.01, 0.01));
const step = computed(() => (width - inset * 2) / Math.max(props.series.points.length, 1));
const candleWidth = computed(() => Math.max(2, Math.min(9, step.value * 0.58)));
function x(index: number): number {
return inset + step.value * (index + 0.5);
}
function y(value: number): number {
return inset + ((maximum.value - value) / span.value) * (height - inset * 2);
}
const linePath = computed(() =>
props.series.points.map((point, index) => `${index ? "L" : "M"}${x(index)},${y(point.close)}`).join(" "),
);
const averagePath = computed(() =>
props.series.points
.filter((point) => point.average !== null)
.map((point, index) => `${index ? "L" : "M"}${x(index)},${y(point.average ?? point.close)}`)
.join(" "),
);
const zeroY = computed(() =>
props.series.previous_close === null ? null : y(props.series.previous_close),
);
</script>
<template>
<svg class="market-chart" :viewBox="`0 0 ${width} ${height}`" role="img" :aria-label="`${series.name}${series.interval === 'day' ? '日K' : '分时'}`">
<line v-if="series.interval === 'minute' && zeroY !== null" class="chart-zero" :x1="inset" :x2="width - inset" :y1="zeroY" :y2="zeroY" />
<template v-if="series.interval === 'day'">
<g v-for="(point, index) in series.points" :key="point.time" :class="point.close >= point.open ? 'candle-up' : 'candle-down'">
<line :x1="x(index)" :x2="x(index)" :y1="y(point.high)" :y2="y(Math.max(point.open, point.close))" />
<line :x1="x(index)" :x2="x(index)" :y1="y(Math.min(point.open, point.close))" :y2="y(point.low)" />
<rect :x="x(index) - candleWidth / 2" :y="y(Math.max(point.open, point.close))" :width="candleWidth" :height="Math.max(1, Math.abs(y(point.open) - y(point.close)))" />
</g>
</template>
<template v-else>
<path class="chart-price" :d="linePath" />
<path v-if="averagePath" class="chart-average" :d="averagePath" />
</template>
</svg>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { marketApi, type ChartSeries, type MarketEntity } from "../api/market";
import MarketChart from "./MarketChart.vue";
const props = defineProps<{ entity: MarketEntity | null }>();
const interval = ref<"day" | "minute">("day");
const series = ref<ChartSeries | null>(null);
const loading = ref(false);
const error = ref("");
let requestSequence = 0;
async function load(): Promise<void> {
const entity = props.entity;
if (!entity) {
series.value = null;
return;
}
const sequence = ++requestSequence;
loading.value = true;
error.value = "";
try {
const result = await marketApi.chart(entity, interval.value);
if (sequence === requestSequence) series.value = result;
} catch (reason) {
if (sequence === requestSequence) {
series.value = null;
error.value = reason instanceof Error ? reason.message : "行情读取失败";
}
} finally {
if (sequence === requestSequence) loading.value = false;
}
}
watch(() => props.entity?.identifier, () => {
interval.value = "day";
void load();
}, { immediate: true });
watch(interval, () => void load());
</script>
<template>
<section class="market-preview">
<header v-if="entity" class="preview-header">
<div><strong>{{ entity.name }}</strong><span>{{ entity.code }}</span></div>
<div class="seg-control" aria-label="行情周期">
<button type="button" :class="{ active: interval === 'day' }" @click="interval = 'day'">日K</button>
<button type="button" :class="{ active: interval === 'minute' }" @click="interval = 'minute'">分时</button>
</div>
</header>
<div v-if="loading" class="preview-state">正在读取真实行情</div>
<div v-else-if="error" class="preview-state">{{ error }}</div>
<MarketChart v-else-if="series" :series="series" />
<div v-else class="preview-state">选择一个结果查看最新行情</div>
<footer v-if="series" class="preview-meta">
数据日期 {{ series.trade_date }}<template v-if="series.interval === 'minute'"> · 分时范围固定 09:3015:00</template>
</footer>
</section>
</template>
+27 -5
View File
@@ -1,6 +1,8 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { marketApi, type MarketSummary } from "../api/market";
function shanghaiDate(): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
@@ -12,12 +14,32 @@ function shanghaiDate(): string {
export const useMarketStore = defineStore("market", () => {
const selectedDate = ref(shanghaiDate());
const summary = ref<MarketSummary | null>(null);
const loading = ref(false);
const error = ref("");
function moveDate(offset: number): void {
const date = new Date(`${selectedDate.value}T12:00:00+08:00`);
date.setUTCDate(date.getUTCDate() + offset);
selectedDate.value = date.toISOString().slice(0, 10);
async function load(date?: string): Promise<void> {
loading.value = true;
error.value = "";
try {
summary.value = await marketApi.summary(date);
selectedDate.value = summary.value.context.actual_date ?? summary.value.context.requested_date;
} catch (reason) {
error.value = reason instanceof Error ? reason.message : "行情摘要读取失败";
} finally {
loading.value = false;
}
}
return { selectedDate, moveDate };
async function selectDate(date: string): Promise<void> {
await load(date);
}
async function moveDate(offset: number): Promise<void> {
const date = new Date(`${selectedDate.value}T12:00:00+08:00`);
date.setUTCDate(date.getUTCDate() + offset);
await load(date.toISOString().slice(0, 10));
}
return { selectedDate, summary, loading, error, load, selectDate, moveDate };
});
+61 -15
View File
@@ -103,21 +103,6 @@
min-height: var(--s-44);
}
.search-categories {
display: flex;
gap: var(--s-8);
padding-bottom: var(--s-10);
border-bottom: var(--s-1) solid var(--color-divider);
}
.search-categories span {
padding: var(--s-4) var(--s-9);
border-radius: var(--tag-radius);
color: var(--color-text-secondary);
background: var(--color-surface-muted);
font-size: var(--font-11);
}
.search-empty {
min-height: var(--s-200);
display: grid;
@@ -128,6 +113,67 @@
font-size: var(--font-12);
}
.search-layout {
min-height: var(--s-320);
display: grid;
grid-template-columns: var(--s-320) minmax(0, 1fr);
gap: var(--s-12);
}
.search-results {
max-height: var(--s-400);
overflow-y: auto;
border-right: var(--s-1) solid var(--color-divider);
padding-right: var(--s-12);
}
.search-group + .search-group {
margin-top: var(--s-12);
}
.search-group h3 {
padding: var(--s-4) var(--s-8);
color: var(--color-text-faint);
font-size: var(--font-11);
font-weight: var(--weight-600);
}
.search-result {
width: 100%;
min-height: var(--s-36);
display: grid;
grid-template-columns: var(--s-64) minmax(0, 1fr) auto;
align-items: center;
gap: var(--s-8);
padding: var(--s-6) var(--s-8);
border-radius: var(--control-radius);
color: var(--color-text-secondary);
background: var(--c-transparent);
text-align: left;
}
.search-result:hover,
.search-result.active {
color: var(--color-text);
background: var(--color-primary-soft);
}
.search-result strong {
overflow: hidden;
color: var(--color-text);
font-size: var(--font-12-5);
text-overflow: ellipsis;
white-space: nowrap;
}
.search-result span {
font-size: var(--font-11);
}
.result-code {
font-variant-numeric: tabular-nums;
}
.search-empty span {
padding: var(--s-2) var(--s-6);
border: var(--s-1) solid var(--color-border);
+126
View File
@@ -0,0 +1,126 @@
.market-preview {
min-width: 0;
display: grid;
grid-template-rows: auto minmax(var(--s-260), 1fr) auto;
overflow: hidden;
background: var(--color-surface);
}
.preview-header {
min-height: var(--s-40);
display: flex;
align-items: center;
gap: var(--s-12);
padding: var(--s-8) var(--s-12);
border-bottom: var(--s-1) solid var(--color-divider);
}
.preview-header > div:first-child {
min-width: 0;
display: flex;
align-items: baseline;
gap: var(--s-8);
}
.preview-header strong {
font-size: var(--font-14);
}
.preview-header span,
.preview-meta {
color: var(--color-text-faint);
font-size: var(--font-11);
}
.seg-control {
display: inline-flex;
gap: var(--s-2);
margin-left: auto;
padding: var(--s-2);
border-radius: var(--control-radius);
background: var(--color-surface-muted);
}
.seg-control button {
min-height: var(--s-24);
padding: var(--s-4) var(--s-8);
border-radius: var(--radius-5);
color: var(--color-text-secondary);
background: var(--c-transparent);
font-size: var(--font-11);
}
.seg-control button.active {
color: var(--color-primary);
background: var(--color-surface);
}
.market-chart {
width: 100%;
height: 100%;
min-height: var(--s-260);
padding: var(--s-8);
background: var(--color-surface);
}
.candle-up,
.candle-down {
stroke-width: var(--s-1);
}
.candle-up {
fill: var(--color-surface);
stroke: var(--color-up);
}
.candle-down {
fill: var(--color-down);
stroke: var(--color-down);
}
.chart-price,
.chart-average {
fill: none;
stroke-width: var(--s-2);
}
.chart-price {
stroke: var(--color-primary);
}
.chart-average {
stroke: var(--color-warning);
}
.chart-zero {
stroke: var(--color-text-faint);
stroke-width: var(--s-1);
stroke-dasharray: var(--s-4) var(--s-4);
}
.preview-state {
min-height: var(--s-260);
display: grid;
place-items: center;
color: var(--color-text-secondary);
font-size: var(--font-12);
}
.preview-meta {
min-height: var(--s-30);
padding: var(--s-7) var(--s-12);
border-top: var(--s-1) solid var(--color-divider);
}
.entity-heading {
justify-content: space-between;
align-items: center;
}
.entity-heading h1 {
display: inline;
}
.entity-chart {
min-height: var(--s-400);
}
@@ -114,6 +114,20 @@
grid-template-columns: minmax(0, 1fr) var(--s-64) var(--s-64);
}
.search-layout {
grid-template-columns: 1fr;
}
.search-results {
max-height: none;
border-right: 0;
padding-right: 0;
}
.search-preview {
display: none;
}
.auth-view {
align-items: start;
padding-top: var(--s-34);