rebuild(audit): complete entity detail and market preview
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user