Files
easy_tdx_max/web-ui/src/views/WatchlistView.vue
T
awayingsandClaude Code fb35677802 feat: 自选列表新增「近3日/近1周/近2周」涨跌幅三列(issue #7)
交易日偏移口径:T = 上证指数日线(交易日历)中 <= 今天的最后一天,
D_n = T 往前 n 个交易日,锚点 = 个股日线(/bars 同款 QFQ)中
date <= D_n 的最后一根 bar。后端只回锚点收盘价,涨跌幅由前端用
SSE 实时价现算,盘中三列随报价免费跳动、无需轮询本接口。

- 新增 GET /watchlist/returns + 纯计算模块 web/returns.py(零 IO,单测覆盖
  停牌回退/次新 null/除权日 QFQ/非交易日回退等口径)
- 个股日线与交易日历均进程内缓存(当日不变、次日失效),重复刷新零行情请求;
  日历缺今天(serve 盘前启动)时按 60s 间隔重取,避免 T 整体前移
- 取数并发 ≤ 4(TDX 防封红线);单只失败只落 error,不影响整表
- 前端 +3 列(着色复用 dirClass/fmtPctSigned),e2e 同步断言

与 issue 定稿的两处偏差(/simplify 收敛,"减少不必要的改动"):
- 删除 windows 查询参数:列名与窗口一一对应(issue 亦将"用户自定义窗口"
  列为 out of scope),固定 3/5/10
- 个股缓存由磁盘 JSON 改为进程内 dict:可观测行为不变(当日不重复请求),
  但 serve 重启后当天首次请求会重取一次

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-11 11:39:10 +08:00

417 lines
13 KiB
Vue

<script setup lang="ts">
// 自选行情:SSE 实时表格 + 行内迷你分时 + 一键加删 + 点击开个股对话框。
// 迷你分时按需懒加载(行可见时才拉 /minute,60s 重拉)——这里简化为
// 打开页面时批量拉前 N 只(80/批上限内),足够 MVP。
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
addWatchItem,
fetchMinute,
fetchQuotes,
fetchSymbolName,
fetchWatchlist,
fetchWatchlistReturns,
formatError,
removeWatchItem,
} from '../api'
import StockDialog from '../components/StockDialog.vue'
import BoardDialog from '../components/BoardDialog.vue'
import Sparkline from '../components/Sparkline.vue'
import { dirClass, fmt2, fmtAmount, fmtPctSigned, fmtVol } from '../format'
import { detectMarket } from '../market'
import { useQuoteStore } from '../stores/quotes'
import type { WatchItem, WatchReturnItem } from '../types'
const quoteStore = useQuoteStore()
// 近 N 交易日涨跌幅(交易日偏移口径,见后端 /watchlist/returns):列名与窗口一一对应,
// 窗口本身由后端 returns.DEFAULT_WINDOWS 固定,这里只负责标签与取值顺序。
const WINDOWS: ReadonlyArray<{ days: number; label: string }> = [
{ days: 3, label: '近3日' },
{ days: 5, label: '近1周' },
{ days: 10, label: '近2周' },
]
/** 板块指数(881/885/880 开头)走板块弹窗,其余走个股弹窗。 */
function isBoardCode(code: string): boolean {
return /^88\d/.test(code)
}
const items = ref<WatchItem[]>([])
const listError = ref('')
const adding = ref(false)
const addCode = ref('')
const addName = ref('')
// ── 列表加载 ────────────────────────────────────────────────────────────────
async function loadList() {
listError.value = ''
try {
const resp = await fetchWatchlist()
items.value = resp.items
loadSparks()
loadReturns()
restFallback()
fillMissingNames()
} catch (e) {
listError.value = formatError(e)
}
}
/** 给历史遗留的"只有代码没有名称"的自选项补中文名(幂等写回存储)。 */
async function fillMissingNames() {
const missing = items.value.filter((i) => !i.name)
for (const it of missing) {
const name = await fetchSymbolName(it.market, it.code)
if (name) {
it.name = name
try {
await addWatchItem(it.market, it.code, name)
} catch {
// 写回失败仅影响下次加载,静默
}
}
}
}
// ── 行情(SSE 快照 + REST 首次兜底) ────────────────────────────────────────
/** SSE 未覆盖时(自选刚加、服务重启间隙)用 REST 主动拉一次。
* 后端 /quotes 单次最多 80 只(通达信协议上限),超量需分批。 */
const QUOTE_BATCH = 80
async function restFallback() {
if (items.value.length === 0) return
const missing = items.value.filter((i) => !quoteStore.getQuote(i.symbol))
if (missing.length === 0) return
for (let i = 0; i < missing.length; i += QUOTE_BATCH) {
try {
await fetchQuotes(missing.slice(i, i + QUOTE_BATCH).map((it) => ({ market: it.market, code: it.code })))
} catch {
// SSE 会补上,静默(单批失败不阻断后续批次)
}
}
}
function q(item: WatchItem) {
return quoteStore.getQuote(item.symbol)
}
function pct(item: WatchItem): number | null {
const qq = q(item)
if (!qq?.price || !qq.pre_close) return null
return (qq.price / qq.pre_close - 1) * 100
}
// ── 近 N 交易日涨跌幅(锚点收盘价来自后端,涨跌幅在这里用实时价现算) ──────────
const returns = ref(new Map<string, WatchReturnItem>())
/** 拉一次锚点(后端按天缓存,盘中/重复刷新不重复请求行情)。 */
async function loadReturns() {
if (items.value.length === 0) {
returns.value = new Map()
return
}
try {
const resp = await fetchWatchlistReturns()
returns.value = new Map(Object.entries(resp.items))
} catch {
// 单只失败/整体失败都不影响其余列,静默(与 loadSparks 同语义)
}
}
function retItem(item: WatchItem): WatchReturnItem | undefined {
return returns.value.get(item.symbol)
}
/** 锚点日期(悬停提示用):近N日涨跌幅的基准 bar 实际日期。 */
function anchorDate(item: WatchItem, days: number): string {
const a = retItem(item)?.anchors?.find((x) => x.days === days)
return a?.date ? `锚点 ${a.date}` : '锚点不可用'
}
/** 近 N 交易日涨跌幅:优先 SSE 实时价,无报价时用后端 last_close 兜底。 */
function pctVs(item: WatchItem, days: number): number | null {
const r = retItem(item)
const anchor = r?.anchors?.find((a) => a.days === days)?.close
if (anchor == null || !(anchor > 0)) return null
const price = q(item)?.price ?? r?.last_close
if (price == null || !Number.isFinite(price) || price <= 0) return null
return (price / anchor - 1) * 100
}
/** 长期停牌(最后一根 bar 不在 T):标灰,避免误读成当日行情。 */
function isStale(item: WatchItem): boolean {
const r = retItem(item)
return !!r && !r.error && (r.stale_days ?? 0) > 0
}
// ── 迷你分时 ────────────────────────────────────────────────────────────────
const sparks = ref(new Map<string, number[]>())
const sparkBase = ref(new Map<string, number>())
async function loadSparks() {
const targets = items.value.slice(0, 80)
for (const it of targets) {
if (sparks.value.has(it.symbol)) continue
try {
const pts = await fetchMinute(it.market, it.code)
if (pts.length > 0) {
sparks.value.set(it.symbol, pts.map((p) => p.price))
const qq = q(it)
sparkBase.value.set(it.symbol, qq?.pre_close ?? pts[0].price)
}
} catch {
// 单只失败不影响整表
}
}
}
let sparkTimer = 0
onMounted(() => {
loadList()
sparkTimer = window.setInterval(loadSparks, 60_000)
})
onBeforeUnmount(() => window.clearInterval(sparkTimer))
// ── 加/删自选 ───────────────────────────────────────────────────────────────
async function add() {
const code = addCode.value.trim()
if (!/^\d{6}$/.test(code)) {
listError.value = '请输入 6 位数字代码'
return
}
const market = detectMarket(code)
adding.value = true
listError.value = ''
try {
// 名称优先级:用户备注 > 证券名称接口(五档行情协议本身不带名称)。
// 名称取不到不阻断(BJ 等市场 MAC 协议可能不支持),退回显示代码。
let name = addName.value.trim()
if (!name) name = await fetchSymbolName(market, code)
try {
const quotes = await fetchQuotes([{ market, code }])
const qq = quotes[0]
if (qq && qq.price == null) {
listError.value = `提示:${market}${code} 暂无行情返回,仍已加入自选`
} else if (qq && Number.isFinite(qq.vol) && qq.vol === 0 && qq.amount === 0) {
listError.value = `提示:${market}${code} 今日无成交(停牌或非交易日),仍已加入自选`
}
} catch {
listError.value = `提示:行情校验不可用,${market}${code} 仍已加入自选`
}
await addWatchItem(market, code, name)
addCode.value = ''
addName.value = ''
await loadList()
} catch (e) {
listError.value = formatError(e)
} finally {
adding.value = false
}
}
async function remove(item: WatchItem) {
listError.value = ''
try {
await removeWatchItem(item.market, item.code)
items.value = items.value.filter((i) => i.symbol !== item.symbol)
sparks.value.delete(item.symbol)
returns.value.delete(item.symbol)
} catch (e) {
listError.value = formatError(e)
}
}
// ── 弹窗(个股 / 板块分流) ─────────────────────────────────────────────────
const dialog = ref<WatchItem | null>(null)
const boardDlg = ref<WatchItem | null>(null)
function openItem(item: WatchItem) {
if (isBoardCode(item.code)) boardDlg.value = item
else dialog.value = item
}
const emptyHint = computed(() =>
items.value.length === 0 && !listError.value
? '自选为空:输入 6 位代码加入(如 600519)。加入后行情实时推送。'
: '',
)
</script>
<template>
<div class="wl">
<div class="toolbar">
<h2>自选行情</h2>
<div class="add-row">
<input v-model="addCode" class="code-input" placeholder="6 位代码" maxlength="6" @keyup.enter="add" />
<input v-model="addName" class="name-input" placeholder="备注名(可空)" maxlength="16" @keyup.enter="add" />
<button class="primary" :disabled="adding" @click="add">{{ adding ? '加入中…' : '加入自选' }}</button>
</div>
<span class="hint"> {{ items.length }} · 行情实时推送</span>
</div>
<div v-if="listError" class="err">{{ listError }}</div>
<div class="table-wrap">
<table class="qtable">
<thead>
<tr>
<th>名称</th>
<th>现价</th>
<th>涨跌幅</th>
<th v-for="w in WINDOWS" :key="w.days">{{ w.label }}</th>
<th>涨跌额</th>
<th>成交量</th>
<th>成交额</th>
<th>最高</th>
<th>最低</th>
<th>今开</th>
<th>昨收</th>
<th>分时</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-if="emptyHint" class="empty-row">
<td colspan="15">{{ emptyHint }}</td>
</tr>
<tr v-for="item in items" :key="item.symbol" class="data-row" @click="openItem(item)">
<td>
<div class="cell-name">{{ item.name || item.code }}</div>
<div class="cell-code mono dim">{{ item.symbol }}</div>
</td>
<td class="big" :class="dirClass(pct(item))">{{ fmt2(q(item)?.price) }}</td>
<td :class="dirClass(pct(item))">{{ fmtPctSigned(pct(item)) }}</td>
<td
v-for="w in WINDOWS"
:key="w.days"
:class="[dirClass(pctVs(item, w.days)), { dim: isStale(item) }]"
:title="anchorDate(item, w.days)"
>
{{ fmtPctSigned(pctVs(item, w.days)) }}
</td>
<td :class="dirClass(pct(item))">
{{ q(item)?.price && q(item)?.pre_close ? fmt2(q(item)!.price! - q(item)!.pre_close!) : '-' }}
</td>
<td>{{ fmtVol(q(item)?.vol) }}</td>
<td>{{ fmtAmount(q(item)?.amount) }}</td>
<td>{{ fmt2(q(item)?.high) }}</td>
<td>{{ fmt2(q(item)?.low) }}</td>
<td>{{ fmt2(q(item)?.open) }}</td>
<td class="dim">{{ fmt2(q(item)?.pre_close) }}</td>
<td>
<Sparkline :prices="sparks.get(item.symbol) ?? []" :base="sparkBase.get(item.symbol) ?? null" />
</td>
<td>
<button class="del" @click.stop="remove(item)"></button>
</td>
</tr>
</tbody>
</table>
</div>
<StockDialog
v-if="dialog"
:market="dialog.market"
:code="dialog.code"
:name="dialog.name"
@close="dialog = null"
@watchlist-changed="loadList"
/>
<BoardDialog
v-if="boardDlg"
:code="boardDlg.code"
:name="boardDlg.name || boardDlg.code"
@close="boardDlg = null"
@watchlist-changed="loadList"
/>
</div>
</template>
<style scoped>
.wl {
height: 100%;
display: flex;
flex-direction: column;
padding: 14px 16px;
gap: 10px;
}
.toolbar {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.toolbar h2 {
font-size: 16px;
}
.add-row {
display: flex;
gap: 8px;
}
.code-input {
width: 110px;
}
.name-input {
width: 140px;
}
.hint {
font-size: 12px;
color: var(--text-dim);
margin-left: auto;
}
.err {
color: var(--up);
font-size: 12px;
}
.table-wrap {
flex: 1;
overflow: auto;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.data-row {
cursor: pointer;
}
.data-row:hover {
background: var(--bg-elevated);
}
.cell-name {
font-weight: 600;
}
.cell-code {
font-size: 11px;
}
.big {
font-size: 14px;
font-weight: 700;
}
.empty-row td {
text-align: center;
color: var(--text-dim);
padding: 40px 0;
font-size: 13px;
}
.del {
padding: 1px 7px;
font-size: 11px;
color: var(--text-dim);
border: none;
background: transparent;
}
.del:hover {
color: var(--up);
}
.dim {
color: var(--text-dim);
}
</style>