rebuild(audit): complete entity detail and market preview

This commit is contained in:
leefer
2026-07-30 09:25:52 +08:00
parent d1b3b977d2
commit 4fc8691eee
27 changed files with 946 additions and 35 deletions
@@ -1,12 +1,29 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute } from "vue-router";
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { MarketEntity } from "../../shared/api/market";
import { marketApi, type EntityDetail, type EntityMetric, type MarketEntity, type ThemeDetailData } from "../../shared/api/market";
import { reviewApi } from "../../shared/api/review";
import DataTable from "../../shared/components/DataTable.vue";
import EmptyState from "../../shared/components/EmptyState.vue";
import MarketPreviewPanel from "../../shared/market/MarketPreviewPanel.vue";
import { useMarketStore } from "../../shared/stores/market";
import { useUiStore } from "../../shared/stores/ui";
import StockNotes from "../../pages/review/StockNotes.vue";
const route = useRoute();
const router = useRouter();
const market = useMarketStore();
const ui = useUiStore();
const detail = ref<EntityDetail | null>(null);
const theme = ref<ThemeDetailData | null>(null);
const loading = ref(true);
const error = ref("");
const watchlisted = ref(false);
const watchBusy = ref(false);
const sortKey = ref("change");
const sortDirection = ref<"asc" | "desc">("desc");
const entity = computed<MarketEntity>(() => ({
entity_type: String(route.params.entityType) as MarketEntity["entity_type"],
identifier: String(route.params.identifier),
@@ -14,15 +31,153 @@ const entity = computed<MarketEntity>(() => ({
name: String(route.query.name ?? route.params.identifier),
sector: route.query.sector ? String(route.query.sector) : null,
}));
const resolvedEntity = computed(() => detail.value?.entity ?? entity.value);
const themeRows = computed(() => {
const rows = theme.value?.members ?? [];
const direction = sortDirection.value === "asc" ? 1 : -1;
return [...rows].sort((left, right) => compare(left[sortKey.value], right[sortKey.value]) * direction);
});
const themeColumns = [
{ key: "code", label: "代码", code: true, sortable: true },
{ key: "name", label: "股票", sortable: true },
{ key: "change", label: "涨跌幅(%", numeric: true, sortable: true, format: decimal },
{ key: "close", label: "收盘(元)", numeric: true, sortable: true, format: decimal },
{ key: "amount", label: "成交额(亿)", numeric: true, sortable: true, format: amountCell },
{ key: "quoted", label: "行情状态", format: (value: unknown) => value ? "正常交易" : "当日无行情" },
];
async function load(): Promise<void> {
loading.value = true;
error.value = "";
detail.value = null;
theme.value = null;
try {
const [marketDetail, review, themeDetail] = await Promise.all([
marketApi.detail(entity.value, market.selectedDate),
entity.value.entity_type === "stock" ? reviewApi.workspace(market.selectedDate) : null,
entity.value.entity_type === "theme" ? marketApi.themeDetail(entity.value.identifier, market.selectedDate) : null,
]);
detail.value = marketDetail;
theme.value = themeDetail;
watchlisted.value = Boolean(review?.watchlist.some((item) => item.identifier === entity.value.identifier));
} catch (reason) {
error.value = reason instanceof Error ? reason.message : "行情详情读取失败";
} finally {
loading.value = false;
}
}
async function toggleWatch(): Promise<void> {
if (watchBusy.value) return;
watchBusy.value = true;
try {
if (watchlisted.value) await reviewApi.removeWatch(resolvedEntity.value.identifier);
else await reviewApi.addWatch(resolvedEntity.value.identifier);
watchlisted.value = !watchlisted.value;
ui.showToast(watchlisted.value ? "已加入自选" : "已移出自选");
} catch (reason) {
ui.showToast(reason instanceof Error ? reason.message : "自选操作失败");
} finally {
watchBusy.value = false;
}
}
function openTrend(): void {
void router.push({ path: "/workspace/heaven", query: { mode: "trend", query: resolvedEntity.value.code } });
}
function sort(key: string): void {
if (sortKey.value === key) sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
else { sortKey.value = key; sortDirection.value = "desc"; }
}
function formatMetric(metric: EntityMetric): string {
if (metric.value === null || metric.value === undefined) return "";
if (metric.key === "amount") return amount(metric.value);
if (metric.key === "volume") return `${(metric.value / 10_000).toFixed(2)}`;
return `${metric.value.toFixed(2)} ${metric.unit}`.trim();
}
function money(value: number | null | undefined): string {
return value === null || value === undefined ? "" : `${(value * 100).toFixed(2)}`;
}
function decimal(value: unknown): string {
const parsed = Number(value); return Number.isFinite(parsed) ? parsed.toFixed(2) : "";
}
function amount(value: unknown): string {
const parsed = Number(value); return Number.isFinite(parsed) ? `${(parsed / 100_000_000).toFixed(2)} 亿` : "";
}
function amountCell(value: unknown): string {
const parsed = Number(value); return Number.isFinite(parsed) ? (parsed / 100_000_000).toFixed(2) : "";
}
function compare(left: unknown, right: unknown): number {
const a = Number(left); const b = Number(right);
if (Number.isFinite(a) && Number.isFinite(b)) return a - b;
return String(left ?? "").localeCompare(String(right ?? ""), "zh-CN");
}
watch([() => route.params.identifier, () => market.selectedDate], load, { immediate: true });
</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" />
<StockNotes v-if="entity.entity_type === 'stock'" :code="entity.code" :name="entity.name" />
<div v-if="loading" class="card workspace-state">正在读取完整行情详情</div>
<EmptyState v-else-if="error" class="card" title="行情详情暂不可用" :description="error" />
<template v-else-if="detail">
<header class="page-header entity-detail-heading">
<div>
<span class="entity-detail-code">{{ detail.entity.code }}</span>
<h1>{{ detail.entity.name }}</h1>
<span v-if="detail.entity.sector" class="tag">{{ detail.entity.sector }}</span>
</div>
<div class="entity-detail-price">
<strong class="numeric">{{ detail.price.toFixed(2) }}</strong>
<span v-if="detail.change !== null" class="numeric" :class="detail.change >= 0 ? 'up' : 'down'">{{ detail.change >= 0 ? '+' : '' }}{{ detail.change.toFixed(2) }}%</span>
<small>数据日期 {{ detail.trade_date }}</small>
</div>
<div v-if="detail.entity.entity_type === 'stock'" class="page-actions entity-detail-actions">
<button class="btn" type="button" :disabled="watchBusy" @click="toggleWatch">{{ watchlisted ? "移出自选" : "加入自选" }}</button>
<button class="btn btn-primary" type="button" @click="openTrend">进入观势</button>
</div>
</header>
<section class="entity-detail-grid">
<MarketPreviewPanel :entity="resolvedEntity" class="card entity-chart" />
<article class="card entity-metrics-card">
<header class="card-header"><h2>交易数据</h2><span class="faint">对应 {{ detail.trade_date }}</span></header>
<dl class="entity-metrics">
<div v-for="metric in detail.metrics" :key="metric.key"><dt>{{ metric.label }}</dt><dd class="numeric">{{ formatMetric(metric) }}</dd></div>
</dl>
</article>
</section>
<div v-if="detail.entity.entity_type === 'stock'" class="entity-stock-grid">
<article class="card entity-flow-card">
<header class="card-header"><h2>资金流</h2><span class="faint">同一正式数据口径</span></header>
<dl v-if="detail.money_flow?.available" class="entity-metrics entity-flow-metrics">
<div><dt>当日净流入</dt><dd class="numeric">{{ money(detail.money_flow.net_million) }}</dd></div>
<div><dt>大单净流入</dt><dd class="numeric">{{ money(detail.money_flow.large_million) }}</dd></div>
<div><dt>近5日净流入</dt><dd class="numeric">{{ money(detail.money_flow.net_5d_million) }}</dd></div>
<div><dt>近5日/流通市值</dt><dd class="numeric">{{ detail.money_flow.flow_to_circ_mv_5d === null ? "" : `${detail.money_flow.flow_to_circ_mv_5d.toFixed(4)}%` }}</dd></div>
</dl>
<EmptyState v-else title="资金流暂不可用" description="当前日期尚无达到覆盖标准的同口径资金流数据。" />
</article>
<article class="card entity-event-card">
<header class="card-header"><h2>事件逻辑</h2><span v-if="detail.event" class="tag">{{ detail.event.status }}</span></header>
<div v-if="detail.event" class="entity-event-body">
<strong>{{ detail.event.reason || `${detail.event.status}原因待补充` }}</strong>
<dl><div><dt>连板</dt><dd>{{ detail.event.streak ?? "" }}</dd></div><div><dt>首次</dt><dd>{{ detail.event.first_time ?? "" }}</dd></div><div><dt>最后</dt><dd>{{ detail.event.last_time ?? "" }}</dd></div><div><dt>开板</dt><dd>{{ detail.event.open_times ?? "" }}</dd></div></dl>
</div>
<EmptyState v-else title="当日无特殊事件" description="该股票当日不在涨停、炸板或跌停事件池中。" />
</article>
</div>
<section v-if="detail.entity.entity_type === 'theme'" class="card entity-members-card">
<header class="card-header"><h2>题材成分股</h2><span class="faint">{{ theme?.summary.quoted_count ?? 0 }} / {{ theme?.summary.member_count ?? 0 }}只有行情</span></header>
<DataTable v-if="themeRows.length" :columns="themeColumns" :rows="themeRows" :sort-key="sortKey" :sort-direction="sortDirection" @sort="sort" />
<EmptyState v-else title="暂无成分股" description="当前题材尚无可核验的成分股归档。" />
</section>
<StockNotes v-if="detail.entity.entity_type === 'stock'" :code="detail.entity.code" :name="detail.entity.name" />
</template>
</main>
</template>