tradingSystem/app/web/static/index.html

1453 lines
89 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PMS 持仓管理系统</title>
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css"/>
<link rel="stylesheet" href="https://unpkg.com/element-plus/theme-chalk/dark/css-vars.css"/>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<style>
body { margin:0; background:#0f0f0f; color:#ccc; font-family:'Inter',system-ui,sans-serif; }
.app { padding:12px 18px 32px; }
.hd { display:flex; align-items:center; gap:14px; flex-wrap:wrap;
padding:10px 14px; background:#1a1a1a; border:1px solid #333; border-radius:8px; }
.hd h1 { font-size:17px; margin:0; color:#eee; font-weight:600; letter-spacing:.5px; }
.hd .ver { color:#666; font-size:12px; }
.spacer { flex:1; }
.metrics { display:flex; gap:10px; flex-wrap:wrap; margin:12px 0; }
.m { background:#1a1a1a; border:1px solid #333; border-radius:8px; padding:10px 14px; min-width:118px; }
.m .k { color:#888; font-size:12px; }
.m .v { color:#eee; font-size:18px; font-weight:600; margin-top:3px; }
.m .v.up { color:#f56c6c; } .m .v.down { color:#67c23a; }
.panel { background:#1a1a1a; border:1px solid #333; border-radius:8px; padding:14px; margin-bottom:14px; }
.panel h3 { margin:0 0 10px; font-size:14px; color:#409EFF; font-weight:600; }
.muted { color:#777; font-size:12px; }
.row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.banner { margin:10px 0; }
.el-table { background:transparent !important; font-size:13px; }
.tier { font-size:12px; color:#909399; }
.mono { font-family:ui-monospace,Menlo,Consolas,monospace; }
.nowrap { white-space:nowrap; }
pre.json { background:#111; border:1px solid #333; border-radius:6px; padding:10px;
max-height:420px; overflow:auto; font-size:12px; color:#bbb; }
.tcard{background:#161616;border:1px solid #333;border-radius:8px;padding:10px 12px;margin-bottom:10px;}
.clr-up{color:#f56c6c;} .clr-down{color:#67c23a;}
.attn{color:#e6a23c;padding:3px 0;}
.tblk h3{margin:0 0 10px;font-size:14px;color:#409EFF;font-weight:600;}
.op-row{display:flex;align-items:flex-start;gap:12px;padding:6px 0;border-bottom:1px solid #262626;}
.op-row:last-child{border-bottom:none;}
.op-btn{flex:none;width:150px;}
.op-btn .el-button{width:100%;}
.op-row .d{color:#999;font-size:12px;line-height:1.5;padding-top:3px;}
</style>
</head>
<body>
<div id="app" class="app" v-cloak>
<div class="hd">
<h1>PMS 持仓管理系统</h1>
<span class="ver">v{{ health.version || '-' }} · {{ health.now || '' }}
<el-tag v-if="health.trade_day" size="small" type="success" effect="dark">交易日</el-tag>
<el-tag v-else size="small" type="info" effect="dark">非交易日</el-tag>
</span>
<el-button-group style="margin-left:8px">
<el-button size="small" :type="mode==='trader'?'primary':''" @click="mode='trader'">交易员</el-button>
<el-button size="small" :type="mode==='ops'?'primary':''" @click="mode='ops'">运维</el-button>
</el-button-group>
<div class="spacer"></div>
<el-tag :type="ov.buy_halt ? 'danger':'info'" effect="dark" size="small">
买入 {{ ov.buy_halt ? '已暂停':'正常' }}</el-tag>
<el-tag :type="ov.exec_halt ? 'danger':'info'" effect="dark" size="small">
执行 {{ ov.exec_halt ? '已暂停':'正常' }}</el-tag>
<el-tag type="warning" effect="dark" size="small">自主档位 {{ tx('autonomy', ov.autonomy) }}</el-tag>
<el-tag :type="dm.shadow ? 'info':'success'" effect="dark" size="small" v-show="mode==='ops'">
下发 {{ dm.mode || '-' }}</el-tag>
<el-tag :type="planStatus.ok===false ? 'danger':'success'" effect="dark" size="small">
计划 {{ planStatus.ok===false ? '不可用' : (planStatus.date || '-') }}</el-tag>
<el-button size="small" @click="openPlan">上游计划</el-button>
<el-button size="small" @click="openReport">日报</el-button>
<el-button size="small" v-show="mode==='ops'" @click="opsDrawer=true">运维</el-button>
<el-button size="small" type="primary" :loading="loading" @click="loadAll">刷新</el-button>
</div>
<el-alert v-if="err" class="banner" :title="err" type="error" effect="dark" show-icon
@close="err=''"></el-alert>
<el-alert v-if="planStatus.ok===false" class="banner" type="error" effect="dark" show-icon
:closable="false"
:title="'上游选股计划不可用 —— 升仓/建仓类命令将无票可选: ' + (planStatus.hint||'')">
</el-alert>
<el-alert v-if="ov.sector_ready===false" class="banner" type="warning" effect="dark" show-icon
:closable="false"
title="行业划分数据源未配置 —— 行业集中度硬拦截已停用, 行业类命令置灰 (设计 §5)"></el-alert>
<el-alert v-if="ov.price_missing && ov.price_missing.length" class="banner" type="warning"
effect="dark" show-icon :closable="false"
:title="'以下持仓取不到实时价, 已用摊薄成本兜底: ' + ov.price_missing.join(', ')"></el-alert>
<el-alert v-if="health.calendar_degraded" class="banner" type="warning" effect="dark" show-icon
:closable="false" title="交易日历降级: 未安装 chinesecalendar, 节假日不可辨"></el-alert>
<el-alert v-if="dm.shadow" class="banner" type="info" effect="dark" show-icon :closable="false"
:title="'影子运行中 —— ' + (dm.hint || '')"></el-alert>
<el-alert v-if="scaleGap" class="banner" type="warning" effect="dark" show-icon :closable="false"
:title="'总规模与账户不符 —— 参数 ' + money(ov.scale) + ' / 账户总资产 ' + money(ov.total_asset) + ' (' + pct(scaleGap) + ')。仓位上限按参数算、资金校验按账户算, 差得远时方案会过闸却下不出去'"></el-alert>
<!-- ==================== 交易员视图 ==================== -->
<div v-show="mode==='trader'">
<!-- 1) 今日一览 + 需要注意 -->
<div class="metrics">
<div class="m"><div class="k">账户总资产</div><div class="v">{{ money(ov.total_asset) }}</div></div>
<div class="m"><div class="k">可用资金<span class="muted" v-if="ov.cash_source!=='ws'"> (估算)</span></div>
<div class="v" :class="ov.cash_source==='ws'?'':'muted'">{{ money(ov.cash_source==='ws' ? ov.cash_avail : ov.cash_est) }}</div></div>
<div class="m"><div class="k">持仓市值</div><div class="v">{{ money(ov.portfolio_mv) }}</div></div>
<div class="m"><div class="k">仓位</div><div class="v">{{ pct(ov.portfolio_pct) }}<span class="muted"> / {{ pct(ov.portfolio_cap) }}</span></div></div>
<div class="m"><div class="k">浮动盈亏</div>
<div class="v" :class="(ov.float_pnl||0)>=0?'up':'down'">{{ money(ov.float_pnl) }}<span style="font-size:13px"> {{ ov.float_pnl_pct==null?'':pct(ov.float_pnl_pct) }}</span></div></div>
<div class="m"><div class="k">持仓数</div><div class="v">{{ ov.names_count }}<span class="muted"> / {{ ov.max_names }}</span></div></div>
<div class="m" style="cursor:pointer" @click="scrollTo('t-prop')"><div class="k">等我拍板</div>
<div class="v" :class="proposals.length?'up':''">{{ proposals.length }} 条</div></div>
<div class="m"><div class="k">自主档位</div><div class="v" style="font-size:15px">{{ tx('autonomy', ov.autonomy) }}</div></div>
</div>
<div class="panel" v-if="attention.length" style="border-color:#7a5b1e">
<h3 style="color:#e6a23c">需要注意</h3>
<div v-for="(a,i) in attention" :key="i" class="attn">· {{ a }}</div>
</div>
<!-- 2) 等我拍板 -->
<div class="panel tblk" id="t-prop">
<h3>等我拍板 · {{ proposals.length }} 条</h3>
<div class="muted" v-if="!proposals.length">暂无需要你确认的提议。</div>
<div v-for="p in proposals" :key="p.proposal_id" class="tcard">
<div class="row" style="justify-content:space-between">
<div><b style="font-size:15px">{{ nm(p.ts_code) }}</b> <span class="muted mono">{{ p.ts_code }}</span>
<el-tag size="small" effect="dark" type="primary" style="margin-left:8px">{{ tx('action', p.action) }}</el-tag></div>
<div class="muted">{{ p.expire_at }} 前有效</div>
</div>
<div style="margin:6px 0" v-if="p.judge_reason"><span class="muted">研判:</span>{{ p.judge_reason }}</div>
<div class="muted" style="margin:4px 0">{{ propWhy(p) }}</div>
<div class="row" style="margin-top:6px">
<span class="muted">约 {{ p.qty }} 股 · 参考价 {{ (p.hard_numbers||{}).price==null ? '—' : (p.hard_numbers||{}).price }}
· 约需 {{ money(((p.hard_numbers||{}).price||0)*(p.qty||0)) }}</span>
<div class="spacer"></div>
<el-button type="success" size="small" @click="decide(p,'ACCEPTED')">采纳</el-button>
<el-button type="danger" size="small" @click="decide(p,'DECLINED')">驳回</el-button>
</div>
<div class="muted" style="font-size:11px;margin-top:4px">采纳=记一笔待执行指令,由择时在窗口内择机下单;驳回=今日不再提这只。</div>
</div>
</div>
<!-- 3) 我的持仓 -->
<div class="panel tblk" id="t-pos">
<h3>我的持仓 · {{ heldPositions.length }} 只</h3>
<el-table :data="heldPositions" size="small" border max-height="460">
<el-table-column type="expand">
<template #default="s">
<div style="padding:6px 16px">
<el-button size="small" @click="loadLots(s.row.ts_code)">查看批次</el-button>
<el-table v-if="lotsOf===s.row.ts_code" :data="lots" size="small" border style="margin-top:6px">
<el-table-column label="批次" width="100"><template #default="l">{{ tx('lot', l.row.lot_type) }}</template></el-table-column>
<el-table-column prop="qty" label="剩余" width="80"></el-table-column>
<el-table-column prop="open_price" label="开仓价" width="90"></el-table-column>
<el-table-column prop="open_date" label="开仓日" width="110"></el-table-column>
<el-table-column prop="realized_pnl" label="已实现" width="90"></el-table-column>
</el-table>
<div class="muted" style="margin-top:8px">该股近期账本:</div>
<div class="muted" v-if="!ledger.filter(x=>x.ts_code===s.row.ts_code).length"></div>
<div v-for="(l,i) in ledger.filter(x=>x.ts_code===s.row.ts_code)" :key="i" style="padding:2px 0">
<span class="muted">{{ (l.decided_at||'').slice(5,16) }}</span>
{{ tx('arbiter', l.arbiter) }}{{ tx('verdict', l.verdict) }} {{ tx('action', l.action) }}<span v-if="l.reason" class="muted"> — {{ l.reason }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="股票" width="150">
<template #default="s"><b>{{ nm(s.row.ts_code) }}</b><br><span class="muted mono">{{ s.row.ts_code }}</span></template>
</el-table-column>
<el-table-column label="现价 / 成本" width="120">
<template #default="s">{{ s.row.price }}<span v-if="!s.row.price_ok" class="muted"></span><br><span class="muted">{{ s.row.avg_cost||'—' }}</span></template>
</el-table-column>
<el-table-column label="浮动盈亏" width="110">
<template #default="s">
<span v-if="s.row.price_ok && s.row.avg_cost" :class="(s.row.price/s.row.avg_cost-1)>=0?'clr-up':'clr-down'">{{ pct(s.row.price/s.row.avg_cost-1) }}</span>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column label="市值 / 占比" width="130">
<template #default="s">{{ money(s.row.market_value) }}<br><span class="muted">{{ pct(s.row.pct_of_scale) }}</span></template>
</el-table-column>
<el-table-column label="安全垫" width="120">
<template #default="s"><el-tag size="small" effect="dark" :type="cuTag(s.row.cushion_state)">{{ tx('cushion', s.row.cushion_state) }}</el-tag>
<div class="muted">{{ pct(s.row.cushion_pct) }}<span v-if="s.row.neg_cushion_days>0"> · 连负{{ s.row.neg_cushion_days }}日</span></div></template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="s">{{ tx('posStatus', s.row.status) }}
<div v-if="s.row.frozen_reason && s.row.frozen_reason!=='NONE'" style="color:#f56c6c">{{ tx('frozen', s.row.frozen_reason) }}</div></template>
</el-table-column>
<el-table-column label="下一步" min-width="150"><template #default="s"><span class="muted">{{ nextStep(s.row) }}</span></template></el-table-column>
<el-table-column label="操作" width="220">
<template #default="s">
<el-button size="small" @click="actReduceStock(s.row)">减仓</el-button>
<el-button size="small" type="danger" @click="actExitStock(s.row)">清仓</el-button>
<el-dropdown style="margin-left:6px" @command="c=>rowMore(c,s.row)">
<el-button size="small">更多 ▾</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="stop">设止损价</el-dropdown-item>
<el-dropdown-item command="target">设目标价</el-dropdown-item>
<el-dropdown-item command="freeze">{{ (s.row.frozen_reason && s.row.frozen_reason!=='NONE') ? '解冻' : '冻结(禁增持)' }}</el-dropdown-item>
<el-dropdown-item command="black">加入黑名单</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
</el-table>
</div>
<!-- 4) 今日在办与动向 -->
<div class="panel tblk">
<h3>今日在办</h3>
<el-table :data="instructions" size="small" border max-height="240" empty-text="今天没有在办的买卖">
<el-table-column label="股票" width="150"><template #default="s"><b>{{ nm(s.row.ts_code) }}</b> <span class="muted mono">{{ s.row.ts_code }}</span></template></el-table-column>
<el-table-column label="动作" width="150"><template #default="s">{{ tx('action', s.row.action) }} · {{ tx('side', s.row.side) }}</template></el-table-column>
<el-table-column label="数量 / 已成" width="110"><template #default="s">{{ s.row.qty }} / {{ s.row.exec_qty||0 }}</template></el-table-column>
<el-table-column prop="limit_price" label="限价" width="90"></el-table-column>
<el-table-column label="状态" width="130"><template #default="s">{{ tx('insStatus', s.row.status) }}</template></el-table-column>
<el-table-column label="操作" width="90"><template #default="s"><el-button size="small" @click="cancelIns(s.row.instruction_id)">撤单</el-button></template></el-table-column>
</el-table>
<h3 style="margin-top:14px">今天发生了什么</h3>
<div class="muted" v-if="!ledger.length">今天还没有记录。</div>
<div v-for="(l,i) in ledger" :key="i" style="padding:3px 0;border-bottom:1px solid #262626">
<span class="muted">{{ (l.decided_at||'').slice(11,16) }}</span>
<b> {{ nm(l.ts_code) }}</b>{{ tx('arbiter', l.arbiter) }}{{ tx('verdict', l.verdict) }} {{ tx('action', l.action) }}<span v-if="l.reason" class="muted"> —— {{ l.reason }}</span>
</div>
</div>
<!-- 5) 组合操作 + 设置 -->
<div class="panel tblk">
<h3>组合操作</h3>
<div class="muted" style="margin-bottom:8px">对整个组合的手动动作。带「…」的会先弹窗让你填数值;危险动作要二次确认。</div>
<div class="op-row">
<span class="op-btn"><el-button size="small" :type="ov.buy_halt?'success':'warning'" @click="ov.buy_halt?pResumeBuy():pHaltBuy()">{{ ov.buy_halt?'恢复买入':'暂停买入' }}</el-button></span>
<span class="d">停止一切新增买入,并撤销在途买入指令;卖出与止损不受影响。当前:{{ ov.buy_halt?'已暂停买入':'买入正常' }}。</span>
</div>
<div class="op-row">
<span class="op-btn"><el-button size="small" :type="ov.exec_halt?'success':''" @click="ov.exec_halt?pResumeAll():pHaltAll()">{{ ov.exec_halt?'结束休假':'休假模式' }}</el-button></span>
<span class="d">所有自动动作暂停,只保留对账与日报(长假或手动接管时用)。当前:{{ ov.exec_halt?'休假中':'正常运行' }}。</span>
</div>
<div class="op-row">
<span class="op-btn"><el-button size="small" @click="pReduce()">降仓…</el-button></span>
<span class="d">按占总规模的百分比整体减少敞口。优先级:停新买 → 清弱票 → 收利润 → 等比微减。</span>
</div>
<div class="op-row">
<span class="op-btn"><el-button size="small" @click="pIncrease()">升仓…</el-button></span>
<span class="d">按占总规模的百分比整体提高敞口。来源:持仓补到目标 + 候选池新票建仓。</span>
</div>
<div class="op-row">
<span class="op-btn"><el-button size="small" :disabled="!ov.sector_ready" @click="pSectorExit()">清仓某行业…</el-button></span>
<span class="d">卖光指定行业的全部持仓。{{ ov.sector_ready?'':'(行业数据未就绪,暂不可用)' }}</span>
</div>
<div class="op-row">
<span class="op-btn"><el-button size="small" type="danger" @click="pLiquidate()">一键清仓(紧急)</el-button></span>
<span class="d">全部持仓按最快节奏卖出、不做择时优化。用于紧急离场,需二次确认。</span>
</div>
</div>
<div class="panel tblk">
<h3>设置</h3>
<el-table :data="traderParams" size="small" border>
<el-table-column label="项" min-width="240"><template #default="s">{{ s.row.desc || s.row.key }}</template></el-table-column>
<el-table-column label="当前值" width="240">
<template #default="s">
<el-select v-if="s.row.key==='PMS_AUTONOMY'" v-model="s.row.value" size="small">
<el-option label="全自动" value="full"></el-option>
<el-option label="只提议不下手" value="propose_only"></el-option>
<el-option label="暂停" value="off"></el-option>
</el-select>
<el-input v-else v-model="s.row.value" size="small"></el-input>
</template>
</el-table-column>
</el-table>
<div class="row" style="margin-top:8px">
<el-button size="small" type="primary" :disabled="!dirtyCount" @click="saveParams">保存修改 ({{ dirtyCount }})</el-button>
<el-button size="small" @click="loadParams">放弃</el-button>
<span class="muted">改「自主档位」或「总仓上限」会即时生效;调低总仓上限可能触发降仓提议。</span>
<span class="muted">补仓(浮亏时逢跌补仓):「补仓上限」设为 0 即关闭补仓;「补仓评估档」是触发补仓的浮亏档位(如 -0.08,-0.15);到「深档确认」及更深的补仓永远要你点头。</span>
</div>
</div>
<!-- 6) 系统在盯的候选 -->
<div class="panel tblk">
<h3>系统在盯的候选(今日选股计划)<span class="muted" style="font-weight:400"> · 计划日 {{ planStatus.date || '—' }}</span></h3>
<div class="muted" v-if="planStatus.ok===false">上游计划暂不可用。</div>
<el-table v-else :data="planRows.slice(0,20)" size="small" border max-height="300" empty-text="暂无">
<el-table-column prop="rank" label="名次" width="60"></el-table-column>
<el-table-column label="股票" width="160"><template #default="s"><b>{{ s.row.name || nm(s.row.ts_code) }}</b> <span class="muted mono">{{ s.row.ts_code }}</span></template></el-table-column>
<el-table-column prop="theme" label="主题" width="130"></el-table-column>
<el-table-column prop="tier" label="传导档" width="90"></el-table-column>
<el-table-column label="预期空间" width="100"><template #default="s">{{ s.row.upside==null ? '—' : ((s.row.upside>0?'+':'')+(s.row.upside*100).toFixed(0)+'%') }}</template></el-table-column>
</el-table>
<div class="muted" style="margin-top:6px">这是系统当前在盯、可能冒出提议的池子;仅供参考,不自动下单。</div>
</div>
</div>
<div v-show="mode==='ops'">
<div class="metrics">
<div class="m"><div class="k">总规模</div><div class="v">{{ money(ov.scale) }}</div></div>
<div class="m"><div class="k">组合市值</div><div class="v">{{ money(ov.portfolio_mv) }}</div></div>
<div class="m"><div class="k">总仓位</div>
<div class="v">{{ pct(ov.portfolio_pct) }}<span class="muted"> / {{ pct(ov.portfolio_cap) }}</span></div></div>
<div class="m"><div class="k">可用额度</div><div class="v">{{ money(ov.cap_room) }}</div></div>
<div class="m"><div class="k">账户可用<span class="muted" v-if="ov.cash_source!=='ws'"> (估算)</span></div>
<div class="v" :class="ov.cash_source==='ws'?'':'muted'">{{ money(ov.cash_source==='ws' ? ov.cash_avail : ov.cash_est) }}</div></div>
<div class="m"><div class="k">持仓数</div>
<div class="v">{{ ov.names_count }}<span class="muted"> / {{ ov.max_names }}</span></div></div>
<div class="m"><div class="k">浮动盈亏</div>
<div class="v" :class="(ov.float_pnl||0)>=0?'up':'down'">{{ money(ov.float_pnl) }}</div></div>
<div class="m"><div class="k">厚垫 / 负垫</div>
<div class="v">{{ ov.solid_names }} / {{ ov.neg_names }}</div></div>
</div>
<el-tabs v-model="tab" type="border-card">
<!-- ==================== ① 参数设置 ==================== -->
<el-tab-pane label="参数设置" name="params">
<div class="row" style="margin-bottom:10px">
<span class="muted">页面改动持久化到 pms_runtime_param, 优先于 settings.py 初值; 基础设施连接串只在 .env 维护。</span>
<div class="spacer"></div>
<el-button size="small" type="primary" :disabled="!dirtyCount" @click="saveParams">
保存修改 ({{ dirtyCount }})</el-button>
<el-button size="small" @click="loadParams">放弃修改</el-button>
</div>
<el-table :data="params" size="small" height="560" border>
<el-table-column prop="key" label="参数" width="240" class-name="mono"></el-table-column>
<el-table-column label="当前值" width="200">
<template #default="s">
<el-select v-if="s.row.key==='PMS_AUTONOMY'" v-model="s.row.value" size="small">
<el-option v-for="o in ['full','propose_only','off']" :key="o" :label="o" :value="o"></el-option>
</el-select>
<el-select v-else-if="s.row.key==='PMS_SECTOR_SOURCE'" v-model="s.row.value" size="small">
<el-option label="(空) 停用行业约束" value=""></el-option>
<el-option label="custom_table" value="custom_table"></el-option>
<el-option label="gp_stock_category" value="gp_stock_category"></el-option>
</el-select>
<el-switch v-else-if="s.row.type==='bool'" v-model="s.row.value"></el-switch>
<el-input v-else v-model="s.row.value" size="small"></el-input>
</template>
</el-table-column>
<el-table-column prop="file_default" label="文件初值" width="130"></el-table-column>
<el-table-column label="来源" width="80">
<template #default="s">
<el-tag size="small" :type="s.row.source==='table'?'success':'info'" effect="dark">
{{ s.row.source==='table' ? '页面' : '文件' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="desc" label="说明" min-width="260"></el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="160" class-name="muted"></el-table-column>
</el-table>
</el-tab-pane>
<!-- ==================== ② 命令台 ==================== -->
<el-tab-pane label="命令台" name="cmd">
<div class="panel">
<h3>下达命令</h3>
<div class="row">
<el-select v-model="form.cmd_type" filterable placeholder="选择命令" style="width:300px"
@change="onCmdChange">
<el-option-group v-for="g in ['A','B','C']" :key="g" :label="groupLabel(g)">
<el-option v-for="c in catalog.filter(x=>x.group===g)" :key="c.cmd_type"
:label="c.label + ' · ' + c.cmd_type" :value="c.cmd_type"
:disabled="c.disabled">
<span>{{ c.label }}</span>
<span class="muted" style="float:right">{{ c.cls==='param'?'参数':'任务' }}</span>
</el-option>
</el-option-group>
</el-select>
<el-tag v-if="curSpec" size="small" effect="dark"
:type="curSpec.cls==='param'?'warning':'primary'">
{{ curSpec.cls==='param' ? '参数命令 (立即生效)' : '任务命令 (生成方案)' }}</el-tag>
<el-tag v-if="curSpec && curSpec.danger" size="small" type="danger" effect="dark">高危</el-tag>
</div>
<div class="muted" v-if="curSpec && curSpec.note" style="margin:8px 0">{{ curSpec.note }}</div>
<div class="row" style="margin-top:10px" v-if="curSpec">
<div v-for="(f,name) in curSpec.fields" :key="name" class="row">
<span class="muted">{{ fieldLabel(name) }}</span>
<el-select v-if="f.type==='enum'" v-model="form.params[name]" size="small" style="width:150px">
<el-option v-for="o in f.choices" :key="o" :label="o" :value="o"></el-option>
</el-select>
<el-input v-else v-model="form.params[name]" size="small" style="width:150px"
:placeholder="f.required===false ? '可留空(默认)' : ''">
<template v-if="f.type==='pct'" #append>%</template>
<template v-else-if="f.type==='money'" #append></template>
<template v-else-if="f.type==='price'" #append></template>
</el-input>
</div>
<el-input v-model="form.note" size="small" style="width:200px" placeholder="备注(可选)"></el-input>
<el-button type="primary" size="small" :loading="issuing" @click="issue(false)">下达</el-button>
</div>
<div class="muted" style="margin-top:8px" v-if="curSpec && curSpec.cls==='task'">
任务命令下达后立即生成方案并落 pms_plan; 指令下发由择时执行器负责 (下一批交付)。
</div>
</div>
<div class="panel">
<h3>在途命令与进度</h3>
<el-table :data="commands" size="small" border max-height="420"
:row-class-name="()=>''">
<el-table-column type="expand">
<template #default="s">
<div style="padding:8px 16px">
<div class="muted">参数: <span class="mono">{{ JSON.stringify(s.row.params) }}</span></div>
<div class="muted" v-if="s.row.progress && s.row.progress.notes">
说明: {{ (s.row.progress.notes||[]).join(' | ') }}</div>
<el-button size="small" style="margin:8px 0" @click="loadPlans(s.row.command_id)">
查看方案明细</el-button>
<el-table v-if="plansOf===s.row.command_id" :data="plans" size="small" border>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
<el-table-column prop="action" label="动作" width="80"></el-table-column>
<el-table-column prop="qty" label="数量" width="90"></el-table-column>
<el-table-column label="金额" width="110">
<template #default="p">{{ money(p.row.amount) }}</template></el-table-column>
<el-table-column prop="priority" label="优先级" width="80"></el-table-column>
<el-table-column prop="status" label="状态" width="90"></el-table-column>
<el-table-column prop="deadline" label="截止" width="110"></el-table-column>
<el-table-column prop="reason" label="理由" min-width="260"></el-table-column>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="command_id" label="命令号" width="160" class-name="mono"></el-table-column>
<el-table-column prop="cmd_type" label="类型" width="160"></el-table-column>
<el-table-column prop="ts_code" label="标的" width="110" class-name="mono"></el-table-column>
<el-table-column label="状态" width="100">
<template #default="s">
<el-tag size="small" effect="dark" :type="stTag(s.row.status)">{{ s.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="进度" min-width="220">
<template #default="s">
<div v-if="s.row.progress && s.row.progress.target_amount">
<el-progress :percentage="progPct(s.row.progress)" :stroke-width="10"
:text-inside="true" status="success"></el-progress>
<span class="muted">{{ money(s.row.progress.done_amount) }} /
{{ money(s.row.progress.target_amount) }}
<template v-if="s.row.progress.gap"> · 缺口 {{ money(s.row.progress.gap) }}</template>
</span>
</div>
<span v-else class="muted">{{ (s.row.progress||{}).plan_count || 0 }} 条方案</span>
</template>
</el-table-column>
<el-table-column prop="issued_at" label="下达时间" width="160" class-name="muted"></el-table-column>
<el-table-column label="操作" width="150">
<template #default="s">
<el-button size="small" :disabled="!canCancel(s.row)"
@click="cancelCmd(s.row.command_id)">撤销</el-button>
<el-button size="small" :disabled="!canCancel(s.row) || s.row.cmd_class!=='task'"
@click="replan(s.row.command_id)">重规划</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<!-- ==================== ③ 持仓与账本 ==================== -->
<el-tab-pane label="持仓与账本" name="pos">
<div class="panel">
<h3>持仓总览 ({{ positions.length }} 只)</h3>
<el-table :data="positions" size="small" border max-height="460">
<el-table-column type="expand">
<template #default="s">
<div style="padding:8px 16px">
<el-button size="small" @click="loadLots(s.row.ts_code)">查看批次</el-button>
<el-table v-if="lotsOf===s.row.ts_code" :data="lots" size="small" border
style="margin-top:8px">
<el-table-column prop="lot_type" label="批次" width="90"></el-table-column>
<el-table-column prop="qty" label="剩余" width="90"></el-table-column>
<el-table-column prop="closed_qty" label="已核销" width="90"></el-table-column>
<el-table-column prop="open_price" label="开仓价" width="100"></el-table-column>
<el-table-column prop="close_avg_price" label="核销均价" width="100"></el-table-column>
<el-table-column prop="realized_pnl" label="已实现" width="100"></el-table-column>
<el-table-column prop="open_date" label="开仓日" width="110"></el-table-column>
<el-table-column prop="instruction_id" label="来源指令" min-width="180"
class-name="mono"></el-table-column>
<el-table-column prop="note" label="备注" min-width="160"></el-table-column>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
<el-table-column prop="sector" label="行业" width="100"></el-table-column>
<el-table-column prop="status" label="状态" width="90"></el-table-column>
<el-table-column label="冻结" width="110">
<template #default="s">
<el-tag v-if="s.row.frozen_reason && s.row.frozen_reason!=='NONE'" size="small"
type="danger" effect="dark">{{ s.row.frozen_reason }}</el-tag>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column prop="total_qty" label="持仓" width="90"></el-table-column>
<el-table-column prop="avail_qty" label="可卖" width="90"></el-table-column>
<el-table-column label="批次(底/补/加/补仓/T)" width="170">
<template #default="s"><span class="mono nowrap">{{ s.row.base_qty }}/{{ s.row.fill_qty }}/{{ s.row.add_qty }}/{{ s.row.dca_qty }}/{{ s.row.t0_qty }}</span></template>
</el-table-column>
<el-table-column prop="avg_cost" label="摊薄成本" width="100"></el-table-column>
<el-table-column prop="price" label="现价" width="90"></el-table-column>
<el-table-column label="安全垫" width="130">
<template #default="s">
<el-tag size="small" effect="dark" :type="cuTag(s.row.cushion_state)">
{{ pct(s.row.cushion_pct) }} {{ s.row.cushion_state }}</el-tag>
<span v-if="s.row.neg_cushion_days>0" class="muted"> 连负{{ s.row.neg_cushion_days }}日</span>
</template>
</el-table-column>
<el-table-column label="占规模" width="90">
<template #default="s">{{ pct(s.row.pct_of_scale) }}</template></el-table-column>
<el-table-column label="市值" width="110">
<template #default="s">{{ money(s.row.market_value) }}</template></el-table-column>
<el-table-column label="参考位(支/压/止损)" width="180">
<template #default="s"><span class="mono nowrap">{{ s.row.support_ref||'-' }}/{{ s.row.pressure_ref||'-' }}/{{ s.row.stop_ref||'-' }}</span>
<span class="muted"> {{ s.row.ref_source||'' }}</span></template>
</el-table-column>
<el-table-column label="做T" width="80">
<template #default="s">
<el-tag v-if="s.row.t0_enabled" size="small" type="warning" effect="dark">
{{ pct(s.row.t0_ratio) }}</el-tag><span v-else class="muted"></span>
</template>
</el-table-column>
</el-table>
</div>
<div class="panel">
<h3>在途指令</h3>
<el-table :data="instructions" size="small" border max-height="320">
<el-table-column type="expand">
<template #default="s">
<div style="padding:6px 16px">
<div class="muted" v-if="(s.row.progress||{}).last_decision">
最近判定 [{{ s.row.progress.last_decision.at }}]
<b>{{ s.row.progress.last_decision.action }}</b>
{{ s.row.progress.last_decision.reason }}
</div>
<div class="muted" v-if="(s.row.progress||{}).deadline">
执行窗口截止: {{ s.row.progress.deadline }}
<template v-if="(s.row.progress||{}).window_verdict">
· {{ s.row.progress.window_verdict.note }}</template>
</div>
<el-table v-if="((s.row.progress||{}).children||[]).length"
:data="s.row.progress.children" size="small" border
style="margin-top:6px">
<el-table-column prop="ymd" label="日期" width="100"></el-table-column>
<el-table-column prop="at" label="时点" width="90"></el-table-column>
<el-table-column prop="qty" label="数量" width="90"></el-table-column>
<el-table-column prop="limit" label="限价" width="90"></el-table-column>
<el-table-column prop="mode" label="通道" width="100"></el-table-column>
<el-table-column label="兜底" width="70">
<template #default="c">{{ c.row.forced ? '是' : '—' }}</template>
</el-table-column>
<el-table-column prop="reason" label="出手依据" min-width="260"></el-table-column>
</el-table>
<div v-else class="muted" style="margin-top:6px">尚无出手记录</div>
</div>
</template>
</el-table-column>
<el-table-column prop="instruction_id" label="指令号" width="230" class-name="mono"></el-table-column>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
<el-table-column prop="action" label="动作" width="80"></el-table-column>
<el-table-column prop="side" label="方向" width="70"></el-table-column>
<el-table-column prop="qty" label="数量" width="80"></el-table-column>
<el-table-column prop="exec_qty" label="已成交" width="80"></el-table-column>
<el-table-column label="进度" width="130">
<template #default="s">
<el-progress :percentage="insPct(s.row)" :stroke-width="10" :text-inside="true"></el-progress>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="115"></el-table-column>
<el-table-column prop="origin_type" label="来源" width="80"></el-table-column>
<el-table-column label="操作" width="90">
<template #default="s">
<el-button size="small" @click="cancelIns(s.row.instruction_id)">撤销</el-button>
</template>
</el-table-column>
</el-table>
<div class="muted" style="margin-top:6px">
{{ dm.hint }} 展开可看每日子单与择时依据; 成交量以下游回放为准, 不拿下发量当成交量。
</div>
</div>
<div class="panel">
<h3>评审账本 (规则闸 / 研判闸 / 人工裁决全量留痕)</h3>
<el-table :data="ledger" size="small" border max-height="260">
<el-table-column prop="decided_at" label="时间" width="160"></el-table-column>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
<el-table-column prop="action" label="动作" width="90"></el-table-column>
<el-table-column prop="arbiter" label="评审方" width="90"></el-table-column>
<el-table-column label="结论" width="90">
<template #default="s">
<el-tag size="small" effect="dark" :type="s.row.verdict==='PASS'?'success':'danger'">
{{ s.row.verdict }}</el-tag></template>
</el-table-column>
<el-table-column prop="price_at" label="评审价" width="90"></el-table-column>
<el-table-column label="未通过项" min-width="240">
<template #default="s"><span class="muted">{{ (s.row.failed_checks||[]).join(' | ') }}</span></template>
</el-table-column>
<el-table-column prop="reason" label="理由" min-width="200"></el-table-column>
</el-table>
</div>
</el-tab-pane>
<!-- ==================== ④ 提议确认 ==================== -->
<el-tab-pane name="prop">
<template #label>
提议确认
<el-badge v-if="proposals.length" :value="proposals.length" class="ml"></el-badge>
</template>
<div class="panel">
<h3>待确认队列 (自主档位 {{ ov.autonomy }})</h3>
<div class="muted" style="margin-bottom:8px">
propose_only 档位下, 增持类自主动作全部在此等待裁决; 减持方向不设确认门槛。
采纳后先落指令表 (先记账后动作), 由择时执行器分日出手。
</div>
<el-alert v-if="dm.judge && !dm.judge.available" type="warning" effect="dark" show-icon
:closable="false" style="margin-bottom:10px"
:title="'研判闸未接通 —— ' + dm.judge.reason + ' (补足/加仓/补仓一律降级为人工确认)'"></el-alert>
<el-table :data="proposals" size="small" border max-height="420">
<el-table-column prop="proposal_id" label="提议号" width="230" class-name="mono"></el-table-column>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
<el-table-column prop="action" label="动作" width="90"></el-table-column>
<el-table-column prop="qty" label="数量" width="90"></el-table-column>
<el-table-column label="硬数字" min-width="320">
<template #default="s"><span class="mono muted">{{ JSON.stringify(s.row.hard_numbers) }}</span></template>
</el-table-column>
<el-table-column prop="judge_verdict" label="研判" width="100"></el-table-column>
<el-table-column prop="expire_at" label="失效时间" width="160"></el-table-column>
<el-table-column label="裁决" width="170">
<template #default="s">
<el-button size="small" type="success" @click="decide(s.row,'ACCEPTED')">采纳</el-button>
<el-button size="small" type="danger" @click="decide(s.row,'DECLINED')">驳回</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<!-- ==================== ④ ws 通道 (只读监控) ====================
只读: 任何会产生新委托的动作都不放在页面上。联调发单仍走
scripts/ws_smoke.py place --yes —— 那条路径有显式确认、有参数校验、
有"这张单会真成交"的警告, 且不受 PMS_DISPATCH_MODE 影响。页面上加个
发单按钮等于在生产界面开了第二条绕过开关的下单入口, 不值当。 -->
<el-tab-pane label="ws 通道" name="ws">
<div class="row" style="margin-bottom:10px">
<el-tag :type="wsc.process_alive ? 'success':'danger'" effect="dark" size="small">
进程 {{ wsc.process_alive ? '在线':'不在线' }}</el-tag>
<el-tag :type="wsc.conn_state==='ONLINE' ? 'success':'warning'" effect="dark" size="small">
{{ wsc.conn_state || '-' }}</el-tag>
<span class="muted">心跳 {{ wsc.heartbeat_age_sec==null ? '从未' : wsc.heartbeat_age_sec+'s 前' }}</span>
<div class="spacer"></div>
<el-checkbox v-model="wsAuto" size="small">每 3 秒自动刷新</el-checkbox>
<el-button size="small" @click="loadWs">刷新</el-button>
</div>
<!-- 异常自检: 与 scripts/ws_smoke.py 用同一套判据, 页面和命令行的结论必须一致,
否则联调时两边打架比没有告警更糟 -->
<el-alert v-for="(w,i) in wsWarnings" :key="i" class="banner" type="warning" effect="dark"
show-icon :closable="false" :title="w"></el-alert>
<div class="metrics">
<div class="m"><div class="k">seq 已落库</div><div class="v">{{ wsc.last_seq }}</div></div>
<div class="m"><div class="k">seq 已确认</div><div class="v">{{ wsc.acked_seq }}</div></div>
<div class="m"><div class="k">待入账上行</div><div class="v">{{ wsc.inbox_pending }}</div></div>
<div class="m"><div class="k">收 / 发</div>
<div class="v">{{ wst.rx||0 }} / {{ wst.tx||0 }}</div></div>
<div class="m"><div class="k">成交 / 拒绝</div>
<div class="v">{{ wst.trades||0 }} / {{ wst.rejects||0 }}</div></div>
<div class="m"><div class="k">重连</div><div class="v">{{ wst.reconnects||0 }}</div></div>
<div class="m"><div class="k">对端时钟偏差</div>
<div class="v" :class="Math.abs(wst.peer_skew_ms||0)>20000?'up':''">
{{ wst.peer_skew_ms==null ? '—' : (wst.peer_skew_ms>0?'+':'') + wst.peer_skew_ms + 'ms' }}</div></div>
</div>
<div class="panel">
<h3>通道明细</h3>
<div class="row" style="gap:24px">
<span class="muted">下发模式 <span class="mono">{{ wsMode || '-' }}</span></span>
<span class="muted">上次连上 {{ wsc.connected_at || '—' }}</span>
<span class="muted">出口队列 <span class="mono">{{ JSON.stringify(wsc.queue||{}) }}</span></span>
</div>
<div v-if="wsc.last_error" class="muted" style="margin-top:8px;color:#e6a23c">
最后错误: {{ wsc.last_error }}</div>
<div v-if="wst.last_reject" class="muted" style="margin-top:6px">
最后一条拒绝: <span class="mono">{{ wst.last_reject }}</span></div>
<div v-if="wst.last_close" class="muted" style="margin-top:6px">
最后断开原因: <span class="mono">{{ wst.last_close }}</span></div>
</div>
<div class="panel">
<h3>出口委托 (pms_qmt_order)</h3>
<el-table :data="wsOrders" size="small" border max-height="300" empty-text="暂无委托">
<el-table-column prop="instruction_id" label="指令号" width="230" class-name="mono"></el-table-column>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
<el-table-column prop="side" label="方向" width="70"></el-table-column>
<el-table-column prop="qty" label="数量" width="80"></el-table-column>
<el-table-column prop="limit_price" label="限价" width="90"></el-table-column>
<el-table-column prop="status" label="状态" width="120"></el-table-column>
<el-table-column prop="cum_qty" label="已成" width="80"></el-table-column>
<el-table-column prop="cancel_state" label="撤单" width="90"></el-table-column>
<el-table-column prop="reject_code" label="拒绝码" min-width="140"></el-table-column>
</el-table>
</div>
<div class="panel">
<h3>上行消息 (pms_qmt_inbox)
<span class="muted" style="font-weight:400">· 新→旧</span></h3>
<div class="row" style="margin-bottom:8px">
<el-checkbox v-model="wsHidePong" size="small">隐藏 pong</el-checkbox>
<span class="muted">pong 每 5 秒一条且按协议 §5 带 seq, 不隐藏会把成交冲没</span>
</div>
<el-table :data="wsInbox" size="small" border max-height="380" empty-text="还没收到上行消息">
<el-table-column prop="seq" label="seq" width="90" class-name="mono"></el-table-column>
<el-table-column prop="msg_type" label="类型" width="130"></el-table-column>
<el-table-column label="入账" width="80">
<template #default="s">
<span class="muted">{{ ({0:'待入账',1:'已入账',2:'已消化'})[s.row.processed] || '?' }}</span>
</template>
</el-table-column>
<el-table-column label="内容" min-width="380">
<template #default="s">
<span class="mono muted">{{ JSON.stringify(s.row.payload || {}) }}</span>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
</div>
<!-- 运维抽屉 -->
<el-drawer v-model="opsDrawer" title="运维操作 (与调度器同一份实现)" size="46%">
<div class="muted" style="margin-bottom:10px">
调度器 (--profile sched) 会自动按调度总表执行; 此处按钮用于未接调度或临时验证时手动触发。
</div>
<div class="row">
<el-button @click="ops('plan-pending')" :loading="opsLoading">推进待规划命令</el-button>
<el-button @click="ops('materialize')" :loading="opsLoading">方案转指令</el-button>
<el-button type="primary" @click="ops('exec-tick?dry_run=true')" :loading="opsLoading">
出手试算 (不下发)</el-button>
<el-button @click="ops('exec-tick')" :loading="opsLoading">出手一跳</el-button>
<el-button @click="ops('sweep-windows')" :loading="opsLoading">窗口收口</el-button>
</div>
<div class="row" style="margin-top:8px">
<el-button type="primary" @click="ops('scan-proposals?dry_run=true')" :loading="opsLoading">
提议扫描试算 (不落表)</el-button>
<el-button @click="ops('scan-proposals')" :loading="opsLoading">提议扫描</el-button>
</div>
<div class="row" style="margin-top:8px">
<el-button @click="ops('replay')" :loading="opsLoading">成交回放</el-button>
<el-button @click="ops('reconcile')" :loading="opsLoading">账本对账</el-button>
<el-button @click="ops('premarket')" :loading="opsLoading">盘前准备</el-button>
<el-button @click="ops('daily-settle')" :loading="opsLoading">日终结算</el-button>
<el-button @click="ops('report')" :loading="opsLoading">生成日报</el-button>
<el-button @click="loadSchema" :loading="opsLoading">导出下游表结构</el-button>
</div>
<div class="muted" style="margin-top:10px">
当前下发通道: <b>{{ dm.mode }}</b> —— {{ dm.hint }}
(通道在「参数设置 → PMS_DISPATCH_MODE」切换)
</div>
<pre class="json" v-if="opsResult">{{ opsResult }}</pre>
</el-drawer>
<!-- 上游选股计划抽屉 -->
<el-drawer v-model="planDrawer" title="上游选股计划 (候选池的事实源)" size="56%">
<div class="row" style="margin-bottom:8px">
<el-button type="primary" :loading="planLoading" @click="refreshPlan">
强刷 + 灌行业映射</el-button>
<el-button :loading="planLoading" @click="loadPlan">只重读</el-button>
<span class="muted" style="margin-left:8px">
计划不带价格与金额 —— 买多少/什么价一律 PMS 自己算; 行情取不到价的票会被剔除。
</span>
</div>
<el-alert v-if="planStatus.ok===false" type="error" effect="dark" show-icon :closable="false"
:title="planStatus.hint || '不可用'" style="margin-bottom:8px"></el-alert>
<div class="muted" v-else style="margin-bottom:8px">
计划日 <b>{{ planStatus.date }}</b> (日龄 {{ planStatus.age_tdays }} 交易日) ·
热度日 {{ planStatus.heat_date || '-' }} · 快照 {{ (planStatus.market_snapshot_days||[]).join(',') }} ·
上游主题上限 {{ planStatus.theme_cap }} · theme 灌映射 {{ JSON.stringify(planStatus.theme_sync||{}) }}
<br>请求 {{ JSON.stringify(planStatus.query||{}) }} · 漏斗: 打分池
主榜 {{ (planStatus.funnel||{}).scored_main }} / 观察 {{ (planStatus.funnel||{}).scored_observe }}
→(券商预期 + 目标价≥现价 + 主题限额 + top)→ 实收
主榜 {{ (planStatus.funnel||{}).returned_main }} / 观察 {{ (planStatus.funnel||{}).returned_observe }}
</div>
<el-alert v-if="planCand && planCand.tier_complete===false" type="warning" effect="dark"
show-icon :closable="false" style="margin-bottom:8px"
:title="'返回的全是白名单档位 (top=' + ((planStatus.requested||{}).top)
+ ' 吃满了) —— 白名单档位可能没取全, 调大 PMS_PLAN_TOP'">
</el-alert>
<div class="muted" v-if="planCand" style="margin-bottom:8px">
按当前参数筛选: 排序池 {{ planCand.considered }} → 合格 {{ planCand.eligible }} →
取 {{ (planCand.items||[]).length }} 只 ·
白名单档位取全 {{ planCand.tier_complete===null ? '不适用' : (planCand.tier_complete ? '是':'否') }} ·
丢弃 {{ JSON.stringify(planCand.dropped||{}) }}
<span v-if="(planCand.st_unknown||[]).length" style="color:#E6A23C">·
{{ planCand.st_unknown.length }} 只无名称, ST 判不了已放行</span>
<br><span class="mono">{{ (planCand.items||[]).map(x=>x.ts_code).join(' ') }}</span>
</div>
<!-- 榜单变化 (PMS 自算; 上游 changes 恒为 null) -->
<el-divider content-position="left">榜单变化</el-divider>
<div class="row" style="margin-bottom:6px">
<el-button size="small" :loading="chgLoading" @click="loadChanges">重算变化</el-button>
<span class="muted" style="margin-left:8px" v-if="chgSt.hint">{{ chgSt.hint }}</span>
</div>
<el-alert v-if="chgSt.in_sync === false" type="error" effect="dark" show-icon
:closable="false" style="margin-bottom:8px"
title="手上这份计划没落进快照 —— 下面比的是两个旧版本, 与当前候选池不是同一份">
</el-alert>
<el-alert v-else-if="chg && chg.kind === 'same_date_revision'" type="warning" effect="dark"
show-icon :closable="false" style="margin-bottom:8px"
:title="'同一计划日 ' + chg.curr_date + ' 的新版本 —— 上游重算过, '
+ '此前拿到的候选池与现在不是同一份 (上游 /plan 是实时算的, 没有版本戳)'">
</el-alert>
<el-alert v-if="chg && chg.prev_broken" type="error" effect="dark" show-icon
:closable="false" style="margin-bottom:8px"
title="上一版名册读不出来 (快照损坏) —— 这次比不了, 下面的空白不代表没有变化">
</el-alert>
<el-alert v-if="chgHeld.n_watch" type="error" effect="dark" show-icon :closable="false"
style="margin-bottom:8px"
:title="'持仓票 ' + chgHeld.n_watch + ' 只掉榜 / 降档 / 丢了券商覆盖 —— 见下表红字'">
</el-alert>
<el-alert v-if="chg && chg.date_regressed" type="warning" effect="dark" show-icon
:closable="false" style="margin-bottom:8px"
title="计划日往回走了 (拉过一份旧计划?) —— 新进/掉榜的方向是反的">
</el-alert>
<div class="muted" v-if="chg" style="margin-bottom:8px">
{{ chg.kind === 'first' ? '首次落快照' : (chg.prev_date + ' → ' + chg.curr_date) }} ·
名册 {{ chg.roster_size.prev }} → {{ chg.roster_size.curr }} ·
新进 {{ chg.counts.entered }} · 掉榜 {{ chg.counts.exited }} ·
升档 {{ chg.counts.tier_up }} · 降档 {{ chg.counts.tier_down }} ·
覆盖翻转 {{ chg.counts.bucket_moved }} · 名次跳变 {{ chg.counts.rank_jump }}
<span v-if="(chg.tail_churn.entered + chg.tail_churn.exited)">·
榜尾进出 {{ chg.tail_churn.entered + chg.tail_churn.exited }} 只未计入 (那一版吃满 top,
尾部是截断噪音不是观点变化)</span>
<span v-if="chg.tail_churn.held_exempt">· 其中
<b>{{ chg.tail_churn.held_exempt }} 只因是持仓票照报</b> (持仓票不受榜尾闸约束 ——
漏报一条就是一个仓位)</span>
<span v-if="chgRevised.length">· <b>同一计划日出现多版</b>:
{{ chgRevised.map(x => x[0] + '×' + x[1]).join(', ') }}</span>
</div>
<el-tabs v-model="chgTab" v-if="chg">
<el-tab-pane name="held" :label="'持仓票 (' + chgHeld.n_total + ')'">
<el-table :data="chgWatch.filter(x => x.held)" size="small" border max-height="220"
empty-text="持仓票没被摘也没降档">
<el-table-column prop="ts_code" label="代码" width="104"></el-table-column>
<el-table-column prop="name" label="名称" width="92"></el-table-column>
<el-table-column prop="why" label="发生了什么" min-width="200">
<template #default="s"><span style="color:#F56C6C">{{ s.row.why }}</span></template>
</el-table-column>
<el-table-column prop="rank" label="上版 rank" width="90"></el-table-column>
<el-table-column prop="theme" label="主题" width="120"></el-table-column>
</el-table>
<div class="muted" style="margin-top:6px">
上游把一只<b>持仓票</b>摘出榜或降了档, 是"还该不该继续拿着"的直接信号 ——
单独拎出来免得被几十条新进榜淹掉。<b>它只是提示, 不自动产生任何指令</b>:
要减要清仍走命令台或提议确认。
</div>
</el-tab-pane>
<el-tab-pane name="watch" :label="'掉榜与降档 (' + chgWatch.length + ')'">
<el-table :data="chgWatch" size="small" border max-height="220" empty-text="无">
<el-table-column prop="ts_code" label="代码" width="104"></el-table-column>
<el-table-column prop="name" label="名称" width="92"></el-table-column>
<el-table-column prop="why" label="发生了什么" min-width="180"></el-table-column>
<el-table-column prop="rank" label="上版 rank" width="90"></el-table-column>
<el-table-column label="持仓" width="70">
<template #default="s">
<el-tag v-if="s.row.held" type="danger" size="small">持仓</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane name="in" :label="'新进榜 (' + chg.counts.entered + ')'">
<el-table :data="chg.entered" size="small" border max-height="220" empty-text="无">
<el-table-column prop="rank" label="rank" width="62"></el-table-column>
<el-table-column prop="ts_code" label="代码" width="104"></el-table-column>
<el-table-column prop="name" label="名称" width="92"></el-table-column>
<el-table-column prop="tier" label="传导档" width="82"></el-table-column>
<el-table-column prop="theme" label="主题" width="120"></el-table-column>
<el-table-column prop="score" label="score" width="82"></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane name="up" :label="'升档 (' + chg.counts.tier_up + ')'">
<el-table :data="chg.tier_up" size="small" border max-height="220" empty-text="无">
<el-table-column prop="rank" label="rank" width="62"></el-table-column>
<el-table-column prop="ts_code" label="代码" width="104"></el-table-column>
<el-table-column prop="name" label="名称" width="92"></el-table-column>
<el-table-column label="档位" min-width="150">
<template #default="s">{{ s.row.tier_from }} → <b>{{ s.row.tier_to }}</b></template>
</el-table-column>
<el-table-column prop="theme" label="主题" width="120"></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane name="jump" :label="'名次跳变 (' + chg.counts.rank_jump + ')'">
<el-table :data="chg.rank_jump" size="small" border max-height="220" empty-text="无">
<el-table-column prop="ts_code" label="代码" width="104"></el-table-column>
<el-table-column prop="name" label="名称" width="92"></el-table-column>
<el-table-column label="名次" min-width="150">
<template #default="s">
{{ s.row.rank_from }} → {{ s.row.rank_to }}
<span :style="{color: s.row.rank_delta > 0 ? '#F56C6C' : '#67C23A'}">
({{ s.row.rank_delta > 0 ? '+' : '' }}{{ s.row.rank_delta }})</span>
</template>
</el-table-column>
<el-table-column prop="tier" label="传导档" width="82"></el-table-column>
<el-table-column prop="theme" label="主题" width="120"></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane name="log" :label="'快照台账 (' + (chgLog.rows||[]).length + ')'">
<el-table :data="chgLog.rows" size="small" border max-height="220" empty-text="还没有快照">
<el-table-column prop="plan_date" label="计划日" width="104"></el-table-column>
<el-table-column prop="fetched_at" label="拉到的时刻" width="170"></el-table-column>
<el-table-column prop="n_main" label="主榜" width="70"></el-table-column>
<el-table-column prop="n_observe" label="观察" width="70"></el-table-column>
<el-table-column label="吃满 top" width="90">
<template #default="s">
<el-tag v-if="s.row.capped_main" type="warning" size="small">主榜</el-tag>
</template>
</el-table-column>
<el-table-column prop="digest" label="指纹" min-width="140"></el-table-column>
</el-table>
<div class="muted" style="margin-top:6px">
<b>同一「计划日」出现多行 = 上游那天重算过多版</b>(UPSTREAM_PLAN_API.md §7.3:
`/plan` 是实时算的, 应答里没有 generated_at, 下游本来没办法判断手上这份是哪一版)。
指纹不含 score —— 分数抖动但次序没变不算新版本, 否则表白涨而信息量为零。
</div>
</el-tab-pane>
</el-tabs>
<el-divider content-position="left">主榜明细</el-divider>
<el-table :data="planRows" size="small" border height="420">
<el-table-column prop="rank" label="rank" width="62"></el-table-column>
<el-table-column prop="ts_code" label="代码" width="104"></el-table-column>
<el-table-column prop="name" label="名称" width="92"></el-table-column>
<el-table-column prop="score" label="score" width="82"></el-table-column>
<el-table-column prop="tier" label="传导档" width="82"></el-table-column>
<el-table-column prop="theme" label="主题" width="120"></el-table-column>
<el-table-column prop="heat" label="heat" width="78"></el-table-column>
<el-table-column label="预期空间" width="96">
<template #default="s">
<span class="muted">{{ s.row.upside==null ? '-' :
((s.row.upside>0?'+':'') + (s.row.upside*100).toFixed(0) + '%') }}</span>
</template>
</el-table-column>
<el-table-column prop="n_sources" label="源数" width="66"></el-table-column>
</el-table>
<div class="muted" style="margin-top:6px">
「预期空间」= 券商目标价相对现价的空间 (上游 upside 字段, 2.12 → +212%)。噪音大,
所以<b>永不参与排序</b> —— 排序始终按 score; 只在 PMS_PLAN_MIN_UPSIDE 上做下限过滤。
筛选参数在「参数设置」里: PMS_PLAN_TOP_N / PMS_PLAN_TIERS / PMS_PLAN_INCLUDE_OBSERVE /
PMS_PLAN_MIN_SCORE / PMS_PLAN_MIN_SOURCES / PMS_PLAN_MIN_UPSIDE / PMS_PLAN_STALE_TDAYS。
<br>发给上游的三个: PMS_PLAN_TOP / PMS_PLAN_OBS_TOP / PMS_PLAN_THEME_CAP (0=不传, 用上游默认
20/10/5)。<b>PMS_PLAN_THEME_CAP_LOCAL</b> 是 PMS 侧的同主题限额, 在 TOP_N 截断之前生效 ——
让上游别裁 (THEME_CAP 设大) + PMS 自己裁, 前 N 名才不会被单一主题垄断。
<br><b>PMS_PLAN_EXCLUDE_ST</b> 默认开: 上游名单明确不剔除 ST/*ST, 这道闸在 PMS 侧。
「主题」是<b>传导主题</b>(事件驱动、天天变、覆盖约三成), <b>不是行业</b> —— 行业硬拦截
请把 PMS_SECTOR_SOURCE 设成 gp_stock_category, 别用 PMS_PLAN_THEME_SYNC。
</div>
<pre class="json" v-if="planResult">{{ planResult }}</pre>
</el-drawer>
<!-- 日报抽屉 -->
<el-drawer v-model="reportDrawer" :title="'运营日报 ' + (report.ymd||'')" size="52%">
<div v-if="(report.report||{}).attention">
<h3 style="color:#409EFF;font-size:14px">关注区</h3>
<el-table :data="report.report.attention" size="small" border>
<el-table-column prop="type" label="事项" width="130"></el-table-column>
<el-table-column label="内容">
<template #default="s"><span class="mono muted">{{ JSON.stringify(s.row) }}</span></template>
</el-table-column>
</el-table>
</div>
<pre class="json">{{ JSON.stringify(report.report||{}, null, 2) }}</pre>
</el-drawer>
</div>
<script>
const { createApp, ref, reactive, computed, onMounted, onUnmounted, watch } = Vue;
const api = axios.create({ timeout: 60000 });
createApp({
setup() {
const tab = ref('cmd'), loading = ref(false), err = ref('');
const mode = ref('trader');
const health = ref({}), ov = ref({}), params = ref([]), paramsRaw = ref({});
const catalog = ref([]), commands = ref([]), plans = ref([]), plansOf = ref('');
const positions = ref([]), lots = ref([]), lotsOf = ref('');
const instructions = ref([]), ledger = ref([]), proposals = ref([]);
const report = ref({}), reportDrawer = ref(false), dm = ref({});
const opsDrawer = ref(false), opsResult = ref(''), opsLoading = ref(false);
// 上游选股计划: 单独取数, 不塞进 /api/overview —— overview 是轮询的, 上游不通时
// 会把 10 秒超时拖进每一次页面刷新。
const planDrawer = ref(false), planLoading = ref(false), planRaw = ref({}),
planResult = ref('');
const planStatus = computed(() => planRaw.value.status || {});
const planRows = computed(() => planRaw.value.rows || []);
const planCand = computed(() => planRaw.value.candidates_raw || null);
// 榜单变化 (PMS 自算 —— 上游 changes 字段恒为 null)。另起一个接口, 不塞进 /api/upstream/plan:
// 它要读库, 而计划预览要读上游, 两者的故障域不同, 混在一起会互相拖垮。
const chgRaw = ref({}), chgLoading = ref(false), chgTab = ref('held');
const chgSt = computed(() => chgRaw.value.changes || {});
const chg = computed(() => chgSt.value.diff || null);
const chgHeld = computed(() => (chg.value || {}).held || {});
const chgLog = computed(() => chgRaw.value.log || {});
const chgRevised = computed(() => Object.entries(chgLog.value.revised || {}));
// 掉榜/降档合成一张表 —— 「上游把这只票摘了或降了档」是同一个意思的两种说法
const chgWatch = computed(() => {
const d = chg.value; if (!d) return [];
// 三类都算「上游把这只票看淡了」: 掉榜 / 降档 / 掉进观察档(=没券商覆盖了, 估值锚没了)。
// 与 plan_diff 的 held.n_watch 口径一致 —— 两边分家的话红条会亮而表里没有对应行。
return (d.exited || []).map(x => ({ ...x, why: '掉榜' }))
.concat((d.tier_down || []).map(x => ({ ...x, why: '降档 ' + x.tier_from + '→' + x.tier_to })))
.concat((d.bucket_moved || []).filter(x => x.moved_to === 'observe')
.map(x => ({ ...x, why: '券商覆盖没了 (主榜→观察档)' })));
});
const issuing = ref(false);
const form = reactive({ cmd_type: '', params: {}, note: '' });
// ---- ws 通道监控 (只读)
const wsRaw = ref({}), wsAuto = ref(true), wsHidePong = ref(true);
let wsTimer = null;
// 总规模(用户命令参数) 与 账户实际总资产(ws 资金快照) 的偏差。三道仓位闸都按 scale 算,
// 资金校验按账户算 —— 差得远时方案会过闸但下不出去, 而页面上原来完全看不出来。
const scaleGap = computed(() => {
const ta = Number(ov.value.total_asset || 0), s = Number(ov.value.scale || 0);
if (!ta || !s) return 0;
const g = ta / s - 1;
return Math.abs(g) > 0.10 ? g : 0;
});
const wsc = computed(() => wsRaw.value.channel || {});
const wst = computed(() => wsc.value.stat || {});
const wsMode = computed(() => wsRaw.value.mode || '');
const wsOrders = computed(() => wsRaw.value.orders || []);
const wsInbox = computed(() => (wsRaw.value.inbox || [])
.filter(r => !(wsHidePong.value && r.msg_type === 'pong')));
// 判据与 scripts/ws_smoke.py 的自检逐条对应。改一边就要改另一边 ——
// 页面说"正常"而命令行说"异常"(或反过来), 比两边都没有告警更能误导人。
const wsWarnings = computed(() => {
const c = wsc.value, s = wst.value, out = [];
if (!c.process_alive)
out.push('pms-ws 进程不在线 (心跳陈旧) —— dispatcher 会一律拒发。'
+ '起进程: docker compose --profile ws up -d pms-ws');
const hsSrv = +(s.hs_server_seq || 0), hsLast = +(s.hs_last_seq || 0);
if (hsSrv && hsSrv < hsLast)
out.push(`序号倒挂: 握手时对端自报 ${hsSrv}, 本端水位已到 ${hsLast}。对端此后发的每一条`
+ `(含成交)都会因「不高于水位」被静默丢弃。不要自行下调水位 —— 会把 `
+ `${hsSrv}..${hsLast} 重复入账。须先与 QMT 侧对齐 seq 语义`);
if (+(s.dropped || 0))
out.push(`已丢弃 ${s.dropped} 条上行 (seq 不高于水位)。补发时命中是正常的, `
+ `持续增长说明序号语义对不上`);
if (s.ack_seq_degraded)
out.push('ack_seq 已降级停发 (对端不认这个类型, 协议 §4.5)。水位照常落库、重连补发靠 '
+ 'hello.last_seq, 不丢成交; 代价是 QMT 侧 Redis 清不掉');
if (+(s.proto_rejects || 0))
out.push(`协议级 reject ${s.proto_rejects} 条 (不带 instruction_id, 拒的不是委托而是`
+ `我们发的协议消息)。若收发计数同步增长, 基本是「ack→reject→再 ack」的自激循环`);
if (s.peer_skew_ms != null && Math.abs(+s.peer_skew_ms) > 20000)
out.push(`对端时钟偏差 ${s.peer_skew_ms} ms, 已逼近 QMT 侧 ±30 秒时间窗。再漂下去连 `
+ `place_order 都会被 TS_SKEW 拒, 现象是「下单没反应」。两台机器都对一下 NTP`);
if (c.resync_required)
out.push('resync_flag=1: 对端补发不全, 须走全量对账后经运维抽屉清除标记');
if (c.error) out.push(c.error);
return out;
});
const curSpec = computed(() => catalog.value.find(c => c.cmd_type === form.cmd_type));
const dirtyCount = computed(() =>
params.value.filter(p => String(p.value) !== String(paramsRaw.value[p.key])).length);
const money = v => (v === null || v === undefined || v === '') ? '—'
: Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
const pct = v => (v === null || v === undefined || v === '') ? '—'
: (Number(v) * 100).toFixed(2) + '%';
const groupLabel = g => ({ A: 'A 资金与总体参数', B: 'B 组合级动作', C: 'C 个股级动作' }[g]);
const fieldLabel = n => ({
pct: '比例', scale: '总规模', cap: '上限', target: '目标', n: '数量', mode: '档位',
ratio: '比例', window_tdays: '窗口(交易日)', ts_code: '股票代码', target_pct: '目标仓位',
sector: '行业', t_ratio: 'T仓比例', price: '价格', confirm: '二次确认',
target_command_id: '目标命令号'
}[n] || n);
const stTag = s => ({ EXECUTING: 'primary', DONE: 'success', PARTIAL: 'warning',
CANCELLED: 'info', EFFECTIVE: 'success', SUPERSEDED: 'info' }[s] || 'warning');
const cuTag = s => ({ SOLID: 'success', THIN: 'warning', NONE: 'danger' }[s] || 'info');
const canCancel = r => r.cmd_class === 'task' &&
['PENDING', 'PLANNING', 'EXECUTING', 'PARTIAL'].includes(r.status);
const progPct = p => {
const t = Number(p.target_amount || 0), d = Number(p.done_amount || 0);
return t > 0 ? Math.min(100, Math.round(d / t * 100)) : 0;
};
const insPct = r => {
const q = Number(r.qty || 0), e = Number(r.exec_qty || 0);
return q > 0 ? Math.min(100, Math.round(e / q * 100)) : 0;
};
// ===================== 交易员视图:翻译层 + 名称 + 动作 =====================
const T = {
action: { OPEN:'建新仓', FILL:'回踩补足', ADD:'盈利加仓', DCA:'浮亏补仓', TRIM:'保垫减仓',
EXIT:'清仓', T0:'做T', T0_ROUND:'做T', SWITCH:'换仓', HALT:'停手' },
side: { buy:'买入', sell:'卖出' },
autonomy: { full:'全自动', propose_only:'只提议不下手', off:'暂停' },
cushion: { SOLID:'厚垫', THIN:'薄垫', NONE:'浮亏无垫' },
lot: { BASE:'底仓', FILL:'回踩补足', ADD:'盈利加仓', DCA:'补仓', T0:'做T仓', RECON:'对账调整' },
frozen: { NONE:'', COMMAND_HALT:'命令冻结(禁增持)', BRAKE:'刹车冻结', MANUAL:'手动冻结' },
posStatus: { PLANNED:'计划建仓', OPENING:'建仓中', HOLDING:'持有中', EXITING:'清仓中', CLOSED:'已清仓' },
insStatus: { PROPOSED:'待校验', RULE_PASSED:'规则闸已过', JUDGE_PASSED:'研判已过', DISPATCHED:'已下发挂单',
CONFIRMED:'已成交', REJECTED:'被拒', EXPIRED:'到期作废', CANCELLED:'已撤销' },
arbiter: { rule:'规则闸', judge:'研判闸', user:'你' },
verdict: { PASS:'放行', REJECT:'驳回', NOTE:'记录', UNAVAILABLE:'研判暂不可用' },
};
const tx = (g, c) => {
const m = T[g];
if (m && Object.prototype.hasOwnProperty.call(m, c)) return m[c];
if (c == null || c === '') return '—';
return String(c); // 未登记的码:显示原样, 不臆造
};
const nameMap = ref({});
const nm = c => (nameMap.value[c] && nameMap.value[c].name) || c;
const fullName = c => (nameMap.value[c] && nameMap.value[c].full) || '';
async function loadNames() {
const set = new Set();
(positions.value || []).forEach(p => p.ts_code && set.add(p.ts_code));
(proposals.value || []).forEach(p => p.ts_code && set.add(p.ts_code));
(instructions.value || []).forEach(p => p.ts_code && set.add(p.ts_code));
(ledger.value || []).forEach(p => p.ts_code && set.add(p.ts_code));
const codes = [...set].filter(Boolean);
if (!codes.length) return;
const d = await call('get', '/api/names?codes=' + encodeURIComponent(codes.join(',')));
const names = (d && (d.names || (d.data && d.data.names))) || null;
if (names) nameMap.value = { ...nameMap.value, ...names };
}
const heldPositions = computed(() => (positions.value || []).filter(p => (p.total_qty || 0) > 0));
const settingKeys = ['PMS_TOTAL_SCALE', 'PMS_PORTFOLIO_CAP', 'PMS_STOCK_CAP',
'PMS_MAX_NAMES', 'PMS_CASH_RESERVE',
'PMS_DCA_MAX_RATIO', 'PMS_DCA_TRIGGERS', 'PMS_DCA_DEEP_CONFIRM',
'PMS_FILL_MAX_LOSS', 'PMS_AUTONOMY'];
const traderParams = computed(() => {
const ix = {}; settingKeys.forEach((k, i) => ix[k] = i);
return (params.value || []).filter(p => settingKeys.includes(p.key)).sort((a, b) => ix[a.key] - ix[b.key]);
});
const attention = computed(() => {
const o = ov.value || {}, out = [];
if (o.buy_halt) out.push('已暂停买入(卖出与止损不受影响)');
if (o.exec_halt) out.push('休假模式:所有自动动作暂停,仅保留对账与日报');
if (o.brake_until) out.push('组合刹车中(解除日 ' + o.brake_until + '');
if ((o.price_missing || []).length)
out.push(o.price_missing.length + ' 只取不到实时价(停牌等),其盈亏与安全垫暂不可用');
if (o.sector_ready === false) out.push('行业数据未就绪,行业类操作暂不可用');
const j = (dm.value || {}).judge;
if (j && j.available === false) out.push('研判暂不可用:补足/加仓/补仓一律降级为需你确认');
const c = wsc.value || {};
if (c.conn_state && c.conn_state !== 'ONLINE') out.push('行情/成交通道未连接(' + c.conn_state + '');
if (c.resync_required) out.push('通道待人工全量对账');
return out;
});
function scrollTo(id) { const e = document.getElementById(id); if (e) e.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
function propWhy(p) {
const h = p.hard_numbers || {}, bits = [];
if (h.rank != null) bits.push('候选榜第 ' + h.rank);
if (h.theme) bits.push('主题「' + h.theme + '」' + (h.tier ? (' · ' + h.tier) : ''));
if (h.upside != null) bits.push('预期空间 ' + (h.upside > 0 ? '+' : '') + (h.upside * 100).toFixed(0) + '%');
if (h.score != null) bits.push('评分 ' + (typeof h.score === 'number' ? h.score.toFixed(0) : h.score));
if (h.heat != null) bits.push('热度 ' + (typeof h.heat === 'number' ? h.heat.toFixed(2) : h.heat));
return bits.join(' · ') || '—';
}
function nextStep(r) {
if (r.frozen_reason && r.frozen_reason !== 'NONE') return '已冻结,暂不增持';
if (r.cushion_state === 'SOLID') return '安全垫较厚,达标可加仓';
if ((r.cushion_pct || 0) < 0) return '浮亏中,跌到评估档会考虑补仓(深档需你确认)';
return '持有观察';
}
async function promptNum(msg, title) {
try {
const { value } = await ElementPlus.ElMessageBox.prompt(msg, title, {});
const n = parseFloat(value);
if (isNaN(n)) { ElementPlus.ElMessage.warning('请输入数字'); return null; }
return n;
} catch (e) { return null; }
}
async function quickCmd(cmd_type, params, confirmMsg) {
if (confirmMsg) {
try { await ElementPlus.ElMessageBox.confirm(confirmMsg, '确认', { type: 'warning' }); }
catch (e) { return; }
}
let d = await call('post', '/api/commands', { cmd_type, params: params || {}, note: '交易员视图' });
if (!d.ok && (d.conflicts || []).length) {
try {
await ElementPlus.ElMessageBox.confirm('与在途命令冲突,仍要下达?', '命令冲突', { type: 'warning' });
d = await call('post', '/api/commands', { cmd_type, params: params || {}, note: '交易员视图', force: true });
} catch (e) { return; }
}
if (d.ok) { ElementPlus.ElMessage.success('已下达 ' + (d.command_id || '')); await loadAll(); }
else ElementPlus.ElMessage.error((d.errors || [d.error]).join('; '));
}
// 个股
function actExitStock(row) { quickCmd('EXIT_STOCK', { ts_code: row.ts_code }, '清仓 ' + nm(row.ts_code) + '?系统会在窗口内择机把它卖光。'); }
async function actReduceStock(row) {
const v = await promptNum('减到目标仓位(占总规模,%', '减仓 ' + nm(row.ts_code));
if (v != null) quickCmd('REDUCE_STOCK', { ts_code: row.ts_code, target_pct: v + '%' });
}
async function actStop(row) {
const v = await promptNum('止损价(元)', '设止损 ' + nm(row.ts_code));
if (v != null) quickCmd('SET_STOP_PRICE', { ts_code: row.ts_code, price: v });
}
async function actTargetPrice(row) {
const v = await promptNum('目标价(元)', '设目标价 ' + nm(row.ts_code));
if (v != null) quickCmd('SET_TARGET_PRICE', { ts_code: row.ts_code, price: v });
}
function actFreeze(row) {
const on = row.frozen_reason && row.frozen_reason !== 'NONE';
quickCmd(on ? 'UNFREEZE_STOCK' : 'FREEZE_STOCK', { ts_code: row.ts_code });
}
function actBlacklist(row) { quickCmd('BLACKLIST_ADD', { ts_code: row.ts_code }, '把 ' + nm(row.ts_code) + ' 加入黑名单(永不买入)?'); }
function rowMore(c, row) {
if (c === 'stop') actStop(row);
else if (c === 'target') actTargetPrice(row);
else if (c === 'freeze') actFreeze(row);
else if (c === 'black') actBlacklist(row);
}
// 组合
function pHaltBuy() { quickCmd('HALT_BUY', {}, '暂停一切新增买入并撤销在途买入指令?卖出与止损不受影响。'); }
function pResumeBuy() { quickCmd('RESUME_BUY', {}); }
function pHaltAll() { quickCmd('HALT_ALL', {}, '进入休假模式:所有自动动作暂停,只保留对账与日报?'); }
function pResumeAll() { quickCmd('RESUME_ALL', {}); }
function pLiquidate() { quickCmd('LIQUIDATE_ALL', { confirm: 'YES' }, '【一键清仓】将把全部持仓按最快节奏卖出、不做择时优化。确认?'); }
async function pReduce() { const v = await promptNum('降低敞口(占总规模,%', '降仓'); if (v != null) quickCmd('REDUCE_EXPOSURE', { pct: v + '%' }); }
async function pIncrease() { const v = await promptNum('提高敞口(占总规模,%', '升仓'); if (v != null) quickCmd('INCREASE_EXPOSURE', { pct: v + '%' }); }
async function pSectorExit() {
let name; try { name = (await ElementPlus.ElMessageBox.prompt('要清仓的行业名称', '清仓某行业', {})).value; } catch (e) { return; }
if (name) quickCmd('SECTOR_EXIT', { sector: name }, '清仓行业「' + name + '」的全部持仓?');
}
// ===================== /交易员视图 =====================
async function call(method, url, body) {
try {
const r = await api[method](url, body);
if (r.data && r.data.ok === false) { err.value = r.data.error || '请求失败'; }
return r.data;
} catch (e) { err.value = url + ' → ' + e.message; return { ok: false, error: e.message }; }
}
async function loadParams() {
const d = await call('get', '/api/params');
const list = ((d.data || d).params) || [];
params.value = list;
paramsRaw.value = Object.fromEntries(list.map(p => [p.key, p.value]));
}
async function saveParams() {
const items = params.value
.filter(p => String(p.value) !== String(paramsRaw.value[p.key]))
.map(p => ({ key: p.key, value: p.value }));
const d = await call('post', '/api/params', { items });
const bad = (d.results || []).filter(r => !r.ok);
if (bad.length) ElementPlus.ElMessage.error(bad.map(b => b.error).join('; '));
else ElementPlus.ElMessage.success('已保存 ' + items.length + ' 项');
await loadParams(); await loadOverview();
}
async function loadOverview() {
const d = await call('get', '/api/overview'); ov.value = d.data || d || {};
health.value = await call('get', '/health');
}
async function loadCatalog() {
const d = await call('get', '/api/commands/catalog');
catalog.value = ((d.data || d).commands) || [];
}
async function loadCommands() {
const d = await call('get', '/api/commands?limit=60'); commands.value = d.data || [];
}
async function loadPositions() {
const d = await call('get', '/api/positions'); positions.value = d.positions || [];
}
async function loadInstructions() {
const d = await call('get',
'/api/instructions?status=PROPOSED,RULE_PASSED,JUDGE_PASSED,DISPATCHED&limit=100');
instructions.value = d.data || [];
}
async function loadLedger() {
const d = await call('get', '/api/ledger?limit=60'); ledger.value = d.data || [];
}
async function loadProposals() {
const d = await call('get', '/api/proposals'); proposals.value = d.data || [];
}
async function loadPlans(cid) {
plansOf.value = cid;
const d = await call('get', '/api/plans?command_id=' + cid); plans.value = d.data || [];
}
async function loadLots(code) {
lotsOf.value = code;
const d = await call('get', '/api/positions/' + code + '/lots?status='); lots.value = d.data || [];
}
async function loadDispatchMode() {
const d = await call('get', '/api/dispatch-mode'); dm.value = d.data || d || {};
}
async function loadWs() {
const d = await call('get', '/api/ws-channel?limit=60');
wsRaw.value = d.data || d || {};
}
// 只在 ws 页签可见时轮询。3 秒一次对一个只读接口不算什么, 但让它在后台一直跑
// 会把日志和连接数刷得很难看, 排查问题时反而碍事。
function wsPollSync() {
const want = tab.value === 'ws' && wsAuto.value;
if (want && !wsTimer) { loadWs(); wsTimer = setInterval(loadWs, 3000); }
if (!want && wsTimer) { clearInterval(wsTimer); wsTimer = null; }
}
watch([tab, wsAuto], wsPollSync);
onUnmounted(() => { if (wsTimer) clearInterval(wsTimer); });
async function loadAll() {
loading.value = true; err.value = '';
await Promise.all([loadOverview(), loadParams(), loadCatalog(), loadCommands(),
loadPositions(), loadInstructions(), loadLedger(), loadProposals(),
loadDispatchMode(), loadWs()]);
await loadNames();
loading.value = false;
wsPollSync();
}
async function cancelIns(iid) {
try {
await ElementPlus.ElMessageBox.confirm('撤销指令 ' + iid + '?', '确认',
{ type: 'warning' });
} catch (e) { return; }
const d = await call('post', '/api/instructions/' + iid + '/cancel', {});
ElementPlus.ElMessage[(d.ok ? 'success' : 'error')](d.message || d.error);
await Promise.all([loadInstructions(), loadLedger()]);
}
function onCmdChange() {
form.params = {};
const s = curSpec.value; if (!s) return;
Object.entries(s.fields).forEach(([k, f]) => {
if (f.default !== undefined) form.params[k] = f.default;
if (f.type === 'enum' && f.choices && f.choices.length === 1) form.params[k] = f.choices[0];
});
}
function buildParams() {
const s = curSpec.value, out = {};
Object.entries(s.fields).forEach(([k, f]) => {
let v = form.params[k];
if (v === undefined || v === null || v === '') return;
out[k] = (f.type === 'pct') ? (String(v).trim() + '%') : v;
});
return out;
}
async function issue(force) {
const s = curSpec.value;
if (!s) { ElementPlus.ElMessage.warning('请先选择命令'); return; }
if (s.danger && !force) {
try {
await ElementPlus.ElMessageBox.confirm(
'【' + s.label + '】是高危命令, 将对全部持仓生效。确认执行?', '二次确认',
{ type: 'warning', confirmButtonText: '确认执行', cancelButtonText: '取消' });
} catch (e) { return; }
}
issuing.value = true;
const body = { cmd_type: s.cmd_type, params: buildParams(), note: form.note, force: !!force };
const d = await call('post', '/api/commands', body);
issuing.value = false;
if (d.ok) {
ElementPlus.ElMessage.success('命令 ' + d.command_id + ' 已下达 · ' + (d.status || ''));
const notes = ((d.plan || {}).notes || []); if (notes.length)
ElementPlus.ElMessage.info(notes.join(' | '));
err.value = '';
await Promise.all([loadCommands(), loadOverview(), loadPositions(), loadParams()]);
} else if ((d.conflicts || []).length) {
const txt = d.conflicts.map(c => c.with_cmd_type + '(' + c.with_command_id + '): ' + c.reason)
.join('\n');
try {
await ElementPlus.ElMessageBox.confirm(
'与在途命令冲突:\n' + txt + '\n\n命令冲突由用户裁决 —— 仍要并行下达?', '命令冲突',
{ type: 'warning', confirmButtonText: '仍然下达', cancelButtonText: '放弃' });
await issue(true);
} catch (e) { /* 用户放弃 */ }
} else {
ElementPlus.ElMessage.error((d.errors || [d.error]).join('; '));
}
}
async function cancelCmd(cid) {
const d = await call('post', '/api/commands/' + cid + '/cancel');
ElementPlus.ElMessage[(d.ok ? 'success' : 'error')](d.message || d.error);
await loadCommands();
}
async function replan(cid) {
const d = await call('post', '/api/commands/' + cid + '/replan');
ElementPlus.ElMessage[(d.ok ? 'success' : 'error')](d.ok ? '已重新生成方案' :
(d.errors || [d.error]).join('; '));
await loadCommands(); if (plansOf.value === cid) await loadPlans(cid);
}
async function decide(row, decision) {
const d = await call('post', '/api/proposals/' + row.proposal_id + '/decide', { decision });
if (d.ok) ElementPlus.ElMessage.success(decision === 'ACCEPTED'
? ('已采纳, 指令 ' + d.instruction_id) : '已驳回');
else ElementPlus.ElMessage.error(d.error);
await Promise.all([loadProposals(), loadInstructions(), loadLedger()]);
}
async function ops(name) {
opsLoading.value = true;
const d = await call('post', '/api/ops/' + name);
opsResult.value = JSON.stringify(d, null, 2); opsLoading.value = false;
await loadAll();
}
async function loadSchema() {
opsLoading.value = true;
const d = await call('get', '/api/ops/downstream-schema');
opsResult.value = JSON.stringify(d, null, 2); opsLoading.value = false;
}
async function loadPlan() {
planLoading.value = true;
const d = await call('get', '/api/upstream/plan?limit=50');
planRaw.value = (d && d.ok) ? d : { status: { ok: false, hint: (d||{}).error || '取数失败' } };
planLoading.value = false;
}
async function loadChanges() {
chgLoading.value = true;
const d = await call('get', '/api/upstream/plan-changes?limit=50');
chgRaw.value = (d && d.ok) ? d
: { changes: { ok: false, hint: (d || {}).error || '取数失败' }, log: {} };
chgLoading.value = false;
}
async function openPlan() { planDrawer.value = true; await Promise.all([loadPlan(), loadChanges()]); }
async function refreshPlan() {
planLoading.value = true;
const d = await call('post', '/api/ops/plan-refresh');
planResult.value = JSON.stringify(d, null, 2);
planLoading.value = false;
await Promise.all([loadPlan(), loadChanges(), loadOverview()]);
}
async function openReport() {
const d = await call('get', '/api/report'); report.value = d.data || d || {};
reportDrawer.value = true;
}
onMounted(() => { loadAll(); loadPlan(); }); // 计划状态并行拉, 不阻塞首屏
return { tab, loading, err, health, ov, params, catalog, commands, plans, plansOf,
positions, lots, lotsOf, instructions, ledger, proposals, report, reportDrawer,
opsDrawer, opsResult, opsLoading, issuing, form, curSpec, dirtyCount, dm,
money, pct, groupLabel, fieldLabel, stTag, cuTag, canCancel, progPct, insPct,
scaleGap,
wsRaw, wsAuto, wsHidePong, wsc, wst, wsMode, wsOrders, wsInbox, wsWarnings, loadWs,
loadAll, loadParams, saveParams, loadPlans, loadLots, onCmdChange, issue,
cancelCmd, replan, decide, ops, loadSchema, openReport, cancelIns,
planDrawer, planLoading, planStatus, planRows, planCand, planResult,
loadPlan, openPlan, refreshPlan,
chgRaw, chgSt, chgLoading, chgTab, chg, chgHeld, chgLog, chgRevised, chgWatch,
loadChanges,
mode, tx, nm, fullName, nameMap, attention, heldPositions, traderParams,
propWhy, nextStep, scrollTo, actExitStock, actReduceStock, rowMore,
pHaltBuy, pResumeBuy, pHaltAll, pResumeAll, pReduce, pIncrease, pLiquidate, pSectorExit };
}
}).use(ElementPlus).mount('#app');
</script>
</body>
</html>