2026-07-27 17:12:09 +08:00
<!DOCTYPE html>
2026-08-12 14:51:22 +08:00
< html lang = "zh-CN" data-theme = "light" >
2026-07-27 17:12:09 +08:00
< 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" / >
< 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 >
2026-08-12 14:51:22 +08:00
:root, html[data-theme=light]{
--plane:#f4f5f2; --surface:#ffffff; --surface-2:#fafaf8; --ink:#141412; --ink-2:#54534f; --muted:#8a8882;
--hair:#e7e6df; --ring:rgba(17,17,16,.10); --accent:#2a78d6; --accent-weak:#ecf3fd;
--up:#d5342f; --up-weak:#fbeceb; --down:#158a4c; --down-weak:#e7f4ec;
--warn:#9a6a00; --warn-weak:#fbf1d6; --stop:#b4552b; --stop-weak:#fbeadf;
--shadow:0 1px 2px rgba(17,17,16,.04),0 2px 8px rgba(17,17,16,.045);
}
[v-cloak]{display:none;}
body{margin:0;background:var(--plane);color:var(--ink);
font-family:system-ui,-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;}
.app{max-width:1280px;margin:0 auto;padding:0 20px 44px;}
.mono{font-family:ui-monospace,Menlo,Consolas,monospace;font-variant-numeric:tabular-nums;}
.muted{color:var(--muted);font-size:12.5px;}
.nowrap{white-space:nowrap;}
.spacer{flex:1;}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
.clr-up{color:var(--up);} .clr-down{color:var(--down);}
/* 顶栏 */
.hd{position:sticky;top:0;z-index:30;display:flex;align-items:center;gap:9px;flex-wrap:wrap;
margin:0 -20px 6px;padding:10px 20px;background:rgba(244,245,242,.92);backdrop-filter:blur(8px);border-bottom:1px solid var(--hair);}
.hd h1{font-size:15px;margin:0;font-weight:680;letter-spacing:.2px;}
.hd .ver{color:var(--muted);font-size:12px;font-weight:400;margin-left:6px;}
.seg{display:inline-flex;border:1px solid var(--hair);border-radius:8px;overflow:hidden;background:var(--surface);}
.seg button{border:0;background:transparent;color:var(--ink-2);padding:5px 12px;font-size:13px;cursor:pointer;}
.seg button.on{background:var(--accent);color:#fff;}
.tbtn{border:1px solid var(--hair);background:var(--surface);color:var(--ink);border-radius:8px;padding:6px 12px;font-size:13px;cursor:pointer;font-weight:520;}
.tbtn:hover{border-color:var(--accent);color:var(--accent);}
.tbtn.pri{background:var(--accent);border-color:var(--accent);color:#fff;}
.tbtn.pri:hover{color:#fff;}
.tbtn.sm{padding:3px 10px;font-size:12.5px;}
/* 状态胶囊(系统状态:不用红绿) */
.chip{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:560;padding:3px 9px;border-radius:999px;white-space:nowrap;}
.chip .dot{width:6px;height:6px;border-radius:50%;flex:none;}
.chip.ok{background:var(--accent-weak);color:var(--accent);} .chip.ok .dot{background:var(--accent);}
.chip.warn{background:var(--warn-weak);color:var(--warn);} .chip.warn .dot{background:var(--warn);}
.chip.idle{background:rgba(138,136,130,.15);color:var(--ink-2);} .chip.idle .dot{background:var(--muted);}
.chip.stop{background:var(--stop-weak);color:var(--stop);} .chip.stop .dot{background:var(--stop);}
.help{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;border-radius:50%;
border:1px solid var(--muted);color:var(--muted);font-size:10px;cursor:help;position:relative;font-weight:600;margin-left:3px;}
.help:hover::after{content:attr(data-tip);position:absolute;left:0;top:150%;width:250px;background:var(--ink);color:#fff;
font-size:12px;font-weight:400;line-height:1.5;padding:8px 10px;border-radius:8px;z-index:50;box-shadow:0 6px 20px rgba(0,0,0,.22);}
/* KPI 焦点行 */
.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(148px,1fr));gap:12px;margin:16px 0;}
.m{background:var(--surface);border:1px solid var(--hair);border-radius:12px;padding:12px 15px;box-shadow:var(--shadow);}
.m .k{color:var(--muted);font-size:12px;}
.m .v{color:var(--ink);font-size:21px;font-weight:680;margin-top:5px;letter-spacing:-.2px;font-variant-numeric:tabular-nums;}
.m .v .muted,.m .v span{font-weight:500;}
.m .v.up{color:var(--up);} .m .v.down{color:var(--down);}
/* 颜色约定图例 */
.legend{display:flex;gap:15px;flex-wrap:wrap;align-items:center;color:var(--muted);font-size:12px;margin:0 2px 16px;}
.legend .sw{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:4px;vertical-align:middle;}
/* 卡片/面板 */
.panel{background:var(--surface);border:1px solid var(--hair);border-radius:14px;padding:16px 18px;margin-bottom:16px;box-shadow:var(--shadow);}
.panel h3,.tblk h3{margin:0 0 10px;font-size:15px;color:var(--ink);font-weight:660;}
.sub2{color:var(--muted);font-size:12.5px;margin:-4px 0 12px;}
.banner{margin:10px 0;}
.attn{color:var(--warn);padding:4px 0;}
.tcard{background:var(--surface-2);border:1px solid var(--hair);border-radius:11px;padding:12px 14px;margin-bottom:10px;}
.feed-row{padding:5px 0;border-bottom:1px solid var(--hair);font-size:13px;}
.feed-row:last-child{border-bottom:none;}
.tag{display:inline-block;font-size:11.5px;font-weight:560;padding:2px 8px;border-radius:6px;background:rgba(138,136,130,.14);color:var(--ink-2);}
.tier{font-size:12px;color:var(--muted);}
/* 处置胶囊(候选表) */
.disp{display:inline-flex;align-items:center;gap:3px;font-size:12px;font-weight:600;padding:3px 9px;border-radius:7px;}
.disp.done{background:var(--accent-weak);color:var(--accent);}
.disp.queue{background:var(--warn-weak);color:var(--warn);}
.disp.deny{background:var(--stop-weak);color:var(--stop);}
.disp.would{background:var(--surface);color:var(--accent);border:1px solid var(--accent);}
.disp.wait{background:rgba(138,136,130,.14);color:var(--ink-2);}
.why{color:var(--ink-2);font-size:12.5px;margin-top:3px;line-height:1.45;}
.pool-note{margin:0 0 12px;padding:9px 12px;background:var(--surface-2);border:1px solid var(--hair);border-radius:9px;color:var(--ink-2);font-size:12.5px;}
.tabn{color:var(--muted);font-weight:600;font-size:12px;margin-left:3px;}
/* 组合操作 / 设置 */
.op-row{display:flex;align-items:flex-start;gap:12px;padding:9px 0;border-bottom:1px solid var(--hair);}
.op-row:last-child{border-bottom:none;}
.op-btn{flex:none;width:150px;} .op-btn .el-button{width:100%;}
.op-row .d{color:var(--ink-2);font-size:12.5px;line-height:1.55;padding-top:3px;}
.set-card{border:1px solid var(--hair);border-radius:10px;padding:12px 14px;margin-bottom:10px;background:var(--surface-2);}
.set-sum{color:var(--ink);font-size:13px;margin-bottom:8px;line-height:1.6;}
.set-fields{display:flex;flex-wrap:wrap;gap:10px 16px;align-items:center;color:var(--ink-2);font-size:12px;}
pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius:8px;padding:10px;max-height:420px;overflow:auto;font-size:12px;color:var(--ink-2);}
/* Element Plus 浅色微调 */
.el-table{background:transparent!important;font-size:13px;
--el-table-border-color:var(--hair);--el-table-header-bg-color:var(--surface-2);
--el-table-row-hover-bg-color:var(--surface-2);--el-table-bg-color:transparent;--el-table-tr-bg-color:transparent;}
.el-table th.el-table__cell{background:var(--surface-2)!important;color:var(--muted)!important;font-weight:560;}
.el-table td.el-table__cell,.el-table th.el-table__cell{padding:10px 0;border-color:var(--hair)!important;}
.el-table .cell{color:var(--ink);line-height:1.5;}
.el-tabs__item{font-size:14px;color:var(--muted);font-weight:560;}
.el-tabs__item.is-active{color:var(--ink);}
.el-tabs__active-bar{background:var(--accent);}
.el-tabs__nav-wrap::after{background:var(--hair);height:1px;}
2026-08-12 15:55:29 +08:00
.pos-exp{display:grid;grid-template-columns:minmax(0,540px) minmax(0,1fr);gap:26px;padding:10px 16px 12px;}
.pos-exp .exp-h{font-size:13px;font-weight:600;color:var(--ink-2);margin-bottom:8px;}
.exp-led{padding:5px 0;border-bottom:1px solid var(--hair);font-size:12.5px;line-height:1.5;}
.exp-led:last-child{border-bottom:none;}
@media(max-width:900px){.pos-exp{grid-template-columns:1fr;gap:14px;}}
2026-08-13 10:45:04 +08:00
.up-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(400px,1fr));gap:16px;}
.up-cell{border:1px solid var(--hair);border-radius:11px;padding:12px 14px;background:var(--surface-2);}
.up-h{font-size:13px;font-weight:640;color:var(--ink-2);margin-bottom:8px;}
.up-h .src-err{color:var(--stop);font-weight:400;font-size:11.5px;}
.mr-two{display:grid;grid-template-columns:1fr 1fr;gap:16px;font-size:12.5px;}
.mr-row{padding:3px 0;border-bottom:1px solid var(--hair);}
.mr-row:last-child{border-bottom:none;}
2026-08-13 11:25:34 +08:00
/* 顶部风控告警条 (系统状态色: 琥珀/橙/灰, 不用红绿; 红绿只留给方向看涨看跌) */
.alert-bar{display:flex;align-items:center;gap:9px;background:var(--surface);border:1px solid var(--hair);
border-left:3px solid var(--hair);border-radius:12px;padding:9px 14px;margin:16px 0 8px;box-shadow:var(--shadow);}
.alert-bar.lv-danger{border-left-color:var(--stop);}
.alert-bar.lv-warn{border-left-color:var(--warn);}
.alert-bar .ab-title{font-weight:640;font-size:13px;color:var(--ink-2);white-space:nowrap;display:flex;align-items:center;gap:6px;}
.alert-bar .ab-badge{font-size:11px;font-weight:600;padding:1px 8px;border-radius:999px;background:rgba(138,136,130,.16);color:var(--ink-2);}
.alert-bar.lv-danger .ab-badge{background:var(--stop-weak);color:var(--stop);}
.alert-bar.lv-warn .ab-badge{background:var(--warn-weak);color:var(--warn);}
.alert-bar .ab-latest{flex:1;min-width:0;font-size:13px;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.alert-bar .ab-toggle{flex:none;color:var(--accent);font-size:12.5px;font-weight:560;cursor:pointer;background:none;border:0;padding:4px 4px;}
.alert-bar .ab-empty{flex:1;color:var(--muted);font-size:12.5px;}
.alert-dot{width:7px;height:7px;border-radius:50%;flex:none;background:var(--muted);}
.alert-dot.lv-danger{background:var(--stop);} .alert-dot.lv-warn{background:var(--warn);}
.alert-panel{margin-top:0;}
2026-08-13 13:12:10 +08:00
.alert-wins{display:grid;grid-template-columns:repeat(auto-fit,minmax(430px,1fr));gap:14px;margin:2px 0 16px;}
.alert-win{border:1px solid var(--hair);border-radius:12px;padding:10px 14px 12px;background:var(--surface);box-shadow:var(--shadow);}
.alert-win .aw-h{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px;}
.alert-win .aw-title{font-size:13px;font-weight:640;color:var(--ink-2);}
.alert-win .aw-toggle{font-size:11px;font-weight:560;color:var(--accent);cursor:pointer;border:1px solid var(--accent);border-radius:999px;padding:1px 9px;background:var(--accent-weak);white-space:nowrap;}
.alert-win .aw-toggle.off{color:var(--muted);border-color:var(--hair);background:var(--surface-2);}
2026-08-14 09:41:33 +08:00
/* 陈旧告警置灰 (超龄不隐藏, 只灰: 风控面板不丢信息)。灰=陈旧, 不动红绿(红绿只留给方向)。 */
.alert-bar .ab-stale{flex:none;font-size:11px;color:var(--muted);border:1px solid var(--hair);border-radius:999px;padding:0 7px;}
.alert-win .el-table__row.alert-stale{opacity:.5;}
.alert-win .el-table__row.alert-stale:hover{opacity:.72;}
.alert-age{color:var(--muted);font-size:10.5px;margin-left:5px;}
2026-07-27 17:12:09 +08:00
< / style >
< / head >
< body >
< div id = "app" class = "app" v-cloak >
< div class = "hd" >
2026-08-12 14:51:22 +08:00
< h1 > PMS 持仓管理< span class = "ver" > v{{ health.version || '-' }} · {{ health.now || '' }}< / span > < / h1 >
2026-08-13 10:00:46 +08:00
< span class = "chip" :class = "marketOpen?'ok':'idle'" > < span class = "dot" > < / span > {{ marketState }}< / span >
2026-08-12 14:51:22 +08:00
< span class = "chip" :class = "ov.buy_halt?'warn':'ok'" > < span class = "dot" > < / span > 买入 {{ ov.buy_halt ? '已暂停':'正常' }}< / span >
< span class = "chip" :class = "ov.exec_halt?'warn':'ok'" > < span class = "dot" > < / span > 执行 {{ ov.exec_halt ? '已暂停':'正常' }}< / span >
< span class = "chip" :class = "ov.autonomy==='off'?'warn':'ok'" > < span class = "dot" > < / span > 自主 {{ tx('autonomy', ov.autonomy) }}< span class = "help" data-tip = "系统自己出手的程度:全自动=闸门通过就执行;只提议=等你在『等我拍板』确认;暂停=不自动动作。" > ?< / span > < / span >
< span class = "chip" :class = "planStatus.ok===false?'warn':'ok'" > < span class = "dot" > < / span > 计划 {{ planStatus.ok===false ? '不可用' : (planStatus.date || '-') }}< / span >
< span class = "chip" :class = "dm.shadow?'idle':'ok'" v-show = "mode==='ops'" > < span class = "dot" > < / span > 下发 {{ dm.mode || '-' }}< span class = "help" data-tip = "实盘=真发到 QMT 下单;影子=只记录不真下单,用来观察。" > ?< / span > < / span >
2026-07-27 17:12:09 +08:00
< div class = "spacer" > < / div >
2026-08-12 14:51:22 +08:00
< div class = "seg" >
< button :class = "{on:mode==='trader'}" @ click = "mode='trader'" > 交易员< / button >
< button :class = "{on:mode==='ops'}" @ click = "mode='ops'" > 运维< / button >
< / div >
< button class = "tbtn" @ click = "openPlan" > 上游计划< / button >
< button class = "tbtn" @ click = "openReport" > 日报< / button >
< button class = "tbtn" v-show = "mode==='ops'" @ click = "opsDrawer=true" > 运维< / button >
2026-08-13 10:00:46 +08:00
< span class = "muted" style = "font-size:11.5px;cursor:pointer" @ click = "autoRefresh=!autoRefresh"
:title="autoRefresh?'自动刷新开,点此关闭':'自动刷新关,点此开启'">{{ autoRefresh ? '自动' : '手动' }}< span v-if = "lastRefresh" > · {{ lastRefresh }}< / span > < / span >
2026-08-12 14:51:22 +08:00
< button class = "tbtn pri" :disabled = "loading" @ click = "loadAll" > {{ loading ? '刷新中…' : '刷新' }}< / button >
2026-07-27 17:12:09 +08:00
< / div >
< el-alert v-if = "err" class = "banner" :title = "err" type = "error" effect = "dark" show-icon
2026-07-29 10:01:00 +08:00
@close="err=''">< / el-alert >
2026-07-30 16:21:16 +08:00
< el-alert v-if = "planStatus.ok===false" class = "banner" type = "error" effect = "dark" show-icon
:closable="false"
:title="'上游选股计划不可用 —— 升仓/建仓类命令将无票可选: ' + (planStatus.hint||'')">
< / el-alert >
2026-07-27 17:12:09 +08:00
< el-alert v-if = "ov.sector_ready===false" class = "banner" type = "warning" effect = "dark" show-icon
:closable="false"
2026-07-29 10:01:00 +08:00
title="行业划分数据源未配置 —— 行业集中度硬拦截已停用, 行业类命令置灰 (设计 §5)">< / el-alert >
2026-08-13 10:00:46 +08:00
< el-alert v-if = "marketOpen && ov.price_missing && ov.price_missing.length" class = "banner" type = "warning"
show-icon :closable="false"
:title="'以下持仓取不到实时价(停牌或行情中断),已用摊薄成本兜底: ' + ov.price_missing.join(', ')">< / el-alert >
< el-alert v-if = "!marketOpen && ov.price_missing && ov.price_missing.length" class = "banner" type = "info"
show-icon :closable="false"
:title="'现在' + marketState + ',行情未刷新;持仓盈亏与安全垫按最近收盘价/成本估算,不是实时。'">< / el-alert >
2026-07-27 17:12:09 +08:00
< el-alert v-if = "health.calendar_degraded" class = "banner" type = "warning" effect = "dark" show-icon
2026-07-29 10:01:00 +08:00
:closable="false" title="交易日历降级: 未安装 chinesecalendar, 节假日不可辨">< / el-alert >
2026-07-28 09:10:07 +08:00
< el-alert v-if = "dm.shadow" class = "banner" type = "info" effect = "dark" show-icon :closable = "false"
2026-07-29 10:01:00 +08:00
:title="'影子运行中 —— ' + (dm.hint || '')">< / el-alert >
2026-07-27 17:12:09 +08:00
2026-07-30 13:02:51 +08:00
< 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 >
2026-08-10 15:09:29 +08:00
<!-- ==================== 交易员视图 ==================== -->
< div v-show = "mode==='trader'" >
2026-08-13 13:12:10 +08:00
<!-- 0) 顶部风控告警条 (折叠态常驻最新一条; 展开后按类型分窗, 每窗可切「折叠条显示」) -->
2026-08-13 11:25:34 +08:00
< div class = "alert-bar" :class = "'lv-'+alertLevelClass" >
< span class = "ab-title" > < span class = "alert-dot" :class = "'lv-'+alertLevelClass" > < / span > 风控告警< / span >
< template v-if = "alertItems.length" >
2026-08-13 13:12:10 +08:00
< span class = "ab-badge" > {{ alertStripCount }} 条< span v-if = "alertTotalCount>alertStripCount" class = "muted" style = "font-weight:400" > / 全部 {{ alertTotalCount }}< / span > < / span >
< span class = "ab-latest" v-if = "alertLatest.ts_code" > 最新 ·
2026-08-13 11:25:34 +08:00
< b > {{ nm(alertLatest.ts_code) }}< / b > < span class = "muted mono" > {{ alertLatest.ts_code }}< / span >
2026-08-13 15:37:21 +08:00
< span v-if = "alertLatest.direction && alertLatest.direction!=='—'" :class = "['看涨','涨'].includes(alertLatest.direction)?'clr-up':'clr-down'" > {{ alertLatest.direction }}< / span >
2026-08-13 13:12:10 +08:00
< span class = "muted" > {{ alertLatest.cat_label }}< / span >
2026-08-13 11:25:34 +08:00
< span class = "disp" :class = "alertLatest.level==='DANGER'?'deny':(alertLatest.level==='WARNING'?'queue':'wait')" style = "padding:1px 7px" > {{ alertLatest.level }}< / span >
2026-08-14 09:41:33 +08:00
< span class = "muted" > {{ alertLatest.time }}< template v-if = "ageText(alertLatest)" > · {{ ageText(alertLatest) }}< / template > < / span >
< span v-if = "alertLatest.stale" class = "ab-stale" title = "超过告警时间窗, 已置灰标注为陈旧" > 陈旧< / span >
2026-08-13 11:25:34 +08:00
< / span >
2026-08-13 13:12:10 +08:00
< span class = "ab-latest muted" v-else > · 折叠条已隐藏当前类型(展开后可切回)< / span >
2026-08-13 11:25:34 +08:00
< button class = "ab-toggle" @ click = "showAlerts=!showAlerts" > {{ showAlerts?'收起 ▲':'展开全部 ▼' }}< / button >
< / template >
< template v-else-if = "usrc('alerts') && usrc('alerts').ok===false" >
< span class = "ab-empty" > · 读取失败:{{ usrc('alerts').error }}< / span >
< / template >
< template v-else >
< span class = "ab-empty" > · 暂无风控告警< / span >
< / template >
< / div >
2026-08-13 13:12:10 +08:00
< div class = "alert-wins" v-if = "showAlerts && alertItems.length" >
< div class = "alert-win" v-for = "g in alertGroups" :key = "g.key" >
< div class = "aw-h" >
< span class = "aw-title" > {{ g.label }} < span class = "muted" style = "font-weight:400" > · {{ g.items.length }}< / span > < / span >
< span class = "aw-toggle" :class = "{off:!catShown(g.key)}" @ click = "toggleCat(g.key)" :title = "catShown(g.key)?'点此不在顶部折叠条里显示这类':'点此让这类回到顶部折叠条'" > {{ catShown(g.key) ? '折叠条显示 ✓' : '折叠条隐藏' }}< / span >
< / div >
2026-08-14 09:41:33 +08:00
< el-table :data = "g.items.slice(0,20)" size = "small" max-height = "240" empty-text = "暂无" :row-class-name = "alertRowClass" >
2026-08-13 13:12:10 +08:00
< el-table-column label = "股票" min-width = "140" > < template # default = "s" > < b > {{ nm(s.row.ts_code) }}< / b > < span class = "muted mono" > {{ s.row.ts_code }}< / span > < / template > < / el-table-column >
2026-08-13 15:37:21 +08:00
< el-table-column label = "方向" width = "58" > < template # default = "s" > < span v-if = "s.row.direction && s.row.direction!=='—'" :class = "['看涨','涨'].includes(s.row.direction)?'clr-up':'clr-down'" > {{ s.row.direction }}< / span > < span v-else class = "muted" > —< / span > < / template > < / el-table-column >
2026-08-13 13:12:10 +08:00
< el-table-column label = "级别" width = "86" > < template # default = "s" > < span class = "disp" :class = "s.row.level==='DANGER'?'deny':(s.row.level==='WARNING'?'queue':'wait')" > {{ s.row.level }}< / span > < / template > < / el-table-column >
< el-table-column label = "来源" min-width = "150" > < template # default = "s" > < span class = "muted" style = "font-size:11px" > {{ s.row.source }}< / span > < / template > < / el-table-column >
2026-08-14 09:41:33 +08:00
< el-table-column label = "时间" width = "120" align = "right" > < template # default = "s" > < span class = "mono muted" > {{ s.row.time }}< / span > < span v-if = "ageText(s.row)" class = "alert-age" > {{ ageText(s.row) }}< / span > < / template > < / el-table-column >
2026-08-13 13:12:10 +08:00
< / el-table >
< / div >
2026-08-13 11:25:34 +08:00
< / div >
2026-08-10 15:09:29 +08:00
<!-- 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 >
2026-08-12 14:51:22 +08:00
< div class = "legend" >
< span > 颜色约定:< / span >
< span > < i class = "sw" style = "background:var(--up)" > < / i > < b class = "clr-up" > 红=涨/盈< / b > < / span >
< span > < i class = "sw" style = "background:var(--down)" > < / i > < b class = "clr-down" > 绿=跌/亏< / b > < / span >
< span > < i class = "sw" style = "background:var(--warn)" > < / i > < b style = "color:var(--warn)" > 琥珀=需注意< / b > < / span >
< span > < i class = "sw" style = "background:var(--stop)" > < / i > < b style = "color:var(--stop)" > 橙=被拦下< / b > < / span >
< span > < i class = "sw" style = "background:var(--accent)" > < / i > < b style = "color:var(--accent)" > 蓝=可操作/正常< / b > < / span >
< span class = "help" data-tip = "红绿只用于价格涨跌和盈亏;系统状态(正常/暂停/过期/否决)改用蓝/琥珀/橙/灰,避免看混。" > ?< / span >
< / div >
2026-08-10 15:09:29 +08:00
< 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 >
2026-08-12 15:10:56 +08:00
< div style = "margin:6px 0" v-if = "p.judge_reason" > < span class = "muted" > 决策系统:< / span > {{ p.judge_reason }}< / div >
2026-08-10 15:09:29 +08:00
< 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 >
2026-08-11 15:38:19 +08:00
< el-table :data = "heldPositions" size = "small" border max-height = "460" @ expand-change = "onPosExpand" >
2026-08-10 15:09:29 +08:00
< el-table-column type = "expand" >
< template # default = "s" >
2026-08-12 15:55:29 +08:00
< div class = "pos-exp" >
< div >
< div class = "exp-h" > 持仓批次< / div >
< el-table v-if = "lotsOf===s.row.ts_code && lots.length" :data = "lots" size = "small" >
< el-table-column label = "批次" min-width = "84" > < template # default = "l" > {{ tx('lot', l.row.lot_type) }}< / template > < / el-table-column >
< el-table-column prop = "qty" label = "剩余" width = "72" align = "right" > < / el-table-column >
< el-table-column prop = "open_price" label = "开仓价" width = "80" align = "right" > < / el-table-column >
< el-table-column prop = "open_date" label = "开仓日" width = "104" > < / el-table-column >
< el-table-column prop = "realized_pnl" label = "已实现" width = "84" align = "right" > < / el-table-column >
< / el-table >
< div class = "muted" v-else-if = "lotsOf===s.row.ts_code" style = "padding:4px 0" > 无持仓批次< / div >
< div class = "muted" v-else style = "padding:4px 0" > 载入中…< / div >
< / div >
< div >
< div class = "exp-h" > 该股账本< / div >
< div class = "muted" v-if = "!ledger.filter(x=>x.ts_code===s.row.ts_code).length" style = "padding:4px 0" > 今日暂无记录< / div >
< div v-for = "(l,i) in ledger.filter(x=>x.ts_code===s.row.ts_code)" :key = "i" class = "exp-led" >
< span class = "muted mono" > {{ (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 >
2026-08-10 15:09:29 +08:00
< / 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) }}
2026-08-12 15:55:29 +08:00
< div v-if = "s.row.frozen_reason && s.row.frozen_reason!=='NONE'" style = "color:var(--warn)" > {{ tx('frozen', s.row.frozen_reason) }}< / div > < / template >
2026-08-10 15:09:29 +08:00
< / 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 >
2026-08-11 11:58:16 +08:00
< el-dropdown-item command = "strategy" divided > 挂交易方案( 做T/网格/跟踪止盈)< / el-dropdown-item >
2026-08-10 15:09:29 +08:00
< / el-dropdown-menu >
< / template >
< / el-dropdown >
< / template >
< / el-table-column >
< / el-table >
< / div >
<!-- 4) 今日在办与动向 -->
< div class = "panel tblk" >
< h3 > 今日在办< / h3 >
2026-08-12 14:51:22 +08:00
< div class = "sub2" > 今天下出去的买卖单,按状态分三档:还在跑的、已成交的、已经结束的。< / div >
< el-tabs v-model = "insTab" >
< el-tab-pane name = "live" >
< template # label > 在办中 < span class = "tabn" > {{ insLive.length }}< / span > < / template >
< el-table :data = "insLive" size = "small" max-height = "260" empty-text = "没有在办的买卖" >
< el-table-column label = "股票" min-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 = "120" align = "right" > < template # default = "s" > < span class = "mono" > {{ s.row.qty }} / {{ s.row.exec_qty||0 }}< / span > < / template > < / el-table-column >
< el-table-column label = "限价" width = "82" align = "right" > < template # default = "s" > < span class = "mono" > {{ s.row.limit_price||'—' }}< / span > < / template > < / el-table-column >
< el-table-column label = "状态" width = "118" > < template # default = "s" > < span class = "chip idle" > < span class = "dot" > < / span > {{ tx('insStatus', s.row.status) }}< / span > < / template > < / el-table-column >
< el-table-column label = "操作" width = "76" > < template # default = "s" > < button class = "tbtn sm" @ click = "cancelIns(s.row.instruction_id)" > 撤单< / button > < / template > < / el-table-column >
< / el-table >
< / el-tab-pane >
< el-tab-pane name = "done" >
< template # label > 今日成交 < span class = "tabn" > {{ insDone.length }}< / span > < / template >
< el-table :data = "insDone" size = "small" max-height = "260" empty-text = "今天还没有成交" >
< el-table-column label = "股票" min-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 = "120" align = "right" > < template # default = "s" > < span class = "mono" > {{ s.row.qty }} / {{ s.row.exec_qty||0 }}< / span > < / template > < / el-table-column >
< el-table-column label = "状态" width = "120" > < template # default = "s" > < span class = "chip ok" > < span class = "dot" > < / span > {{ tx('insStatus', s.row.status) }}< / span > < / template > < / el-table-column >
< / el-table >
< / el-tab-pane >
< el-tab-pane name = "end" >
< template # label > 已了结 < span class = "tabn" > {{ insEnd.length }}< / span > < / template >
< el-table :data = "insEnd" size = "small" max-height = "260" empty-text = "今天没有了结的单" >
< el-table-column label = "股票" min-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 = "结束方式" min-width = "150" > < template # default = "s" > < span class = "chip warn" > < span class = "dot" > < / span > {{ tx('insStatus', s.row.status) }}< / span > < / template > < / el-table-column >
< / el-table >
< / el-tab-pane >
2026-08-12 15:31:10 +08:00
< el-tab-pane name = "deny" >
< template # label > 今日否决 < span class = "tabn" > {{ denyToday.length }}< / span > < / template >
< div class = "sub2" style = "margin:8px 2px 4px" > 今天考察过、但没建仓的票,连原因一起摆出来。< / div >
< el-table :data = "denyToday" size = "small" max-height = "320" empty-text = "今天没有被否决的候选" >
< el-table-column label = "股票" width = "180" > < template # default = "s" > < b > {{ s.row.name }}< / b > < span class = "muted mono" > {{ s.row.ts_code }}< / span > < / template > < / el-table-column >
< el-table-column label = "为什么没建仓" min-width = "440" > < template # default = "s" > < div class = "why" style = "margin:0" > {{ s.row.why || '—' }}< / div > < / template > < / el-table-column >
< / el-table >
< / el-tab-pane >
2026-08-12 14:51:22 +08:00
< / el-tabs >
2026-08-11 15:38:19 +08:00
< h3 style = "margin-top:14px;cursor:pointer" @ click = "showLedger=!showLedger" > 今天发生了什么 · {{ ledger.length }} 条 < span class = "muted" style = "font-size:12px;font-weight:normal" > {{ showLedger?'▲ 收起':'▼ 展开' }}< / span > < / h3 >
< template v-if = "showLedger" >
< div class = "muted" v-if = "!ledger.length" > 今天还没有记录。< / div >
2026-08-12 14:51:22 +08:00
< div v-for = "(l,i) in ledger" :key = "i" class = "feed-row" >
< span class = "muted mono" > {{ (l.decided_at||'').slice(11,16) }}< / span >
2026-08-11 15:38:19 +08:00
< 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 >
< / template >
2026-08-10 15:09:29 +08:00
< / div >
2026-08-11 11:58:16 +08:00
<!-- 4.5) 我的策略 + 操作日志 -->
< div class = "panel tblk" >
< h3 > 我的策略 · {{ strategies.length }} 个
2026-08-12 14:51:22 +08:00
< el-tag size = "small" :type = "stratEnabled?'primary':'info'" effect = "dark" style = "margin-left:8px" >
2026-08-11 11:58:16 +08:00
{{ stratEnabled ? '策略层已启用' : '策略层总开关未开( PMS_STRATEGY_ENABLED) ' }}< / el-tag >
< / h3 >
< el-alert v-if = "!stratEnabled" type = "info" effect = "dark" :closable = "false" show-icon style = "margin-bottom:8px"
title="策略挂了也不会出手:请在「设置 / 运维」把 PMS_STRATEGY_ENABLED 打开;影子还是实盘由 PMS_DISPATCH_MODE 决定。">< / el-alert >
< div class = "muted" v-if = "!strategies.length" > 还没有挂任何交易方案。到「我的持仓」某行「更多 ▾ → 挂交易方案」给它挂一个。< / div >
< el-table v-else :data = "strategies" size = "small" border max-height = "300" >
< 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 = "86" > < template # default = "s" > < el-tag size = "small" effect = "dark" > {{ tx('stype', s.row.type) }}< / el-tag > < / template > < / el-table-column >
< el-table-column label = "自主档" width = "86" > < template # default = "s" > {{ s.row.autonomy==='auto'?'自动执行':'待我拍板' }}< / template > < / el-table-column >
2026-08-11 15:28:46 +08:00
< el-table-column label = "实时状态" min-width = "230" > < template # default = "s" > < span class = "muted" > {{ stratStateText(s.row) }}< / span >
2026-08-12 14:51:22 +08:00
< el-tag v-if = "s.row.buy_paused" size = "small" type = "warning" effect = "dark" style = "margin-left:6px" > 买入已暂停< / el-tag >
< div v-if = "s.row.buy_paused" style = "color:#9a6a00;font-size:11px" > 决策系统风控:{{ s.row.buy_paused.reason || '资金异动' }}(卖出/平回不受影响;确认无碍点右侧「恢复买入」)< / div >
2026-08-11 15:28:46 +08:00
< / template > < / el-table-column >
2026-08-12 14:51:22 +08:00
< el-table-column label = "状态" width = "84" > < template # default = "s" > < el-tag size = "small" :type = "s.row.status==='ACTIVE'?'primary':'info'" > {{ tx('stratStatus', s.row.status) }}< / el-tag > < / template > < / el-table-column >
2026-08-11 15:28:46 +08:00
< el-table-column label = "操作" width = "260" > < template # default = "s" >
2026-08-11 11:58:16 +08:00
< el-button v-if = "s.row.status==='ACTIVE'" size = "small" @ click = "setStrategyStatus(s.row.strategy_id,'PAUSED')" > 暂停< / el-button >
< el-button v-else-if = "s.row.status==='PAUSED'" size = "small" type = "success" @ click = "setStrategyStatus(s.row.strategy_id,'ACTIVE')" > 恢复< / el-button >
2026-08-11 15:28:46 +08:00
< el-button v-if = "s.row.buy_paused" size = "small" type = "warning" @ click = "resumeBuy(s.row)" > 恢复买入< / el-button >
2026-08-11 11:58:16 +08:00
< el-button size = "small" type = "danger" @ click = "setStrategyStatus(s.row.strategy_id,'CANCELLED')" > 撤下< / el-button >
< / template > < / el-table-column >
< / el-table >
2026-08-12 15:55:29 +08:00
< h3 style = "margin-top:14px;cursor:pointer" @ click = "showOpLog=!showOpLog" > 操作日志 · {{ opLog.length }} 条 < span class = "muted" style = "font-size:12px;font-weight:normal" > {{ showOpLog?'▲ 收起':'▼ 展开' }}< / span > < / h3 >
< template v-if = "showOpLog" >
< div class = "muted" style = "margin-bottom:6px" > 每一次页面写操作都留一行;被仓位 / 盈亏 / T+1 可卖 / 单股上限挡下的,连原因一起记,不静默。< / div >
< div class = "muted" v-if = "!opLog.length" > 还没有操作记录。< / div >
< div v-for = "(o,i) in opLog" :key = "i" class = "feed-row" >
< span class = "muted mono" > {{ (o.at||'').slice(11,16) }}< / span >
< el-tag size = "small" :type = "o.result==='OK'?'primary':(o.result==='BLOCKED'?'warning':'info')" style = "margin:0 6px" > {{ o.result }}< / el-tag >
< b > {{ o.op }}< / b > < span v-if = "o.ts_code" class = "muted" > · {{ nm(o.ts_code) }}< / span >
< span v-if = "o.reason" style = "color:var(--warn)" > — {{ o.reason }}< / span >
< / div >
< / template >
2026-08-11 11:58:16 +08:00
< / div >
<!-- 挂交易方案 对话框 (非盲填: 现价/持仓/可卖/浮盈都摆出来) -->
< el-dialog v-model = "stratDlg.visible" :title = "'挂交易方案 · ' + nm(stratDlg.ts_code)" width = "580px" >
< div class = "muted" style = "margin-bottom:10px" >
现价 < b > {{ stratDlg.price }}< / b > · 持仓 < b > {{ stratDlg.total_qty }}< / b > 股( T+1 可卖 < b > {{ stratDlg.avail_qty }}< / b > )· 浮盈 < b > {{ pct(stratDlg.cushion_pct) }}< / b > 。参数按这些现值给了默认,不用盲填。
< / div >
< el-form label-width = "112px" size = "small" >
< el-form-item label = "方案类型" >
< el-radio-group v-model = "stratDlg.type" >
< el-radio-button label = "T0" > 做T< / el-radio-button >
< el-radio-button label = "GRID" > 网格< / el-radio-button >
< el-radio-button label = "TRAIL" > 跟踪止盈< / el-radio-button >
< / el-radio-group >
< / el-form-item >
< template v-if = "stratDlg.type==='T0'" >
< el-form-item label = "T仓比例" >
< el-input v-model = "stratDlg.p.t_ratio" style = "width:120px" > < template # append > ×持仓< / template > < / el-input >
< span class = "muted" style = "margin-left:8px" > ≤ 1/3; 每轮约 < b > {{ stratQty }}< / b > 股< / span >
< / el-form-item >
< el-form-item label = "怎么做" >
< span class = "muted" > 回落近支撑→买、按目标价差高卖( 正T) ; 近压力或滞涨→卖存量、低买回( 反T) 。当日 ≤ 3 次, 14:50 强制平回,单票 / 全局当日T亏熔断。< / span >
< / el-form-item >
< / template >
< template v-else-if = "stratDlg.type==='GRID'" >
< el-form-item label = "下界 / 上界" >
< el-input v-model = "stratDlg.p.lower" style = "width:110px" > < / el-input >
< span style = "margin:0 6px" > ~< / span >
< el-input v-model = "stratDlg.p.upper" style = "width:110px" > < / el-input >
< / el-form-item >
< el-form-item label = "中枢价" >
< el-input v-model = "stratDlg.p.center" style = "width:110px" > < / el-input >
< span class = "muted" style = "margin-left:8px" > 默认取现价< / span >
< / el-form-item >
< el-form-item label = "档距" >
< el-input v-model = "stratDlg.p.step_pct" style = "width:110px" > < template # append > ×价< / template > < / el-input >
< span class = "muted" style = "margin-left:8px" > 0.02=每 2% 一档< / span >
< / el-form-item >
< el-form-item label = "每档股数" >
< el-input v-model = "stratDlg.p.per_lot" style = "width:110px" > < template # append > 股< / template > < / el-input >
< / el-form-item >
< el-form-item label = "最大投入额" >
< el-input v-model = "stratDlg.p.max_capital" style = "width:150px" > < template # append > 元< / template > < / el-input >
2026-08-12 15:10:56 +08:00
< span class = "muted" style = "margin-left:8px" > 须落在单股上限内(合规检查兜底)< / span >
2026-08-11 11:58:16 +08:00
< / el-form-item >
< el-form-item label = "怎么做" >
< span class = "muted" > 跌破未买档→买一份,涨破已买档→卖一份(从 T+1 可卖存量);跌破下界=继续持有、不再买。< / span >
< / el-form-item >
< / template >
< template v-else >
< el-form-item label = "启动线" >
< el-input v-model = "stratDlg.p.start_line" style = "width:110px" > < template # append > 浮盈< / template > < / el-input >
< span class = "muted" style = "margin-left:8px" > 浮盈达此才开始跟踪< / span >
< / el-form-item >
< el-form-item label = "回撤触发" >
< el-input v-model = "stratDlg.p.giveback" style = "width:110px" > < template # append > 自高点< / template > < / el-input >
< span class = "muted" style = "margin-left:8px" > 0.05=从高点回落 5% 就卖< / span >
< / el-form-item >
< el-form-item label = "卖出比例" >
< el-input v-model = "stratDlg.p.sell_ratio" style = "width:110px" > < template # append > ×可卖< / template > < / el-input >
< span class = "muted" style = "margin-left:8px" > 1= 全清< / span >
< / el-form-item >
< el-form-item label = "硬止盈(可选)" >
< el-input v-model = "stratDlg.p.hard_target" style = "width:110px" > < template # append > 浮盈< / template > < / el-input >
< span class = "muted" style = "margin-left:8px" > 0= 不设; 达此直接全清< / span >
< / el-form-item >
< el-form-item label = "怎么做" >
< span class = "muted" > 创新高抬止盈线,从高点回落到设定比例就卖(只卖不买,纯离场保护)。< / span >
< / el-form-item >
< / template >
< el-form-item label = "自主档" >
< el-radio-group v-model = "stratDlg.autonomy" >
< el-radio-button label = "auto" > 自动执行< / el-radio-button >
< el-radio-button label = "confirm" > 待我拍板< / el-radio-button >
< / el-radio-group >
< / el-form-item >
< / el-form >
< el-alert v-if = "stratDlg.reasons.length" type = "error" effect = "dark" :closable = "false" show-icon
title="不能挂载(下列约束理论上不允许违反):" style="margin-top:6px">
< div v-for = "(r,i) in stratDlg.reasons" :key = "i" style = "color:#fca5a5" > · {{ r }}< / div >
< / el-alert >
< template # footer >
< el-button size = "small" @ click = "validateStrategy" :loading = "stratDlg.busy" > 先校验< / el-button >
< el-button size = "small" type = "primary" @ click = "attachStrategy" :loading = "stratDlg.busy" > 挂载并生效< / el-button >
< / template >
< / el-dialog >
2026-08-10 15:09:29 +08:00
<!-- 5) 组合操作 + 设置 -->
< div class = "panel tblk" >
< h3 > 组合操作< / h3 >
2026-08-11 09:42:40 +08:00
< div class = "muted" style = "margin-bottom:8px" > 对整个组合的手动动作。数值都基于当前实际持仓算出、并预览影响金额,不用盲填;危险动作要二次确认。< / div >
2026-08-11 09:27:21 +08:00
< 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 >
2026-08-11 09:42:40 +08:00
< span class = "d" > 停止一切新增买入并撤销在途买入指令;卖出与止损不受影响。当前:{{ ov.buy_halt?'已暂停买入':'买入正常' }}。< / span >
2026-08-11 09:27:21 +08:00
< / 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 >
2026-08-11 09:42:40 +08:00
< span class = "d" > 所有自动动作暂停,只保留对账与日报。当前:{{ ov.exec_halt?'休假中':'正常运行' }}。< / span >
2026-08-11 09:27:21 +08:00
< / div >
2026-08-11 09:42:40 +08:00
< div class = "op-row" style = "align-items:center" >
< span class = "op-btn" > < el-button size = "small" type = "primary" :disabled = "!posMoveValid" @ click = "doPosMove()" > 调整仓位< / el-button > < / span >
< span class = "d" >
当前总仓位 < b > {{ pct(ov.portfolio_pct) }}< / b > (市值 {{ money(ov.portfolio_mv) }} / 规模 {{ money(ov.scale) }}),可用资金 {{ money(ov.cash_source==='ws'?ov.cash_avail:ov.cash_est) }}。
目标 < el-input v-model = "tgtPos" size = "small" style = "width:96px" > < template # append > %< / template > < / el-input >
< span v-if = "posMovePreview" style = "margin-left:8px;color:#bbb" > {{ posMovePreview }}< / span >
< / span >
2026-08-11 09:27:21 +08:00
< / div >
2026-08-11 09:42:40 +08:00
< div class = "op-row" style = "align-items:center" >
< span class = "op-btn" > < el-button size = "small" :disabled = "!secSel" @ click = "doSectorExit()" > 清仓行业< / el-button > < / span >
< span class = "d" >
卖光某个行业的全部持仓,从下面选(不用手打):
< el-select v-model = "secSel" size = "small" style = "width:240px" placeholder = "选持仓行业" :disabled = "!heldSectors.length" clearable >
< el-option v-for = "s in heldSectors" :key = "s.sector" :label = "s.sector+'( '+s.count+'只 '+money(s.mv)+') '" :value = "s.sector" > < / el-option >
< / el-select >
< span v-if = "!heldSectors.length" class = "muted" > —— {{ ov.sector_ready===false?'行业数据未就绪':'当前无带行业的持仓' }}< / span >
< / span >
2026-08-11 09:27:21 +08:00
< / div >
< div class = "op-row" >
< span class = "op-btn" > < el-button size = "small" type = "danger" @ click = "pLiquidate()" > 一键清仓(紧急)< / el-button > < / span >
2026-08-11 09:42:40 +08:00
< span class = "d" > 全部持仓({{ heldPositions.length }} 只,市值 {{ money(ov.portfolio_mv) }})按最快节奏卖出、不做择时优化。紧急离场用,需二次确认。< / span >
2026-08-10 15:09:29 +08:00
< / div >
< / div >
< div class = "panel tblk" >
2026-08-12 15:55:29 +08:00
< h3 style = "cursor:pointer" @ click = "showSettings=!showSettings" > 设置 < span class = "muted" style = "font-size:12px;font-weight:normal" > {{ showSettings?'▲ 收起':'▼ 展开' }}< / span > < / h3 >
< template v-if = "showSettings" >
2026-08-11 09:42:40 +08:00
< div class = "muted" style = "margin-bottom:8px" > 每组先给一句「现在是什么状态」的合成说明,再让你改其中的项。改完点保存即时生效。< / div >
< div class = "set-card" >
< div class = "set-sum" > 资金与仓位纪律:{{ sumPosition }}< / div >
< div class = "set-fields" v-if = "pmap['PMS_TOTAL_SCALE']" >
< span > 总操作规模 < el-input v-model = "pmap['PMS_TOTAL_SCALE'].value" size = "small" style = "width:130px" > < template # append > 元< / template > < / el-input > < / span >
< span > 总仓上限 < el-input v-model = "pmap['PMS_PORTFOLIO_CAP'].value" size = "small" style = "width:88px" placeholder = "0~1" > < / el-input > < / span >
< span > 单股上限 < el-input v-model = "pmap['PMS_STOCK_CAP'].value" size = "small" style = "width:88px" placeholder = "0~1" > < / el-input > < / span >
< span > 最多持仓 < el-input v-model = "pmap['PMS_MAX_NAMES'].value" size = "small" style = "width:82px" > < template # append > 只< / template > < / el-input > < / span >
< span > 预留现金 < el-input v-model = "pmap['PMS_CASH_RESERVE'].value" size = "small" style = "width:88px" placeholder = "0~1" > < / el-input > < / span >
< / div >
< / div >
< div class = "set-card" >
< div class = "set-sum" > 补仓策略:{{ sumDca }}< / div >
< div class = "set-fields" v-if = "pmap['PMS_DCA_MAX_RATIO']" >
< span > 是否补仓 < el-switch v-model = "dcaOn" > < / el-switch > < / span >
< template v-if = "dcaOn" >
< span > 上限(占底仓) < el-input v-model = "pmap['PMS_DCA_MAX_RATIO'].value" size = "small" style = "width:88px" placeholder = "0~1" > < / el-input > < / span >
< span > 评估档(浮亏) < el-input v-model = "pmap['PMS_DCA_TRIGGERS'].value" size = "small" style = "width:130px" placeholder = "-0.08,-0.15" > < / el-input > < / span >
< span > 深档确认 < el-input v-model = "pmap['PMS_DCA_DEEP_CONFIRM'].value" size = "small" style = "width:88px" > < / el-input > < / span >
< span > 回踩止损 < el-input v-model = "pmap['PMS_FILL_MAX_LOSS'].value" size = "small" style = "width:88px" > < / el-input > < / span >
2026-08-10 15:09:29 +08:00
< / template >
2026-08-11 09:42:40 +08:00
< / div >
< / div >
< div class = "set-card" >
< div class = "set-sum" > 自主档位:{{ sumAutonomy }}< / div >
< div class = "set-fields" v-if = "pmap['PMS_AUTONOMY']" >
< el-select v-model = "pmap['PMS_AUTONOMY'].value" size = "small" style = "width:200px" >
< el-option label = "全自动" value = "full" > < / el-option >
< el-option label = "只提议不下手" value = "propose_only" > < / el-option >
< el-option label = "暂停" value = "off" > < / el-option >
< / el-select >
< / div >
< / div >
< div class = "row" style = "margin-top:10px" >
2026-08-10 15:09:29 +08:00
< el-button size = "small" type = "primary" :disabled = "!dirtyCount" @ click = "saveParams" > 保存修改 ({{ dirtyCount }})< / el-button >
< el-button size = "small" @ click = "loadParams" > 放弃< / el-button >
2026-08-11 09:42:40 +08:00
< span class = "muted" > 关闭补仓=把「补仓上限」设为 0; 调低总仓上限可能触发降仓提议。< / span >
2026-08-10 15:09:29 +08:00
< / div >
2026-08-12 15:55:29 +08:00
< / template >
2026-08-10 15:09:29 +08:00
< / div >
<!-- 6) 系统在盯的候选 -->
< div class = "panel tblk" >
2026-08-12 14:51:22 +08:00
< h3 > 系统在盯的候选 < span class = "muted" style = "font-weight:400" > · 今日选股计划 · 计划日 {{ planStatus.date || '—' }}< / span > < / h3 >
2026-08-12 15:55:29 +08:00
< div class = "sub2" > 系统还在盯、可能建仓的票。已下单 / 待你确认 / 被否决的,见上方「今日在办」与「等我拍板」。< / div >
2026-08-12 14:51:22 +08:00
< div class = "pool-note" v-if = "(openScan.notes||[]).length" > < span v-for = "(n,i) in openScan.notes" :key = "i" > {{ n }}< span v-if = "i < openScan.notes.length-1" > ; < / span > < / span > < / div >
2026-08-10 15:09:29 +08:00
< div class = "muted" v-if = "planStatus.ok===false" > 上游计划暂不可用。< / div >
2026-08-12 15:55:29 +08:00
< el-table v-else :data = "watchCandidates" size = "small" max-height = "360" empty-text = "今天没有在盯待建的候选(都已处理,见上方)" >
2026-08-12 14:51:22 +08:00
< el-table-column label = "#" width = "52" align = "right" > < template # default = "s" > < span class = "mono muted" > {{ s.row.rank }}< / span > < / template > < / el-table-column >
< el-table-column label = "股票" min-width = "150" > < 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 label = "主题" width = "130" > < template # default = "s" > < span class = "tag" v-if = "s.row.theme" > {{ s.row.theme }}< / span > < span v-else class = "muted" > —< / span > < / template > < / el-table-column >
< el-table-column label = "传导档" width = "88" > < template # default = "s" > < span class = "muted" > {{ s.row.tier || '—' }}< / span > < / template > < / el-table-column >
< el-table-column label = "处置 · 为什么" min-width = "300" > < template # default = "s" >
< span class = "disp" :class = "dispOf(s.row.ts_code).cls" > ● {{ dispOf(s.row.ts_code).label }}< / span >
< div class = "why" v-if = "dispOf(s.row.ts_code).why" > {{ dispOf(s.row.ts_code).why }}< / div >
< / template > < / el-table-column >
2026-08-10 15:09:29 +08:00
< / el-table >
< / div >
2026-08-13 10:45:04 +08:00
<!-- 7) 上游信号 (只读参考) -->
< div class = "panel tblk" >
< h3 style = "cursor:pointer" @ click = "showUpstream=!showUpstream" > 上游信号 < span class = "muted" style = "font-weight:400" > · 只读参考,不进下单逻辑< / span > < span class = "muted" style = "font-size:12px;font-weight:normal" > {{ showUpstream?'▲ 收起':'▼ 展开' }}< / span > < / h3 >
< template v-if = "showUpstream" >
< div class = "muted" style = "margin:-4px 0 12px" v-if = "upstream.at" > 最近取数 {{ (upstream.at||'').slice(11,19) }}(每次刷新拉一次;源自 mtf / 盘中择时层 / 决策系统)< / div >
< div class = "up-grid" >
< div class = "up-cell" >
< div class = "up-h" > 决策系统 买/卖< span class = "src-err" v-if = "usrc('intraday') && usrc('intraday').ok===false" > · 读取失败:{{ usrc('intraday').error }}< / span > < / div >
< el-table :data = "((usrc('intraday')||{}).decision||[]).slice(0,8)" size = "small" max-height = "200" empty-text = "暂无" >
< el-table-column label = "股票" min-width = "130" > < 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 = "128" > < template # default = "s" > < span class = "disp" :class = "s.row.action==='SELL'?'deny':'done'" > {{ s.row.action }} · {{ s.row.signal_type }}< / span > < / template > < / el-table-column >
< el-table-column label = "时间" width = "66" align = "right" > < template # default = "s" > < span class = "mono muted" > {{ s.row.time }}< / span > < / template > < / el-table-column >
< / el-table >
< / div >
< div class = "up-cell" >
< div class = "up-h" > 盘中择时层 · BUY 入场< span class = "src-err" v-if = "usrc('intraday') && usrc('intraday').ok===false" > · 读取失败< / span > < / div >
< el-table :data = "((usrc('intraday')||{}).timing||[]).slice(0,8)" size = "small" max-height = "200" empty-text = "暂无" >
< el-table-column label = "股票" min-width = "130" > < 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 = "82" align = "right" > < template # default = "s" > < span class = "mono" > {{ s.row.price==null?'—':s.row.price }}< / span > < / template > < / el-table-column >
< el-table-column label = "置信" width = "66" align = "right" > < template # default = "s" > < span class = "mono" > {{ s.row.confidence==null?'—':s.row.confidence }}< / span > < / template > < / el-table-column >
< el-table-column label = "时间" width = "60" align = "right" > < template # default = "s" > < span class = "mono muted" > {{ s.row.time }}< / span > < / template > < / el-table-column >
< / el-table >
< / div >
< div class = "up-cell" >
< div class = "up-h" > mtf 资金异动< span class = "src-err" v-if = "usrc('metrics') && usrc('metrics').ok===false" > · 读取失败< / span > < / div >
< el-table :data = "((usrc('metrics')||{}).items||[]).slice(0,8)" size = "small" max-height = "200" empty-text = "暂无" >
< el-table-column label = "股票" min-width = "120" > < 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 = "78" > < template # default = "s" > < span class = "muted" > {{ s.row.direction || '—' }}< / span > < / template > < / el-table-column >
< el-table-column label = "z_dd" width = "66" align = "right" > < template # default = "s" > < span class = "mono" > {{ s.row.z_dd==null?'—':s.row.z_dd }}< / span > < / template > < / el-table-column >
< el-table-column label = "级别" width = "86" > < template # default = "s" > < span class = "muted" > {{ s.row.level || '—' }}< / span > < / template > < / el-table-column >
< / el-table >
< / div >
< div class = "up-cell" >
< div class = "up-h" > 决策系统 · 风控卖出< span class = "src-err" v-if = "usrc('sell_actions') && usrc('sell_actions').ok===false" > · 读取失败< / span > < / div >
< el-table :data = "((usrc('sell_actions')||{}).items||[]).slice(0,8)" size = "small" max-height = "200" empty-text = "暂无" >
< el-table-column label = "股票" min-width = "118" > < template # default = "s" > < b > {{ nm(s.row.ts_code) }}< / b > < / template > < / el-table-column >
< el-table-column label = "置信" width = "60" align = "right" > < template # default = "s" > < span class = "mono" > {{ s.row.confidence==null?'—':s.row.confidence }}< / span > < / template > < / el-table-column >
< el-table-column label = "理由" min-width = "180" > < template # default = "s" > < span class = "muted" style = "font-size:12px" > {{ s.row.reason || '—' }}< / span > < / template > < / el-table-column >
< el-table-column label = "时间" width = "56" align = "right" > < template # default = "s" > < span class = "mono muted" > {{ s.row.time }}< / span > < / template > < / el-table-column >
< / el-table >
< / div >
< div class = "up-cell" >
< div class = "up-h" > mtf 实况分 · 关注 / 回避榜< span class = "src-err" v-if = "usrc('mr') && usrc('mr').ok===false" > · 读取失败< / span > < / div >
< div class = "mr-two" >
< div >
< div class = "muted" style = "margin-bottom:5px" > 关注(高分)< / div >
< div v-for = "(r,i) in ((usrc('mr')||{}).watch||[]).slice(0,8)" :key = "i" class = "mr-row" > < b > {{ nm(r.ts_code) }}< / b > < span class = "muted mono" > {{ r.ts_code }}< / span > < span class = "mono" style = "float:right;color:var(--accent)" > {{ r.score }}< / span > < / div >
< div class = "muted" v-if = "!((usrc('mr')||{}).watch||[]).length" > 暂无< / div >
< / div >
< div >
< div class = "muted" style = "margin-bottom:5px" > 回避(低分)< / div >
< div v-for = "(r,i) in ((usrc('mr')||{}).avoid||[]).slice(0,8)" :key = "i" class = "mr-row" > < b > {{ nm(r.ts_code) }}< / b > < span class = "muted mono" > {{ r.ts_code }}< / span > < span class = "mono" style = "float:right;color:var(--warn)" > {{ r.score }}< / span > < / div >
< div class = "muted" v-if = "!((usrc('mr')||{}).avoid||[]).length" > 暂无< / div >
< / div >
< / div >
< / div >
< / div >
< / template >
< / div >
2026-08-10 15:09:29 +08:00
< / div >
< div v-show = "mode==='ops'" >
2026-07-27 17:12:09 +08:00
< 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 >
2026-07-30 13:02:51 +08:00
< 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 >
2026-07-27 17:12:09 +08:00
< 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 >
2026-07-29 10:01:00 +08:00
< el-table-column prop = "key" label = "参数" width = "240" class-name = "mono" > < / el-table-column >
2026-07-27 17:12:09 +08:00
< 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" >
2026-07-29 10:01:00 +08:00
< el-option v-for = "o in ['full','propose_only','off']" :key = "o" :label = "o" :value = "o" > < / el-option >
2026-07-27 17:12:09 +08:00
< / el-select >
< el-select v-else-if = "s.row.key==='PMS_SECTOR_SOURCE'" v-model = "s.row.value" size = "small" >
2026-07-29 10:01:00 +08:00
< 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 >
2026-07-27 17:12:09 +08:00
< / el-select >
2026-07-29 10:01:00 +08:00
< 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 >
2026-07-27 17:12:09 +08:00
< / template >
< / el-table-column >
2026-07-29 10:01:00 +08:00
< el-table-column prop = "file_default" label = "文件初值" width = "130" > < / el-table-column >
2026-07-27 17:12:09 +08:00
< 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 >
2026-07-29 10:01:00 +08:00
< 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 >
2026-07-27 17:12:09 +08:00
< / 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" >
2026-07-29 10:01:00 +08:00
< el-option v-for = "o in f.choices" :key = "o" :label = "o" :value = "o" > < / el-option >
2026-07-27 17:12:09 +08:00
< / 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 >
2026-07-29 10:01:00 +08:00
< el-input v-model = "form.note" size = "small" style = "width:200px" placeholder = "备注(可选)" > < / el-input >
2026-07-27 17:12:09 +08:00
< 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 >
2026-07-29 10:01:00 +08:00
< 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 >
2026-07-27 17:12:09 +08:00
< el-table-column label = "金额" width = "110" >
< template # default = "p" > {{ money(p.row.amount) }}< / template > < / el-table-column >
2026-07-29 10:01:00 +08:00
< 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 >
2026-07-27 17:12:09 +08:00
< / el-table >
< / div >
< / template >
< / el-table-column >
2026-07-29 10:01:00 +08:00
< 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 >
2026-07-27 17:12:09 +08:00
< 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"
2026-07-29 10:01:00 +08:00
:text-inside="true" status="success">< / el-progress >
2026-07-27 17:12:09 +08:00
< 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 >
2026-07-29 10:01:00 +08:00
< el-table-column prop = "issued_at" label = "下达时间" width = "160" class-name = "muted" > < / el-table-column >
2026-07-27 17:12:09 +08:00
< 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">
2026-07-29 10:01:00 +08:00
< 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 >
2026-07-27 17:12:09 +08:00
< el-table-column prop = "instruction_id" label = "来源指令" min-width = "180"
2026-07-29 10:01:00 +08:00
class-name="mono">< / el-table-column >
< el-table-column prop = "note" label = "备注" min-width = "160" > < / el-table-column >
2026-07-27 17:12:09 +08:00
< / el-table >
< / div >
< / template >
< / el-table-column >
2026-07-29 10:01:00 +08:00
< 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 >
2026-07-27 17:12:09 +08:00
< 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 >
2026-07-29 10:01:00 +08:00
< el-table-column prop = "total_qty" label = "持仓" width = "90" > < / el-table-column >
< el-table-column prop = "avail_qty" label = "可卖" width = "90" > < / el-table-column >
2026-07-27 17:12:09 +08:00
< 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 >
2026-07-29 10:01:00 +08:00
< el-table-column prop = "avg_cost" label = "摊薄成本" width = "100" > < / el-table-column >
< el-table-column prop = "price" label = "现价" width = "90" > < / el-table-column >
2026-07-27 17:12:09 +08:00
< 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 >
2026-07-28 09:10:07 +08:00
< 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">
2026-07-29 10:01:00 +08:00
< 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 >
2026-07-28 09:10:07 +08:00
< el-table-column label = "兜底" width = "70" >
< template # default = "c" > {{ c.row.forced ? '是' : '—' }}< / template >
< / el-table-column >
2026-07-29 10:01:00 +08:00
< el-table-column prop = "reason" label = "出手依据" min-width = "260" > < / el-table-column >
2026-07-28 09:10:07 +08:00
< / el-table >
< div v-else class = "muted" style = "margin-top:6px" > 尚无出手记录< / div >
< / div >
< / template >
< / el-table-column >
2026-07-29 10:01:00 +08:00
< 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 >
2026-07-28 09:10:07 +08:00
< el-table-column label = "进度" width = "130" >
< template # default = "s" >
2026-07-29 10:01:00 +08:00
< el-progress :percentage = "insPct(s.row)" :stroke-width = "10" :text-inside = "true" > < / el-progress >
2026-07-28 09:10:07 +08:00
< / template >
< / el-table-column >
2026-07-29 10:01:00 +08:00
< el-table-column prop = "status" label = "状态" width = "115" > < / el-table-column >
< el-table-column prop = "origin_type" label = "来源" width = "80" > < / el-table-column >
2026-07-28 09:10:07 +08:00
< 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 >
2026-07-27 17:12:09 +08:00
< / el-table >
< div class = "muted" style = "margin-top:6px" >
2026-07-28 09:10:07 +08:00
{{ dm.hint }} 展开可看每日子单与择时依据; 成交量以下游回放为准, 不拿下发量当成交量。
2026-07-27 17:12:09 +08:00
< / div >
< / div >
< div class = "panel" >
< h3 > 评审账本 (规则闸 / 研判闸 / 人工裁决全量留痕)< / h3 >
< el-table :data = "ledger" size = "small" border max-height = "260" >
2026-07-29 10:01:00 +08:00
< 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 >
2026-07-27 17:12:09 +08:00
< 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 >
2026-07-29 10:01:00 +08:00
< el-table-column prop = "price_at" label = "评审价" width = "90" > < / el-table-column >
2026-07-27 17:12:09 +08:00
< el-table-column label = "未通过项" min-width = "240" >
< template # default = "s" > < span class = "muted" > {{ (s.row.failed_checks||[]).join(' | ') }}< / span > < / template >
< / el-table-column >
2026-07-29 10:01:00 +08:00
< el-table-column prop = "reason" label = "理由" min-width = "200" > < / el-table-column >
2026-07-27 17:12:09 +08:00
< / el-table >
< / div >
< / el-tab-pane >
<!-- ==================== ④ 提议确认 ==================== -->
< el-tab-pane name = "prop" >
< template # label >
提议确认
2026-07-29 10:01:00 +08:00
< el-badge v-if = "proposals.length" :value = "proposals.length" class = "ml" > < / el-badge >
2026-07-27 17:12:09 +08:00
< / template >
< div class = "panel" >
< h3 > 待确认队列 (自主档位 {{ ov.autonomy }})< / h3 >
< div class = "muted" style = "margin-bottom:8px" >
propose_only 档位下, 增持类自主动作全部在此等待裁决; 减持方向不设确认门槛。
采纳后先落指令表 (先记账后动作), 由择时执行器分日出手。
< / div >
2026-07-28 09:29:53 +08:00
< el-alert v-if = "dm.judge && !dm.judge.available" type = "warning" effect = "dark" show-icon
:closable="false" style="margin-bottom:10px"
2026-07-29 10:01:00 +08:00
:title="'研判闸未接通 —— ' + dm.judge.reason + ' (补足/加仓/补仓一律降级为人工确认)'">< / el-alert >
2026-07-27 17:12:09 +08:00
< el-table :data = "proposals" size = "small" border max-height = "420" >
2026-07-29 10:01:00 +08:00
< 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 >
2026-07-27 17:12:09 +08:00
< 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 >
2026-07-29 10:01:00 +08:00
< el-table-column prop = "judge_verdict" label = "研判" width = "100" > < / el-table-column >
< el-table-column prop = "expire_at" label = "失效时间" width = "160" > < / el-table-column >
2026-07-27 17:12:09 +08:00
< 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 >
2026-07-29 13:51:10 +08:00
<!-- ==================== ④ 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 >
2026-07-27 17:12:09 +08:00
< / el-tabs >
2026-08-10 15:09:29 +08:00
< / div >
2026-07-27 17:12:09 +08:00
<!-- 运维抽屉 -->
< el-drawer v-model = "opsDrawer" title = "运维操作 (与调度器同一份实现)" size = "46%" >
< div class = "muted" style = "margin-bottom:10px" >
调度器 (--profile sched) 会自动按调度总表执行; 此处按钮用于未接调度或临时验证时手动触发。
< / div >
< div class = "row" >
2026-07-28 09:10:07 +08:00
< 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 >
2026-07-28 09:29:53 +08:00
< 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 >
2026-07-28 09:10:07 +08:00
< div class = "row" style = "margin-top:8px" >
2026-07-27 17:12:09 +08:00
< 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 >
2026-07-28 09:10:07 +08:00
< div class = "muted" style = "margin-top:10px" >
当前下发通道: < b > {{ dm.mode }}< / b > —— {{ dm.hint }}
(通道在「参数设置 → PMS_DISPATCH_MODE」切换)
< / div >
2026-07-27 17:12:09 +08:00
< pre class = "json" v-if = "opsResult" > {{ opsResult }}< / pre >
< / el-drawer >
2026-07-30 16:21:16 +08:00
<!-- 上游选股计划抽屉 -->
< 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(',') }} ·
2026-07-31 08:54:36 +08:00
上游主题上限 {{ 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 }}
2026-07-30 16:21:16 +08:00
< / div >
2026-07-31 09:17:54 +08:00
< 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'">
2026-07-30 17:00:15 +08:00
< / el-alert >
2026-07-30 16:21:16 +08:00
< div class = "muted" v-if = "planCand" style = "margin-bottom:8px" >
按当前参数筛选: 排序池 {{ planCand.considered }} → 合格 {{ planCand.eligible }} →
2026-07-31 09:17:54 +08:00
取 {{ (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 >
2026-07-30 16:21:16 +08:00
< / div >
2026-07-31 13:28:10 +08:00
<!-- 榜单变化 (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 >
2026-07-30 16:21:16 +08:00
< 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 >
2026-07-30 16:38:24 +08:00
< 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 >
2026-07-30 16:21:16 +08:00
< / el-table-column >
< el-table-column prop = "n_sources" label = "源数" width = "66" > < / el-table-column >
< / el-table >
< div class = "muted" style = "margin-top:6px" >
2026-07-30 16:38:24 +08:00
「预期空间」= 券商目标价相对现价的空间 (上游 upside 字段, 2.12 → +212%)。噪音大,
所以< b > 永不参与排序< / b > —— 排序始终按 score; 只在 PMS_PLAN_MIN_UPSIDE 上做下限过滤。
2026-07-30 16:21:16 +08:00
筛选参数在「参数设置」里: PMS_PLAN_TOP_N / PMS_PLAN_TIERS / PMS_PLAN_INCLUDE_OBSERVE /
2026-07-31 08:54:36 +08:00
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 名才不会被单一主题垄断。
2026-07-31 09:17:54 +08:00
< br > < b > PMS_PLAN_EXCLUDE_ST< / b > 默认开: 上游名单明确不剔除 ST/*ST, 这道闸在 PMS 侧。
「主题」是< b > 传导主题< / b > (事件驱动、天天变、覆盖约三成), < b > 不是行业< / b > —— 行业硬拦截
请把 PMS_SECTOR_SOURCE 设成 gp_stock_category, 别用 PMS_PLAN_THEME_SYNC。
2026-07-30 16:21:16 +08:00
< / div >
< pre class = "json" v-if = "planResult" > {{ planResult }}< / pre >
< / el-drawer >
2026-07-27 17:12:09 +08:00
<!-- 日报抽屉 -->
< 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 >
2026-07-29 10:01:00 +08:00
< el-table-column prop = "type" label = "事项" width = "130" > < / el-table-column >
2026-07-27 17:12:09 +08:00
< 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 >
2026-07-29 13:51:10 +08:00
const { createApp, ref, reactive, computed, onMounted, onUnmounted, watch } = Vue;
2026-07-27 17:12:09 +08:00
const api = axios.create({ timeout: 60000 });
createApp({
setup() {
const tab = ref('cmd'), loading = ref(false), err = ref('');
2026-08-10 15:09:29 +08:00
const mode = ref('trader');
2026-07-27 17:12:09 +08:00
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([]);
2026-08-11 11:58:16 +08:00
const strategies = ref([]), stratEnabled = ref(false), opLog = ref([]);
2026-08-11 15:38:19 +08:00
const showLedger = ref(false); // 「今天发生了什么」默认折叠
2026-08-12 15:55:29 +08:00
const showOpLog = ref(false); // 操作日志默认折叠
const showSettings = ref(false); // 设置默认折叠
2026-08-13 10:00:46 +08:00
const lastRefresh = ref('');
const autoRefresh = ref(true);
2026-08-13 10:45:04 +08:00
const upstream = ref({ sources: {} });
const showUpstream = ref(true);
2026-08-13 11:25:34 +08:00
const showAlerts = ref(false);
2026-08-13 15:37:21 +08:00
const alertShowMap = reactive({ money_flow:true, capital:true, qrs:true, volume:true, multi:true, price_notice:false, buy_emitted:false, other:true });
2026-08-12 14:51:22 +08:00
const openScan = ref({ by_code: {}, notes: [] });
const insTab = ref('live');
2026-08-11 11:58:16 +08:00
const stratDlg = reactive({ visible:false, busy:false, reasons:[], ts_code:'', price:0,
total_qty:0, avail_qty:0, cushion_pct:null, type:'T0', autonomy:'auto',
p:{ t_ratio:0.2, lower:0, upper:0, center:0, step_pct:0.02, per_lot:100, max_capital:0,
start_line:0.03, giveback:0.05, sell_ratio:1.0, hard_target:0 } });
2026-07-28 09:10:07 +08:00
const report = ref({}), reportDrawer = ref(false), dm = ref({});
2026-07-27 17:12:09 +08:00
const opsDrawer = ref(false), opsResult = ref(''), opsLoading = ref(false);
2026-07-30 16:21:16 +08:00
// 上游选股计划: 单独取数, 不塞进 /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);
2026-07-31 13:28:10 +08:00
// 榜单变化 (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: '券商覆盖没了 (主榜→观察档)' })));
});
2026-07-27 17:12:09 +08:00
const issuing = ref(false);
const form = reactive({ cmd_type: '', params: {}, note: '' });
2026-07-29 13:51:10 +08:00
// ---- ws 通道监控 (只读)
const wsRaw = ref({}), wsAuto = ref(true), wsHidePong = ref(true);
let wsTimer = null;
2026-07-30 13:02:51 +08:00
// 总规模(用户命令参数) 与 账户实际总资产(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;
});
2026-07-29 13:51:10 +08:00
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;
});
2026-07-27 17:12:09 +08:00
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');
2026-08-12 14:51:22 +08:00
const cuTag = s => ({ SOLID: 'info', THIN: 'warning', NONE: 'warning' }[s] || 'info');
2026-07-27 17:12:09 +08:00
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;
};
2026-07-28 09:10:07 +08:00
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;
};
2026-08-10 15:09:29 +08:00
// ===================== 交易员视图:翻译层 + 名称 + 动作 =====================
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:'已清仓' },
2026-08-12 15:10:56 +08:00
insStatus: { PROPOSED:'待校验', RULE_PASSED:'合规已过', JUDGE_PASSED:'复核已过', DISPATCHED:'已下发挂单',
2026-08-10 15:09:29 +08:00
CONFIRMED:'已成交', REJECTED:'被拒', EXPIRED:'到期作废', CANCELLED:'已撤销' },
2026-08-12 15:10:56 +08:00
arbiter: { rule:'合规检查', judge:'决策系统', user:'你' },
verdict: { PASS:'放行', REJECT:'驳回', NOTE:'记录', UNAVAILABLE:'决策系统暂不可用' },
2026-08-11 11:58:16 +08:00
stype: { T0:'做T', GRID:'网格', TRAIL:'跟踪止盈' },
stratStatus: { ACTIVE:'运行中', PAUSED:'已暂停', CANCELLED:'已撤下', DONE:'已完成' },
2026-08-10 15:09:29 +08:00
};
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));
2026-08-12 14:51:22 +08:00
// ---- 今日在办分档 + 候选处置 (2026-08-12 重构新增) ----
const todayYmd = computed(() => (String(health.value.now || '').slice(0, 10).replace(/-/g, '')) || '');
2026-08-13 10:00:46 +08:00
// 是否处于连续交易时段 (09:30-11:30 / 13:00-15:00 的交易日)。缺实时价在盘中才是问题,
// 休市/非交易日本就没有实时行情, 不该报警 —— 2026-08-12 用户提。
const marketOpen = computed(() => {
if (!health.value.trade_day) return false;
const t = (health.value.now || '').slice(11, 16);
if (!t) return false;
return (t >= '09:30' & & t < = '11:30') || (t >= '13:00' & & t < = '15:00');
});
const marketState = computed(() =>
!health.value.trade_day ? '非交易日' : (marketOpen.value ? '盘中' : '休市'));
2026-08-12 14:51:22 +08:00
const _LIVE_INS = ['PROPOSED', 'RULE_PASSED', 'JUDGE_PASSED', 'DISPATCHED'];
const _sameDayIns = r => { const y = todayYmd.value;
return !y || String(r.instruction_id || '').indexOf('INS_' + y + '_') === 0; };
const insLive = computed(() => (instructions.value || []).filter(r => _LIVE_INS.includes(r.status)));
const insDone = computed(() => (instructions.value || []).filter(r => r.status === 'CONFIRMED' & & _sameDayIns(r)));
const insEnd = computed(() => (instructions.value || []).filter(r => ['EXPIRED', 'CANCELLED', 'REJECTED'].includes(r.status) & & _sameDayIns(r)));
// 每只候选今天为什么下单/没下单: 已落地的事实(指令/提议)优先, 其余看 /api/open-scan 的只读处置。
function dispOf(code) {
const inst = (instructions.value || []).find(i => i.ts_code === code & & i.action === 'OPEN');
if (inst) return { cls: 'done', label: '已下单', why: '在『今日在办』(' + tx('insStatus', inst.status) + ') ' };
const prop = (proposals.value || []).find(p => p.ts_code === code & & p.action === 'OPEN');
if (prop) return { cls: 'queue', label: '待你确认', why: '已生成提议,在『等我拍板』等你点头' };
const d = (openScan.value.by_code || {})[code];
if (d) {
if (d.disp === 'would') return { cls: 'would', label: '将建仓', why: d.why };
return { cls: 'deny', label: '被否决', why: d.why };
}
return { cls: 'wait', label: '在盯', why: '' };
}
2026-08-12 15:31:10 +08:00
const denyToday = computed(() => (planRows.value || []).map(r => {
const d = dispOf(r.ts_code);
return d.cls === 'deny' ? { ts_code: r.ts_code, name: (r.name || nm(r.ts_code)), why: d.why } : null;
}).filter(Boolean));
2026-08-12 15:55:29 +08:00
const watchCandidates = computed(() => (planRows.value || []).filter(r =>
['would', 'wait'].includes(dispOf(r.ts_code).cls)).slice(0, 20));
2026-08-12 14:51:22 +08:00
2026-08-10 15:09:29 +08:00
const settingKeys = ['PMS_TOTAL_SCALE', 'PMS_PORTFOLIO_CAP', 'PMS_STOCK_CAP',
2026-08-11 09:27:21 +08:00
'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]);
});
2026-08-10 15:09:29 +08:00
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 + ') ');
2026-08-13 10:00:46 +08:00
if (marketOpen.value & & (o.price_missing || []).length)
out.push(o.price_missing.length + ' 只取不到实时价(停牌或行情中断),其盈亏与安全垫暂不可用');
2026-08-10 15:09:29 +08:00
if (o.sector_ready === false) out.push('行业数据未就绪,行业类操作暂不可用');
const j = (dm.value || {}).judge;
2026-08-12 15:10:56 +08:00
if (j & & j.available === false) out.push('决策系统暂不可用:补足/加仓/补仓一律降级为需你确认');
2026-08-10 15:09:29 +08:00
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) {
2026-08-11 09:42:40 +08:00
const curPct = row.pct_of_scale != null ? (row.pct_of_scale * 100).toFixed(1) + '%' : '—';
const v = await promptNum('当前 ' + row.total_qty + ' 股,市值 ' + money(row.market_value)
+ '(占规模 ' + curPct + ')。减到目标仓位(占总规模,%) ', '减仓 ' + nm(row.ts_code));
2026-08-10 15:09:29 +08:00
if (v != null) quickCmd('REDUCE_STOCK', { ts_code: row.ts_code, target_pct: v + '%' });
}
async function actStop(row) {
2026-08-11 09:42:40 +08:00
const v = await promptNum('现价 ' + row.price + ',成本 ' + (row.avg_cost || '—')
+ '。止损价(元,跌破即挂卖)', '设止损 ' + nm(row.ts_code));
2026-08-10 15:09:29 +08:00
if (v != null) quickCmd('SET_STOP_PRICE', { ts_code: row.ts_code, price: v });
}
async function actTargetPrice(row) {
2026-08-11 09:42:40 +08:00
const v = await promptNum('现价 ' + row.price + ',成本 ' + (row.avg_cost || '—')
+ '。目标价(元,到价生成止盈提议)', '设目标价 ' + nm(row.ts_code));
2026-08-10 15:09:29 +08:00
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);
2026-08-11 11:58:16 +08:00
else if (c === 'strategy') openStrategy(row);
2026-08-10 15:09:29 +08:00
}
// 组合
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 + '」的全部持仓?');
}
2026-08-11 09:42:40 +08:00
// ---- 组合仓位调整: 显示当前 + 算出金额, 不盲填 ----
const pctOf = v => (v == null || v === '') ? '—' : (Number(v) * 100).toFixed(0) + '%';
const tgtPos = ref(null);
const posMoveValid = computed(() => {
const t = Number(tgtPos.value);
return tgtPos.value !== null & & tgtPos.value !== '' & & !isNaN(t) & & t >= 0 & & Number(ov.value.scale || 0) > 0;
});
const posMovePreview = computed(() => {
if (!posMoveValid.value) return '';
const cur = Number(ov.value.portfolio_pct || 0), scale = Number(ov.value.scale || 0);
const delta = Number(tgtPos.value) / 100 - cur, amt = Math.abs(delta) * scale;
if (Math.abs(delta) < 0.001 ) return ' 与当前仓位一致 , 无需调整 。 ' ;
return delta < 0
? '→ 降仓:从 ' + pctOf(cur) + ' 降到 ' + tgtPos.value + '%,释放约 ' + money(amt) + ' 元。'
: '→ 升仓:从 ' + pctOf(cur) + ' 升到 ' + tgtPos.value + '%,动用约 ' + money(amt) + ' 元。';
});
function doPosMove() {
if (!posMoveValid.value) { ElementPlus.ElMessage.warning('请输入目标仓位'); return; }
const cur = Number(ov.value.portfolio_pct || 0), scale = Number(ov.value.scale || 0);
const delta = Number(tgtPos.value) / 100 - cur, amt = Math.abs(delta) * scale;
if (Math.abs(delta) < 0.001 ) { ElementPlus . ElMessage . info ( ' 与当前仓位一致 ' ) ; return ; }
if (delta < 0 ) quickCmd ( ' REDUCE_EXPOSURE ' , { pct: ( Math . abs ( delta ) * 100 ) . toFixed ( 1 ) + ' % ' } ,
'降仓到 ' + tgtPos.value + '%,释放约 ' + money(amt) + ' 元?');
else quickCmd('INCREASE_EXPOSURE', { pct: (delta * 100).toFixed(1) + '%' },
'升仓到 ' + tgtPos.value + '%,动用约 ' + money(amt)
+ ' 元(可用资金 ' + money(ov.value.cash_source === 'ws' ? ov.value.cash_avail : ov.value.cash_est) + ') ? ');
}
// ---- 清仓行业: 下拉选持仓行业, 不手输 ----
const heldSectors = computed(() => {
const m = {};
(positions.value || []).filter(p => (p.total_qty || 0) > 0 & & p.sector).forEach(p => {
if (!m[p.sector]) m[p.sector] = { sector: p.sector, count: 0, mv: 0 };
m[p.sector].count++; m[p.sector].mv += (p.market_value || 0);
});
return Object.values(m).sort((a, b) => b.mv - a.mv);
});
const secSel = ref('');
function doSectorExit() {
if (!secSel.value) { ElementPlus.ElMessage.warning('请选择行业'); return; }
const x = heldSectors.value.find(v => v.sector === secSel.value);
quickCmd('SECTOR_EXIT', { sector: secSel.value },
'清仓行业「' + secSel.value + '」的全部持仓(' + (x ? x.count : '?') + ' 只,约 '
+ (x ? money(x.mv) : '?') + ' 元)?');
}
// ---- 设置: 参数映射 + 组合摘要 (给用户看合成结果, 不是孤立参数) ----
const pmap = computed(() => { const m = {}; (params.value || []).forEach(p => m[p.key] = p); return m; });
const pval = k => { const p = pmap.value[k]; return p ? p.value : null; };
const dcaOn = computed({
get: () => Number(pval('PMS_DCA_MAX_RATIO') || 0) > 0,
set: v => { const p = pmap.value['PMS_DCA_MAX_RATIO']; if (!p) return; p.value = v ? (Number(p.value) > 0 ? p.value : 0.5) : 0; }
});
const sumPosition = computed(() =>
'规模 ' + money(pval('PMS_TOTAL_SCALE')) + ' 元;总仓 ≤ ' + pctOf(pval('PMS_PORTFOLIO_CAP'))
+ ',单股 ≤ ' + pctOf(pval('PMS_STOCK_CAP')) + ',最多 ' + (pval('PMS_MAX_NAMES') || '—')
+ ' 只,预留现金 ' + pctOf(pval('PMS_CASH_RESERVE')) + '。');
const sumDca = computed(() => {
if (!dcaOn.value) return '已关闭 —— 浮亏时不补仓。';
const trg = String(pval('PMS_DCA_TRIGGERS') || '').split(',').map(x => x.trim()).filter(Boolean)
.map(x => Math.abs(Number(x) * 100).toFixed(0) + '%').join('、');
const dc = Math.abs(Number(pval('PMS_DCA_DEEP_CONFIRM') || 0) * 100).toFixed(0) + '%';
const fl = Math.abs(Number(pval('PMS_FILL_MAX_LOSS') || 0) * 100).toFixed(0) + '%';
return '开启,上限为底仓的 ' + pctOf(pval('PMS_DCA_MAX_RATIO')) + '。浮亏 ' + (trg || '—')
+ ' 各评估一次补仓;浮亏 ' + dc + ' 及更深的补仓永远需你确认;浮亏超过 ' + fl
+ ' 就不再回踩补足、转入补仓评估。';
});
const sumAutonomy = computed(() => ({
full: '全自动 —— 系统自主提议并直接执行。',
propose_only: '只提议不下手 —— 系统提议,等你在「等我拍板」里确认。',
off: '暂停 —— 系统不自主提议。'
}[pval('PMS_AUTONOMY')] || (pval('PMS_AUTONOMY') || '—')));
2026-08-10 15:09:29 +08:00
// ===================== /交易员视图 =====================
2026-07-27 17:12:09 +08:00
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',
2026-08-12 14:51:22 +08:00
'/api/instructions?status=PROPOSED,RULE_PASSED,JUDGE_PASSED,DISPATCHED,CONFIRMED,EXPIRED,CANCELLED,REJECTED&limit=200');
2026-07-27 17:12:09 +08:00
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 || [];
}
2026-08-12 14:51:22 +08:00
async function loadOpenScan() {
const d = await call('get', '/api/open-scan');
openScan.value = (d & & d.by_code) ? d : { by_code: {}, notes: [] };
}
2026-08-13 10:45:04 +08:00
const usrc = k => (upstream.value.sources || {})[k];
2026-08-13 11:25:34 +08:00
const alertItems = computed(() => { const a = usrc('alerts'); return (a & & Array.isArray(a.items)) ? a.items : []; });
2026-08-13 15:37:21 +08:00
const ALERT_CAT_ORDER = ['money_flow','capital','qrs','volume','multi','price_notice','buy_emitted','other'];
const ALERT_CAT_DIRECTIONAL = { money_flow:true, capital:true, qrs:true, volume:true, multi:true, price_notice:true, buy_emitted:false, other:false };
2026-08-13 13:12:10 +08:00
const catShown = k => alertShowMap[k] !== false; // 未登记类默认进折叠条
function toggleCat(k){ alertShowMap[k] = !catShown(k); }
const alertGroups = computed(() => {
const by = {};
for (const a of alertItems.value) { (by[a.cat] || (by[a.cat] = [])).push(a); }
const keys = ALERT_CAT_ORDER.filter(k => by[k]);
Object.keys(by).forEach(k => { if (!keys.includes(k)) keys.push(k); });
return keys.map(k => ({ key:k, label:(by[k][0].cat_label) || k, items:by[k],
directional: ALERT_CAT_DIRECTIONAL[k] !== false }));
});
const alertStripItems = computed(() => alertItems.value.filter(a => catShown(a.cat)));
const alertLatest = computed(() => alertStripItems.value[0] || {});
const alertStripCount = computed(() => alertStripItems.value.length);
const alertTotalCount = computed(() => alertItems.value.length);
2026-08-13 11:25:34 +08:00
const alertLevelClass = computed(() => {
2026-08-13 13:12:10 +08:00
const it = alertStripItems.value;
2026-08-13 11:25:34 +08:00
if (it.some(x => x.level === 'DANGER')) return 'danger';
if (it.some(x => x.level === 'WARNING')) return 'warn';
return 'none';
});
2026-08-14 09:41:33 +08:00
// 告警分钟龄文案 + 陈旧行置灰。龄由后端按 trigger_time 算好放 age_min;
// stale=超过时间窗 PMS_UPSTREAM_ALERT_MAX_AGE_MIN (置灰不隐藏, 风控面板不丢信息)。
function ageText(row){
const a = row & & row.age_min;
if (a === null || a === undefined) return '';
if (a < = 0) return '刚刚';
if (a < 60 ) return a + ' 分钟前 ' ;
const h = Math.floor(a / 60), m = a % 60;
return m ? (h + '小时' + m + '分前') : (h + '小时前');
}
const alertRowClass = ({ row }) => (row & & row.stale) ? 'alert-stale' : '';
2026-08-13 10:45:04 +08:00
async function loadUpstream() {
const d = await call('get', '/api/upstream-signals');
upstream.value = (d & & d.sources) ? d : { sources: {} };
}
2026-07-27 17:12:09 +08:00
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 || [];
}
2026-08-11 15:38:19 +08:00
function onPosExpand(row, expanded) {
// ElementPlus 展开事件传 (row, 当前展开行数组); 兼容布尔。展开某行即拉它的批次。
const open = Array.isArray(expanded) ? expanded.some(r => r.ts_code === row.ts_code) : !!expanded;
if (open) loadLots(row.ts_code);
}
2026-07-28 09:10:07 +08:00
async function loadDispatchMode() {
const d = await call('get', '/api/dispatch-mode'); dm.value = d.data || d || {};
}
2026-07-29 13:51:10 +08:00
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); });
2026-07-27 17:12:09 +08:00
async function loadAll() {
loading.value = true; err.value = '';
await Promise.all([loadOverview(), loadParams(), loadCatalog(), loadCommands(),
2026-07-28 09:10:07 +08:00
loadPositions(), loadInstructions(), loadLedger(), loadProposals(),
2026-08-13 10:45:04 +08:00
loadDispatchMode(), loadWs(), loadStrategies(), loadOpLog(), loadOpenScan(), loadUpstream()]);
2026-08-10 15:09:29 +08:00
await loadNames();
2026-08-13 15:21:36 +08:00
lastRefresh.value = new Date().toTimeString().slice(0, 8);
2026-07-27 17:12:09 +08:00
loading.value = false;
2026-07-29 13:51:10 +08:00
wsPollSync();
2026-07-27 17:12:09 +08:00
}
2026-07-28 09:10:07 +08:00
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()]);
}
2026-07-27 17:12:09 +08:00
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()]);
}
2026-08-11 11:58:16 +08:00
async function loadStrategies() {
const d = await call('get', '/api/strategies');
strategies.value = d.strategies || (d.data & & d.data.strategies) || [];
stratEnabled.value = !!(d.enabled != null ? d.enabled : (d.data & & d.data.enabled));
}
async function loadOpLog() {
const d = await call('get', '/api/op-log?limit=80');
opLog.value = d.rows || (d.data & & d.data.rows) || [];
}
function openStrategy(row) {
const px = Number(row.price) || 0;
Object.assign(stratDlg, {
visible: true, busy: false, reasons: [], ts_code: row.ts_code,
price: px, total_qty: row.total_qty, avail_qty: row.avail_qty,
cushion_pct: row.cushion_pct, type: 'T0', autonomy: 'auto',
p: { t_ratio: 0.2, lower: +(px * 0.92).toFixed(2), upper: +(px * 1.08).toFixed(2),
center: px, step_pct: 0.02, per_lot: 100, max_capital: 0,
start_line: 0.03, giveback: 0.05, sell_ratio: 1.0, hard_target: 0 }
});
}
function stratPayload() {
const t = stratDlg.type, p = stratDlg.p;
const out = { ts_code: stratDlg.ts_code, type: t, autonomy: stratDlg.autonomy, params: {} };
if (t === 'T0') out.params = { t_ratio: +p.t_ratio };
else if (t === 'GRID') out.params = { lower:+p.lower, upper:+p.upper, center:+p.center,
step_pct:+p.step_pct, per_lot:+p.per_lot, max_capital:+p.max_capital };
else out.params = { start_line:+p.start_line, giveback:+p.giveback,
sell_ratio:+p.sell_ratio, hard_target:+p.hard_target };
return out;
}
async function validateStrategy() {
stratDlg.busy = true;
const d = await call('post', '/api/strategies/validate', stratPayload());
stratDlg.reasons = d.ok ? [] : (d.reasons || [d.error].filter(Boolean));
stratDlg.busy = false;
if (d.ok) ElementPlus.ElMessage.success('校验通过,可挂载');
}
async function attachStrategy() {
stratDlg.busy = true;
const d = await call('post', '/api/strategies', stratPayload());
stratDlg.busy = false;
if (d.ok) { ElementPlus.ElMessage.success('已挂载 ' + (d.strategy_id || ''));
stratDlg.visible = false; await Promise.all([loadStrategies(), loadOpLog()]); }
else { stratDlg.reasons = d.errors || d.reasons || [d.error].filter(Boolean);
ElementPlus.ElMessage.error('挂载被挡下,见下方原因'); }
}
async function setStrategyStatus(sid, status) {
const label = { PAUSED:'暂停', ACTIVE:'恢复', CANCELLED:'撤下' }[status] || status;
try { await ElementPlus.ElMessageBox.confirm(label + '该策略?', '确认', { type:'warning' }); }
catch (e) { return; }
const d = await call('post', '/api/strategies/' + sid + '/status', { status });
ElementPlus.ElMessage[(d.ok ? 'success' : 'error')](d.ok ? (label + '成功') : (d.error || '失败'));
await Promise.all([loadStrategies(), loadOpLog()]);
}
2026-08-11 15:28:46 +08:00
async function resumeBuy(row) {
try { await ElementPlus.ElMessageBox.confirm('恢复 ' + nm(row.ts_code) + ' 的策略买入?此前因决策系统风控预警自动暂停(卖出/平回一直正常)。', '确认', { type:'warning' }); }
catch (e) { return; }
const d = await call('post', '/api/strategies/' + row.strategy_id + '/resume-buy', {});
ElementPlus.ElMessage[(d.ok ? 'success' : 'error')](d.ok ? '已恢复买入' : (d.error || '失败'));
await Promise.all([loadStrategies(), loadOpLog()]);
}
2026-08-11 11:58:16 +08:00
const stratQty = computed(() => {
const p = stratDlg.p, total = stratDlg.total_qty || 0;
if (stratDlg.type === 'T0') return Math.floor(total * (+p.t_ratio || 0) / 100) * 100;
return 0;
});
function stratStateText(s) {
const st = s.state || {};
if (s.type === 'T0') return '今日 ' + (st.t_count_today || 0) + '/3 次'
2026-08-11 15:28:46 +08:00
+ (st.open_leg ? (' · 有未平回的做T( ' + (st.open_leg.dir === 'long' ? '正T已买待卖' : '反T已卖待买回') + ') ') : ' · 今日无未平回');
2026-08-11 11:58:16 +08:00
if (s.type === 'GRID') return '已买 ' + Object.keys(st.filled_levels || {}).length + ' 档 · 投入 '
+ money(st.invested || 0) + (st.below_floor ? ' · 跌破下界已停买' : '');
if (s.type === 'TRAIL') return '高点 ' + (st.high_water || '—') + ' · ' + (st.armed ? '已武装' : '未武装');
return '';
}
2026-07-27 17:12:09 +08:00
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;
}
2026-07-30 16:21:16 +08:00
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;
}
2026-07-31 13:28:10 +08:00
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()]); }
2026-07-30 16:21:16 +08:00
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;
2026-07-31 13:28:10 +08:00
await Promise.all([loadPlan(), loadChanges(), loadOverview()]);
2026-07-30 16:21:16 +08:00
}
2026-07-27 17:12:09 +08:00
async function openReport() {
const d = await call('get', '/api/report'); report.value = d.data || d || {};
reportDrawer.value = true;
}
2026-08-13 10:00:46 +08:00
// —— 自动刷新: 盘中 30s、休市 5min; 只拉快变数据, 不动 params(免得冲掉设置里在改的输入) ——
2026-08-13 15:21:36 +08:00
let refreshTimer = null;
2026-08-13 10:00:46 +08:00
async function refreshLive() {
2026-08-13 15:21:36 +08:00
// allSettled: 单个 loader 失败/超时(axios 60s 兜底)不拖累其余与时间戳更新。
await Promise.allSettled([loadOverview(), loadPositions(), loadInstructions(), loadLedger(),
2026-08-13 10:45:04 +08:00
loadProposals(), loadStrategies(), loadOpLog(), loadOpenScan(), loadUpstream()]);
2026-08-13 15:21:36 +08:00
try { await loadNames(); } catch (e) {}
// 用客户端钟, 保证"自动·HH:MM:SS"稳定跳动, 不依赖 /health 的 now。
lastRefresh.value = new Date().toTimeString().slice(0, 8);
2026-08-13 10:00:46 +08:00
}
function startAutoRefresh() {
if (refreshTimer) return;
2026-08-13 15:21:36 +08:00
// 固定 30s 自动刷新(开关开时)。不再按 marketOpen 分档 —— 交易日历对模拟/未来日期可能
// 误判成非交易日, 那样会把刷新降到 5 分钟、体感像"卡住不刷"。休市时多刷几次也无害。
refreshTimer = setInterval(() => { if (autoRefresh.value) refreshLive(); }, 30000);
2026-08-13 10:00:46 +08:00
}
onUnmounted(() => { if (refreshTimer) clearInterval(refreshTimer); });
onMounted(() => { loadAll(); loadPlan(); startAutoRefresh(); }); // 首屏 + 启动自动刷新
2026-07-27 17:12:09 +08:00
return { tab, loading, err, health, ov, params, catalog, commands, plans, plansOf,
positions, lots, lotsOf, instructions, ledger, proposals, report, reportDrawer,
2026-07-28 09:10:07 +08:00
opsDrawer, opsResult, opsLoading, issuing, form, curSpec, dirtyCount, dm,
money, pct, groupLabel, fieldLabel, stTag, cuTag, canCancel, progPct, insPct,
2026-07-30 13:02:51 +08:00
scaleGap,
2026-07-29 13:51:10 +08:00
wsRaw, wsAuto, wsHidePong, wsc, wst, wsMode, wsOrders, wsInbox, wsWarnings, loadWs,
2026-07-27 17:12:09 +08:00
loadAll, loadParams, saveParams, loadPlans, loadLots, onCmdChange, issue,
2026-07-30 16:21:16 +08:00
cancelCmd, replan, decide, ops, loadSchema, openReport, cancelIns,
planDrawer, planLoading, planStatus, planRows, planCand, planResult,
2026-07-31 13:28:10 +08:00
loadPlan, openPlan, refreshPlan,
chgRaw, chgSt, chgLoading, chgTab, chg, chgHeld, chgLog, chgRevised, chgWatch,
2026-08-10 15:09:29 +08:00
loadChanges,
mode, tx, nm, fullName, nameMap, attention, heldPositions, traderParams,
propWhy, nextStep, scrollTo, actExitStock, actReduceStock, rowMore,
2026-08-11 09:42:40 +08:00
pHaltBuy, pResumeBuy, pHaltAll, pResumeAll, pReduce, pIncrease, pLiquidate, pSectorExit,
pctOf, tgtPos, posMoveValid, posMovePreview, doPosMove, heldSectors, secSel, doSectorExit,
2026-08-11 11:58:16 +08:00
pmap, pval, dcaOn, sumPosition, sumDca, sumAutonomy,
strategies, stratEnabled, opLog, stratDlg, stratQty, loadStrategies, loadOpLog,
2026-08-11 15:38:19 +08:00
openStrategy, validateStrategy, attachStrategy, setStrategyStatus, stratStateText, resumeBuy,
2026-08-12 14:51:22 +08:00
showLedger, onPosExpand,
2026-08-12 15:55:29 +08:00
openScan, insTab, todayYmd, insLive, insDone, insEnd, dispOf, loadOpenScan, denyToday,
2026-08-13 10:45:04 +08:00
showOpLog, showSettings, watchCandidates, marketOpen, marketState, lastRefresh, autoRefresh,
2026-08-13 11:25:34 +08:00
upstream, showUpstream, usrc, loadUpstream,
2026-08-13 13:12:10 +08:00
showAlerts, alertItems, alertLatest, alertLevelClass, alertGroups, alertStripItems,
2026-08-14 09:41:33 +08:00
alertStripCount, alertTotalCount, catShown, toggleCat, ageText, alertRowClass };
2026-07-27 17:12:09 +08:00
}
}).use(ElementPlus).mount('#app');
< / script >
< / body >
< / html >