rebuild(stage-8): deliver market insight workspaces

This commit is contained in:
leefer
2026-07-30 04:12:04 +08:00
parent a18e8e9d27
commit 976a5cac03
39 changed files with 3671 additions and 14 deletions
@@ -1,18 +1,23 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { marketApi, type MarketWorkspaceData } from "../../shared/api/market";
import { marketApi, type MarketInsightData, type MarketWorkspaceData } from "../../shared/api/market";
import EmptyState from "../../shared/components/EmptyState.vue";
import { useMarketStore } from "../../shared/stores/market";
import EmotionPage from "./emotion/EmotionPage.vue";
import LadderPage from "./structure/LadderPage.vue";
import RotationPage from "./structure/RotationPage.vue";
import AuctionPage from "./insights/AuctionPage.vue";
import DragonPage from "./insights/DragonPage.vue";
import PopularityPage from "./insights/PopularityPage.vue";
import ThemesPage from "./insights/ThemesPage.vue";
import PerformancePage from "./pools/PerformancePage.vue";
import PoolPage from "./pools/PoolPage.vue";
const props = defineProps<{ workspaceKey: string }>();
const market = useMarketStore();
const data = ref<MarketWorkspaceData | null>(null);
const data = ref<MarketWorkspaceData | MarketInsightData | null>(null);
const insightData = computed(() => data.value as MarketInsightData);
const loading = ref(false);
const error = ref("");
let sequence = 0;
@@ -22,7 +27,9 @@ async function load(): Promise<void> {
loading.value = true;
error.value = "";
try {
const result = await marketApi.workspace(props.workspaceKey, market.selectedDate);
const result = ["auction", "themes", "popularity", "dragon-list"].includes(props.workspaceKey)
? await marketApi.insight(props.workspaceKey, market.selectedDate)
: await marketApi.workspace(props.workspaceKey, market.selectedDate);
if (current === sequence) data.value = result;
} catch (reason) {
if (current === sequence) error.value = reason instanceof Error ? reason.message : "页面数据读取失败";
@@ -42,6 +49,10 @@ watch([() => props.workspaceKey, () => market.selectedDate], () => void load(),
<EmotionPage v-else-if="workspaceKey === 'emotion'" :data="data" />
<LadderPage v-else-if="workspaceKey === 'ladder'" :data="data" />
<RotationPage v-else-if="workspaceKey === 'rotation'" :data="data" />
<AuctionPage v-else-if="workspaceKey === 'auction'" :data="insightData" />
<ThemesPage v-else-if="workspaceKey === 'themes'" :data="insightData" />
<PopularityPage v-else-if="workspaceKey === 'popularity'" :data="insightData" />
<DragonPage v-else-if="workspaceKey === 'dragon-list'" :data="insightData" @refresh="load" />
<PerformancePage v-else-if="workspaceKey === 'performance'" :data="data" />
<PoolPage v-else :kind="workspaceKey" :data="data" />
</main>
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { MarketInsightData } from "../../../shared/api/market";
import DataTable from "../../../shared/components/DataTable.vue";
import EmptyState from "../../../shared/components/EmptyState.vue";
import { exportCsv, formatNumber, sortRows, type SortDirection } from "../../../shared/market/table";
const props = defineProps<{ data: MarketInsightData }>();
const dataset = ref<"focus" | "watchlist" | "all" | "one-price">("focus");
const expectation = ref("all");
const query = ref("");
const sortKey = ref("attention_score");
const sortDirection = ref<SortDirection>("desc");
const summary = computed(() => props.data.summary ?? {});
const phaseLabels = {
pending: "待开始",
observing: "观察中",
selection: "筛选确认",
finalized: "已归档",
archive: "历史归档",
};
const sources = computed(() => ({
focus: props.data.focus_rows ?? [],
watchlist: props.data.watchlist_rows ?? [],
all: props.data.rows ?? [],
"one-price": props.data.one_price_rows ?? [],
}));
const rows = computed(() => {
const normalized = query.value.trim().toLocaleLowerCase();
const selected = sources.value[dataset.value].filter((row) => {
if (expectation.value !== "all" && row.expectation !== expectation.value) return false;
if (!normalized) return true;
return [row.code, row.name, row.sector, row.source_label]
.some((value) => String(value ?? "").toLocaleLowerCase().includes(normalized));
});
return sortRows(selected, sortKey.value, sortDirection.value);
});
const columns = [
{ key: "code", label: "代码", code: true, sortable: true },
{ key: "name", label: "股票", sortable: true },
{ key: "source_label", label: "候选身份", wide: true, sortable: true },
{ key: "sector", label: "板块", sortable: true },
{ key: "expectation", label: "预期", sortable: true },
{ key: "change", label: "竞价涨幅(%", numeric: true, sortable: true, format: number },
{ key: "expected_change", label: "预期中枢(%", numeric: true, sortable: true, format: number },
{ key: "attention_score", label: "关注分", numeric: true, sortable: true, format: oneDecimal },
{ key: "volume_ratio", label: "量比", numeric: true, sortable: true, format: number },
{ key: "amount_million", label: "竞价额(百万)", numeric: true, sortable: true, format: number },
];
const maxAmount = computed(() => Math.max(1, ...(props.data.amount_history ?? []).map((row) => Number(row.amount_billion ?? 0))));
const fiveDayAverage = computed(() => {
const values = (props.data.amount_history ?? []).slice(-5).map((row) => Number(row.amount_billion ?? 0));
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
});
function sort(key: string): void {
if (sortKey.value === key) sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
else { sortKey.value = key; sortDirection.value = "desc"; }
}
function number(value: unknown): string { return formatNumber(value, 2); }
function oneDecimal(value: unknown): string { return formatNumber(value, 1); }
function download(): void {
const lines = [["代码", "股票", "身份", "板块", "预期", "竞价涨幅(%", "预期中枢(%", "关注分", "量比", "竞价额(百万)"]];
for (const row of rows.value) lines.push([
String(row.code ?? ""), String(row.name ?? ""), String(row.source_label ?? ""),
String(row.sector ?? ""), String(row.expectation ?? ""), String(row.change ?? ""),
String(row.expected_change ?? ""), String(row.attention_score ?? ""),
String(row.volume_ratio ?? ""), String(row.amount_million ?? ""),
]);
exportCsv(`集合竞价-${props.data.trade_date}.csv`, lines);
}
</script>
<template>
<header class="page-header market-page-header">
<div><h1>集合竞价中心</h1><p class="page-subtitle">{{ phaseLabels[data.phase ?? "archive"] }} · 数据日期 {{ data.trade_date }}</p></div>
<span class="tag" :class="{ warning: !data.current_available }">{{ data.message || `竞价覆盖 ${Number(data.coverage ?? 0) * 100}%` }}</span>
</header>
<section class="auction-layout">
<article class="card auction-main">
<div class="auction-dataset-bar">
<div class="seg-control auction-datasets">
<button type="button" :class="{ active: dataset === 'focus' }" @click="dataset = 'focus'">重点异动 <b>{{ data.focus_rows?.length ?? 0 }}</b></button>
<button type="button" :class="{ active: dataset === 'watchlist' }" @click="dataset = 'watchlist'">我的自选 <b>{{ data.watchlist_rows?.length ?? 0 }}</b></button>
<button type="button" :class="{ active: dataset === 'all' }" @click="dataset = 'all'">全部候选 <b>{{ data.rows?.length ?? 0 }}</b></button>
<button type="button" :class="{ active: dataset === 'one-price' }" @click="dataset = 'one-price'">竞价一字 <b>{{ data.one_price_rows?.length ?? 0 }}</b></button>
</div>
<dl class="auction-inline-summary">
<div><dt>竞价覆盖</dt><dd>{{ summary.stock_count ?? 0 }}</dd></div>
<div><dt>重点异动</dt><dd>{{ summary.focus_count ?? 0 }}</dd></div>
<div><dt>竞价一字</dt><dd>{{ summary.one_price_count ?? 0 }}</dd></div>
<div><dt>竞价成交额</dt><dd>{{ number(summary.amount_billion) }} 亿</dd></div>
</dl>
</div>
<div class="auction-toolbar">
<div class="seg-control">
<button v-for="item in [['all','全部'],['超预期','超预期'],['符合预期','符合预期'],['低于预期','低于预期']]" :key="item[0]" type="button" :class="{ active: expectation === item[0] }" @click="expectation = item[0]">{{ item[1] }}</button>
</div>
<label class="search-control"><span>搜索</span><input v-model="query" type="search" placeholder="代码、名称或板块"></label>
<button class="btn btn-small" type="button" @click="download">导出 CSV</button>
</div>
<DataTable v-if="rows.length" :columns="columns" :rows="rows" :sort-key="sortKey" :sort-direction="sortDirection" @sort="sort" />
<EmptyState v-else title="没有符合条件的竞价候选" :description="dataset === 'watchlist' ? '当前账号还没有可用的自选竞价结果。' : '调整预期筛选或搜索条件后再查看。'" />
</article>
<aside class="auction-side">
<article class="card"><header class="card-header"><h2>题材承接</h2><span class="faint">昨日强势方向</span></header><div class="auction-evidence-list"><div v-for="item in data.themes?.carry ?? []" :key="String(item.name)"><strong>{{ item.name }}</strong><span>{{ item.status }}</span><small>{{ item.matched_count }}只 · 中位 {{ item.median_change ?? '' }}%</small></div><p v-if="!data.themes?.carry?.length" class="faint">暂无可核验承接结果</p></div></article>
<article class="card"><header class="card-header"><h2>今日新线索</h2></header><div class="auction-evidence-list"><div v-for="item in data.themes?.new_themes ?? []" :key="String(item.name)"><strong>{{ item.name }}</strong><span>{{ item.stock_count }}只</span><small>{{ (item.leaders as string[])?.join('、') }}</small></div><p v-if="!data.themes?.new_themes?.length" class="faint">尚未形成新的聚集方向</p></div></article>
<article class="card auction-amount-card"><header class="card-header"><h2>竞价成交额对比</h2><span class="faint">5日均值 {{ fiveDayAverage.toFixed(2) }} 亿</span></header><div class="auction-bars"><div v-for="item in data.amount_history ?? []" :key="String(item.trade_date)"><span>{{ String(item.trade_date).slice(5) }}</span><i><b :style="{ width: `${Number(item.amount_billion ?? 0) / maxAmount * 100}%` }"></b></i><strong>{{ number(item.amount_billion) }}</strong></div></div></article>
</aside>
</section>
</template>
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { marketApi, type MarketInsightData } from "../../../shared/api/market";
import DataTable from "../../../shared/components/DataTable.vue";
import EmptyState from "../../../shared/components/EmptyState.vue";
import { formatNumber, sortRows, type SortDirection } from "../../../shared/market/table";
import { useSessionStore } from "../../../shared/stores/session";
import { useUiStore } from "../../../shared/stores/ui";
const props = defineProps<{ data: MarketInsightData }>();
const emit = defineEmits<{ refresh: [] }>();
const session = useSessionStore();
const ui = useUiStore();
const view = ref<"daily" | "profiles">("daily");
const filter = ref<"all" | "buy" | "sell" | "pending">("all");
const query = ref("");
const selectedTrader = ref("");
const selectedProfile = ref("");
const sortKey = ref("net_million");
const sortDirection = ref<SortDirection>("desc");
const aliases = reactive<Record<string, string>>({});
const savingSeat = ref("");
const traders = computed(() => props.data.traders ?? []);
const profiles = computed(() => props.data.profiles ?? []);
const profile = computed(() => profiles.value.find((item) => item.name === selectedProfile.value) ?? profiles.value[0]);
const operations = computed(() => {
const normalized = query.value.trim().toLocaleLowerCase();
const rows = (props.data.operations ?? []).filter((row) => {
if (selectedTrader.value && row.trader_name !== selectedTrader.value) return false;
if (filter.value === "buy" && Number(row.net_million ?? 0) <= 0) return false;
if (filter.value === "sell" && Number(row.net_million ?? 0) >= 0) return false;
if (filter.value === "pending" && row.recognized) return false;
if (!normalized) return true;
return [row.code, row.name, row.seat_name, row.trader_name, row.reason]
.some((value) => String(value ?? "").toLocaleLowerCase().includes(normalized));
});
return sortRows(rows, sortKey.value, sortDirection.value);
});
const columns = [
{ key: "code", label: "代码", code: true, sortable: true },
{ key: "name", label: "股票", sortable: true },
{ key: "direction", label: "方向", sortable: true },
{ key: "buy_million", label: "买入(百万)", numeric: true, sortable: true, format: number },
{ key: "sell_million", label: "卖出(百万)", numeric: true, sortable: true, format: number },
{ key: "net_million", label: "净额(百万)", numeric: true, sortable: true, format: number },
{ key: "trader_name", label: "游资", sortable: true },
{ key: "seat_name", label: "营业部", wide: true, sortable: true },
{ key: "reason", label: "上榜原因", wide: true },
];
watch(profiles, (items) => {
if (!selectedProfile.value && items[0]) selectedProfile.value = String(items[0].name ?? "");
}, { immediate: true });
function sort(key: string): void {
if (sortKey.value === key) sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
else { sortKey.value = key; sortDirection.value = "desc"; }
}
function number(value: unknown): string { return formatNumber(value, 2); }
async function saveAlias(seat: string): Promise<void> {
const alias = aliases[seat]?.trim();
if (!alias) return;
savingSeat.value = seat;
try {
await marketApi.saveSeatAlias(seat, alias);
ui.showToast("席位归类已保存");
emit("refresh");
} catch (reason) {
ui.showToast(reason instanceof Error ? reason.message : "席位归类保存失败");
} finally {
savingSeat.value = "";
}
}
</script>
<template>
<header class="page-header market-page-header">
<div><h1>龙虎榜</h1><p class="page-subtitle">上榜明细与活跃席位 · 数据日期 {{ data.trade_date }}</p></div>
<div class="page-actions"><div class="seg-control"><button type="button" :class="{ active: view === 'daily' }" @click="view = 'daily'">每日明细</button><button type="button" :class="{ active: view === 'profiles' }" @click="view = 'profiles'">游资档案</button></div></div>
</header>
<template v-if="view === 'daily'">
<section class="dragon-summary-grid">
<article class="card"><span>上榜股票</span><strong>{{ data.summary.official_stock_count ?? 0 }}</strong></article>
<article class="card"><span>活跃游资</span><strong>{{ data.summary.trader_count ?? 0 }}</strong></article>
<article class="card"><span>操作明细</span><strong>{{ data.summary.operation_count ?? 0 }}</strong></article>
<article class="card"><span>待归类席位</span><strong>{{ data.summary.unclassified_count ?? 0 }}</strong></article>
<article class="card"><span>席位净额</span><strong>{{ number(data.summary.net_million) }} 百万</strong></article>
</section>
<div v-if="data.message" class="notice" :class="data.status === 'unavailable' ? 'notice-warning' : ''">{{ data.message }}<span v-if="data.previous_date"> · 可切换至 {{ data.previous_date }} 查看</span></div>
<section v-if="data.status !== 'empty' && data.status !== 'detail_missing' && data.status !== 'unavailable'" class="dragon-daily-layout">
<aside class="card dragon-traders">
<header class="card-header"><h2>活跃游资</h2><button v-if="selectedTrader" class="btn btn-small" type="button" @click="selectedTrader = ''">显示全部</button></header>
<div class="dragon-trader-list"><button v-for="trader in traders" :key="String(trader.name)" type="button" :class="{ active: selectedTrader === trader.name }" @click="selectedTrader = String(trader.name)"><span><strong>{{ trader.name }}</strong><small>{{ trader.description || '暂无简介' }}</small></span><b :class="Number(trader.net_million) >= 0 ? 'up' : 'down'">{{ number(trader.net_million) }}</b></button><p v-if="!traders.length" class="faint">当前没有已识别游资</p></div>
<section v-if="data.unclassified_seats?.length" class="dragon-pending"><header><strong>待归类营业部</strong><span>{{ data.unclassified_seats.length }}个</span></header><div v-for="seat in data.unclassified_seats" :key="String(seat.seat_name)"><p><span>{{ seat.seat_name }}</span><b>{{ number(seat.net_million) }}</b></p><form v-if="session.isAdmin" @submit.prevent="saveAlias(String(seat.seat_name))"><input v-model="aliases[String(seat.seat_name)]" type="text" placeholder="归类为游资名称"><button class="btn btn-small" type="submit" :disabled="savingSeat === seat.seat_name">保存</button></form></div></section>
</aside>
<article class="card dragon-operations">
<header class="card-header"><h2>当日操作明细</h2><span class="faint">{{ operations.length }}</span></header>
<div class="dragon-toolbar"><div class="seg-control"><button type="button" :class="{ active: filter === 'all' }" @click="filter = 'all'">全部</button><button type="button" :class="{ active: filter === 'buy' }" @click="filter = 'buy'">净买入</button><button type="button" :class="{ active: filter === 'sell' }" @click="filter = 'sell'">净卖出</button><button type="button" :class="{ active: filter === 'pending' }" @click="filter = 'pending'">待归类</button></div><label class="search-control"><span>搜索</span><input v-model="query" type="search" placeholder="代码、股票、游资或营业部"></label></div>
<div class="dragon-table-scroll"><DataTable v-if="operations.length" :columns="columns" :rows="operations" :sort-key="sortKey" :sort-direction="sortDirection" @sort="sort" /><EmptyState v-else title="暂无符合条件的操作明细" description="调整筛选条件或选择其他活跃游资。" /></div>
</article>
</section>
<EmptyState v-else class="card" :title="data.status === 'empty' ? '当日没有股票上榜' : data.status === 'detail_missing' ? '席位明细尚未返回' : '龙虎榜数据暂不可用'" :description="data.message" />
</template>
<section v-else class="card dragon-profiles">
<aside><header class="card-header"><h2>游资名录</h2><span class="faint">{{ profiles.length }}</span></header><div><button v-for="item in profiles" :key="String(item.name)" type="button" :class="{ active: profile?.name === item.name }" @click="selectedProfile = String(item.name)"><strong>{{ item.name }}</strong><small>{{ item.organization_count }}个关联营业部</small></button></div></aside>
<article v-if="profile"><header><div><h2>{{ profile.name }}</h2><p>{{ profile.description || '暂无公开简介' }}</p></div><span class="tag">{{ profile.organization_count }}个营业部</span></header><section><h3>关联营业部</h3><ul><li v-for="organization in profile.organizations as string[]" :key="organization">{{ organization }}</li></ul></section><p class="notice">档案统计以当前可用名录和已归类席位为准覆盖范围会随归档累积</p></article>
<EmptyState v-else title="游资名录暂不可用" description="当前没有可核验的游资档案。" />
</section>
</template>
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { MarketInsightData } from "../../../shared/api/market";
import DataTable from "../../../shared/components/DataTable.vue";
import EmptyState from "../../../shared/components/EmptyState.vue";
import { formatNumber, sortRows, type SortDirection } from "../../../shared/market/table";
const props = defineProps<{ data: MarketInsightData }>();
const source = ref<"combined" | "ths" | "dc">("combined");
const query = ref("");
const sortKey = ref("rank");
const sortDirection = ref<SortDirection>("asc");
const sourceRows = computed(() => props.data[source.value] ?? []);
const rows = computed(() => {
const normalized = query.value.trim().toLocaleLowerCase();
const filtered = sourceRows.value.filter((row) => !normalized || [row.code, row.name, ...(row.concepts as unknown[] ?? [])]
.some((value) => String(value ?? "").toLocaleLowerCase().includes(normalized)));
return sortRows(filtered, sortKey.value, sortDirection.value);
});
const columns = [
{ key: "rank", label: "排名", numeric: true, sortable: true },
{ key: "code", label: "代码", code: true, sortable: true },
{ key: "name", label: "股票", sortable: true },
{ key: "change", label: "涨跌幅(%", numeric: true, sortable: true, format: number },
{ key: "ths_rank", label: "同花顺排名", numeric: true, sortable: true },
{ key: "dc_rank", label: "东方财富排名", numeric: true, sortable: true },
{ key: "rank_change", label: "排名变化", numeric: true, sortable: true, format: signed },
{ key: "concepts", label: "相关题材", wide: true, format: concepts },
{ key: "reason", label: "上榜线索", wide: true },
];
const topThs = computed(() => (props.data.ths ?? []).slice(0, 3));
const topDc = computed(() => (props.data.dc ?? []).slice(0, 3));
const consensus = computed(() => (props.data.combined ?? []).filter((row) => row.dual_source).slice(0, 3));
function sort(key: string): void {
if (sortKey.value === key) sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
else { sortKey.value = key; sortDirection.value = key === "rank" ? "asc" : "desc"; }
}
function number(value: unknown): string { return formatNumber(value, 2); }
function signed(value: unknown): string {
const text = formatNumber(value, 0);
return text && Number(value) > 0 ? `+${text}` : text;
}
function concepts(value: unknown): string { return Array.isArray(value) ? value.join(" · ") : ""; }
</script>
<template>
<header class="page-header market-page-header">
<div><h1>人气热榜</h1><p class="page-subtitle">双平台人气与共识 · 榜单日期 {{ data.trade_date }}</p></div>
<span v-if="data.message" class="tag warning">{{ data.message }}</span>
</header>
<section class="popularity-summary-strip">
<article><header><span>同花顺热度 Top3</span><strong>{{ data.summary.ths_count ?? 0 }}</strong></header><p>{{ topThs.map((item) => item.name).join(' · ') || '暂无榜单' }}</p></article>
<article><header><span>东方财富热度 Top3</span><strong>{{ data.summary.dc_count ?? 0 }}</strong></header><p>{{ topDc.map((item) => item.name).join(' · ') || '暂无榜单' }}</p></article>
<article><header><span>双榜共识</span><strong>{{ data.summary.dual_count ?? 0 }}</strong></header><p>{{ consensus.map((item) => item.name).join(' · ') || '暂无共识' }}</p></article>
</section>
<section class="card popularity-table-card">
<div class="popularity-toolbar">
<div class="seg-control"><button type="button" :class="{ active: source === 'combined' }" @click="source = 'combined'">双榜综合</button><button type="button" :class="{ active: source === 'ths' }" @click="source = 'ths'">同花顺</button><button type="button" :class="{ active: source === 'dc' }" @click="source = 'dc'">东方财富</button></div>
<label class="search-control"><span>搜索</span><input v-model="query" type="search" placeholder="代码、名称或题材"></label>
</div>
<DataTable v-if="rows.length" :columns="columns" :rows="rows" :sort-key="sortKey" :sort-direction="sortDirection" @sort="sort" />
<EmptyState v-else title="暂无人气榜结果" description="当日榜单尚未生成时,系统会显示最近有效榜单并标注真实日期。" />
</section>
</template>
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { marketApi, type MarketEntity, type MarketInsightData, type ThemeDetailData } from "../../../shared/api/market";
import DataTable from "../../../shared/components/DataTable.vue";
import EmptyState from "../../../shared/components/EmptyState.vue";
import MarketPreviewPanel from "../../../shared/market/MarketPreviewPanel.vue";
import { formatAmount, formatNumber, sortRows, type SortDirection } from "../../../shared/market/table";
const props = defineProps<{ data: MarketInsightData }>();
const query = ref("");
const selectedCode = ref("");
const detail = ref<ThemeDetailData | null>(null);
const loading = ref(false);
const error = ref("");
const preview = ref<MarketEntity | null>(null);
const sortKey = ref("change");
const sortDirection = ref<SortDirection>("desc");
let requestSequence = 0;
const themes = computed(() => {
const normalized = query.value.trim().toLocaleLowerCase();
return (props.data.items ?? []).filter((item) => !normalized || [item.code, item.name]
.some((value) => String(value ?? "").toLocaleLowerCase().includes(normalized)));
});
const members = computed(() => sortRows(detail.value?.members ?? [], sortKey.value, sortDirection.value));
const columns = [
{ key: "code", label: "代码", code: true, sortable: true },
{ key: "name", label: "股票", sortable: true },
{ key: "change", label: "涨跌幅(%", numeric: true, sortable: true, format: number },
{ key: "close", label: "收盘价(元)", numeric: true, sortable: true, format: number },
{ key: "amount", label: "成交额", numeric: true, sortable: true, format: amount },
{ key: "quoted", label: "行情状态", sortable: true, format: quoteState },
];
watch(() => props.data.trade_date, () => {
const first = props.data.items?.[0];
if (first) void selectTheme(first);
}, { immediate: true });
async function selectTheme(theme: Record<string, unknown>): Promise<void> {
const code = String(theme.code ?? "");
if (!code) return;
selectedCode.value = code;
const sequence = ++requestSequence;
loading.value = true;
error.value = "";
try {
const result = await marketApi.themeDetail(code, props.data.trade_date ?? props.data.requested_date);
if (sequence === requestSequence) detail.value = result;
} catch (reason) {
if (sequence === requestSequence) {
detail.value = null;
error.value = reason instanceof Error ? reason.message : "题材成分股读取失败";
}
} finally {
if (sequence === requestSequence) loading.value = false;
}
}
function showPreview(theme: Record<string, unknown>): void {
const identifier = String(theme.code ?? "");
preview.value = identifier ? {
entity_type: "theme",
identifier,
code: identifier.split(".")[0] ?? identifier,
name: String(theme.name ?? ""),
sector: null,
} : null;
}
function sort(key: string): void {
if (sortKey.value === key) sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
else { sortKey.value = key; sortDirection.value = "desc"; }
}
function number(value: unknown): string { return formatNumber(value, 2); }
function amount(value: unknown): string { return formatAmount(value); }
function quoteState(value: unknown): string { return value ? "正常交易" : "当日无行情"; }
</script>
<template>
<header class="page-header market-page-header">
<div><h1>题材库</h1><p class="page-subtitle">题材排行与成分行情 · 数据日期 {{ data.trade_date }}</p></div>
<span v-if="data.message" class="tag warning">{{ data.message }}</span>
</header>
<section class="themes-layout">
<article class="card theme-directory">
<header class="card-header"><h2>题材排行</h2><span class="faint">{{ data.summary.theme_count ?? 0 }}个题材</span></header>
<label class="search-control theme-search"><span>搜索</span><input v-model="query" type="search" placeholder="题材名称或代码"></label>
<div class="theme-rank-list">
<button v-for="(theme, index) in themes" :key="String(theme.code)" type="button" :class="{ active: selectedCode === theme.code }" @click="selectTheme(theme)" @mouseenter="showPreview(theme)" @mouseleave="preview = null">
<b>{{ index + 1 }}</b><span><strong>{{ theme.name }}</strong><small>{{ theme.code }}</small></span><em :class="{ up: Number(theme.change) > 0, down: Number(theme.change) < 0 }">{{ theme.change === null ? '' : `${number(theme.change)}%` }}</em>
</button>
</div>
<div v-if="preview" class="theme-preview-popover"><MarketPreviewPanel :entity="preview" /></div>
</article>
<article class="card theme-detail-card">
<header class="card-header"><h2>{{ detail?.theme.name || "题材基础行情" }}</h2><span class="faint">{{ detail?.theme.code || "选择左侧题材" }}</span></header>
<div v-if="detail" class="theme-summary-strip">
<div><span>成分股</span><strong>{{ detail.summary.member_count ?? 0 }}</strong></div>
<div><span>有行情</span><strong>{{ detail.summary.quoted_count ?? 0 }}</strong></div>
<div><span>上涨</span><strong class="up">{{ detail.summary.up_count ?? 0 }}</strong></div>
<div><span>下跌</span><strong class="down">{{ detail.summary.down_count ?? 0 }}</strong></div>
<div><span>换手率</span><strong>{{ number(detail.summary.turnover_rate) }}%</strong></div>
</div>
<div v-if="loading" class="workspace-state">正在核验题材成分行情</div>
<EmptyState v-else-if="error" title="题材成分暂不可用" :description="error" />
<DataTable v-else-if="members.length" :columns="columns" :rows="members" :sort-key="sortKey" :sort-direction="sortDirection" @sort="sort" />
<EmptyState v-else title="暂无题材成分数据" :description="detail?.message || '选择左侧题材后显示成分股。'" />
</article>
</section>
</template>