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>
|
||||
@@ -10,6 +10,7 @@ import "./shared/styles/components.css";
|
||||
import "./shared/styles/shell.css";
|
||||
import "./shared/styles/auth.css";
|
||||
import "./shared/styles/account.css";
|
||||
import "./shared/styles/market.css";
|
||||
import "./shared/styles/system.css";
|
||||
import "./shared/styles/mobile.css";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:30–15:00</template>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user