3452 lines
233 KiB
HTML
3452 lines
233 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN" data-theme="light">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>PMS 持仓管理系统</title>
|
||
<link rel="stylesheet" href="/static/vendor/element-plus.css"/>
|
||
<script src="/static/vendor/vue.global.js"></script>
|
||
<script src="/static/vendor/element-plus.js"></script>
|
||
<script src="/static/vendor/axios.min.js"></script>
|
||
<style>
|
||
: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:1860px;margin:0 auto;padding:0 16px 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 -16px 6px;padding:9px 16px;background:rgba(244,245,242,.94);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);}
|
||
/* 信号速览带 (2026-08-26): 全宽紧凑行, 高度受控内部滚动 */
|
||
.sigbrief{max-height:216px;overflow:auto}
|
||
.sb-row{display:flex;gap:8px;align-items:center;padding:2px 0;border-bottom:1px dashed rgba(128,128,128,.18)}
|
||
.sb-row:last-child{border-bottom:none}
|
||
.sb-t{width:46px;flex:none;font-size:11.5px}
|
||
/* 颜色约定图例 */
|
||
.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.sig-up{background:var(--up-weak);color:var(--up);} .disp.sig-down{background:var(--down-weak);color:var(--down);}
|
||
.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);}
|
||
.disp.strong{background:var(--accent-weak);color:var(--accent);}
|
||
.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;}
|
||
.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;}}
|
||
.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;}
|
||
/* 上游信号改版 (2026-08-18): 按重要度分档 + 空的在用信号收进待触发条 */
|
||
.sig-tier{margin-top:16px;}
|
||
.sig-tier-h{display:flex;align-items:baseline;gap:8px;margin:0 0 9px;flex-wrap:wrap;}
|
||
.sig-tier-h .tt{font-size:10px;font-weight:700;padding:1px 7px;border-radius:5px;letter-spacing:.3px;}
|
||
.tt-a{background:var(--stop-weak);color:var(--stop);} .tt-b{background:var(--accent-weak);color:var(--accent);} .tt-c{background:rgba(138,136,130,.14);color:var(--ink-2);}
|
||
.sig-tier-h b{font-size:13px;} .sig-tier-h .d{font-size:11px;color:var(--ink-2);}
|
||
.sig-grid{display:grid;grid-template-columns:repeat(12,1fr);gap:14px;}
|
||
.sig-grid .up-cell{margin:0;}
|
||
.c-wide{grid-column:span 8;} .c-half{grid-column:span 6;} .c-third{grid-column:span 4;}
|
||
@media(max-width:960px){.c-wide,.c-half,.c-third{grid-column:span 12;}}
|
||
.sig-rows{max-height:210px;overflow:auto;}
|
||
.sig-row{display:flex;align-items:center;gap:8px;padding:6px 0;border-top:1px solid var(--hair);font-size:13px;}
|
||
.sig-row:first-child{border-top:none;}
|
||
.sig-nm{font-weight:600;} .sig-sp{flex:1;}
|
||
.intens{display:inline-flex;align-items:center;gap:5px;}
|
||
.ibar{display:inline-flex;gap:2px;}
|
||
.ibar i{width:4px;height:11px;border-radius:1px;background:var(--hair);display:inline-block;}
|
||
.ibar i.on{background:var(--ink-2);}
|
||
.armed{display:flex;align-items:center;gap:9px;flex-wrap:wrap;border:1px dashed var(--hair);border-radius:10px;padding:9px 13px;background:var(--surface-2);}
|
||
.armed .lead{font-size:12px;color:var(--ink-2);font-weight:600;}
|
||
.armed .chip{display:inline-flex;align-items:center;gap:6px;font-size:12px;padding:3px 10px;border-radius:14px;background:var(--surface);border:1px solid var(--hair);}
|
||
.armed .chip .dot{width:6px;height:6px;border-radius:50%;background:#c9c7c0;}
|
||
.armed .chip.dead{text-decoration:line-through;opacity:.55;}
|
||
/* 固定成对网格 (2026-08-18 改版二): 三对各占一行, 位置稳定不重排; mtf 一对并排拉高 */
|
||
.sig2{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:14px;align-items:stretch;}
|
||
@media(max-width:880px){.sig2{grid-template-columns:1fr;}}
|
||
.sig2 .up-cell{min-height:200px;display:flex;flex-direction:column;margin:0;}
|
||
.sig2.tall .up-cell{min-height:340px;}
|
||
.sig2 .sig-rows{flex:1;max-height:none;}
|
||
.sig-ev{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px;margin-top:14px;}
|
||
.sig-ev .up-cell{margin:0;}
|
||
.sig-none{color:var(--ink-2);padding:10px 0;font-size:12.5px;}
|
||
.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;}
|
||
/* 顶部风控告警条 (系统状态色: 琥珀/橙/灰, 不用红绿; 红绿只留给方向看涨看跌) */
|
||
.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-dot.lv-strong{background:var(--accent);}
|
||
.alert-panel{margin-top:0;}
|
||
.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);}
|
||
/* 陈旧告警置灰 (超龄不隐藏, 只灰: 风控面板不丢信息)。灰=陈旧, 不动红绿(红绿只留给方向)。 */
|
||
.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;}
|
||
/* 上游信号中心: 最新告警细提示 + 级别筛选 chip + 分节标题 */
|
||
.sig-ticker{display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--surface);border:1px solid var(--hair);border-left:3px solid var(--hair);border-radius:12px;padding:8px 14px;margin:16px 0 8px;box-shadow:var(--shadow);font-size:13px;}
|
||
.sig-ticker.lv-danger{border-left-color:var(--stop);} .sig-ticker.lv-warn{border-left-color:var(--warn);} .sig-ticker.lv-strong{border-left-color:var(--accent);}
|
||
.sig-ticker .sig-tk-label{font-weight:640;color:var(--ink-2);white-space:nowrap;}
|
||
.sig-filter{display:flex;align-items:center;gap:7px;flex-wrap:wrap;margin:2px 0 12px;}
|
||
.lv-chip{font-size:12px;font-weight:560;padding:2px 11px;border-radius:999px;cursor:pointer;border:1px solid var(--hair);user-select:none;color:var(--ink-2);background:var(--surface-2);}
|
||
.lv-chip.off{color:var(--muted);text-decoration:line-through;opacity:.65;}
|
||
.lv-chip.on.lv-danger{color:var(--stop);background:var(--stop-weak);border-color:var(--stop-weak);}
|
||
.lv-chip.on.lv-warn{color:var(--warn);background:var(--warn-weak);border-color:var(--warn-weak);}
|
||
.lv-chip.on.lv-info{color:var(--ink-2);background:rgba(138,136,130,.14);}
|
||
.lv-chip.on.lv-strong{color:var(--accent);background:var(--accent-weak);border-color:var(--accent-weak);}
|
||
.sig-sep{font-size:12px;color:var(--muted);font-weight:600;margin:16px 0 8px;padding-top:9px;border-top:1px dashed var(--hair);}
|
||
/* 宏观择时面板 (MACRO_TIMING_PLAN.md) */
|
||
.mz-bars{display:flex;align-items:flex-end;gap:2px;height:34px;margin:7px 0 3px;}
|
||
.mz-bar{width:9px;border-radius:2px;min-height:3px;opacity:.9;}
|
||
.mz-zone{padding:1px 9px;border-radius:9px;font-size:12px;font-weight:600;color:#fff;}
|
||
/* ══════════════ 交易员工作台改版 (2026-08-26) ══════════════
|
||
三栏骨架 + 融合消息栏 + 可折叠可浮出的左右栏 + 粘性操作列。
|
||
宽度分三段, 临界值按「主区要放得下持仓表」倒推:
|
||
持仓表天然宽 942 + 面板内边距 32 + 边框 2 = 976
|
||
两栏都常驻 976+302+334+26+32 = 1670 → 取 1680
|
||
只常驻左栏 976+302+ 40+26+32 = 1376 → 取 1380
|
||
低于 1380 两栏都收成 40 像素窄条, 点开是浮层。主区永不换行堆叠。 */
|
||
:root{ --rail-w:302px; --rail-w-r:334px; --strip-w:40px; }
|
||
|
||
/* 顶栏不许掉成两行: 宽度不够时按重要性从低到高依次收起 */
|
||
@media(max-width:1560px){ .hd .ver{display:none;} }
|
||
@media(max-width:1400px){ .hd .hd-refresh-at{display:none;} }
|
||
@media(max-width:1240px){ .hd .hd-macro{display:none;} }
|
||
@media(max-width:1120px){ .hd .hd-exec{display:none;} .hd .hd-plan-d{display:none;} }
|
||
@media(max-width:1000px){ .hd .hd-buy{display:none;} .hd .hd-report{display:none;} }
|
||
|
||
/* 运维视图保持原来的居中宽度; 交易员视图铺开用两侧留白 */
|
||
.ops-wrap{max-width:1280px;margin:0 auto;}
|
||
|
||
/* ── 融合消息栏: 上游告警 + 系统下单 + 等我拍板 + 策略与风控, 混成一条时间线 ── */
|
||
.msgbar{position:sticky;top:46px;z-index:40;display:flex;align-items:center;gap:9px;
|
||
background:var(--surface);border:1px solid var(--hair);border-left:3px solid var(--accent);
|
||
border-radius:12px;padding:7px 12px;margin:10px 0 4px;box-shadow:var(--shadow);font-size:13px;}
|
||
.msgbar.lv-warn{border-left-color:var(--warn);}
|
||
.msgbar.lv-stop{border-left-color:var(--stop);}
|
||
.msgbar.lv-none{border-left-color:var(--hair);}
|
||
.mb-label{font-weight:660;color:var(--ink-2);white-space:nowrap;display:flex;align-items:center;gap:6px;}
|
||
.mb-label .bell{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 3px var(--accent-weak);}
|
||
.mb-tabs{display:flex;gap:5px;}
|
||
.mb-tab{font-size:11.5px;font-weight:560;padding:2px 9px;border-radius:999px;cursor:pointer;
|
||
border:1px solid transparent;background:var(--surface-2);color:var(--ink-2);user-select:none;white-space:nowrap;}
|
||
.mb-tab .n{opacity:.62;margin-left:3px;}
|
||
.mb-tab.on{background:var(--accent-weak);color:var(--accent);border-color:#cfe1f8;}
|
||
.mb-latest{flex:1;min-width:0;display:flex;align-items:center;gap:7px;overflow:hidden;white-space:nowrap;}
|
||
.mb-latest .txt{overflow:hidden;text-overflow:ellipsis;}
|
||
.mb-more{flex:none;color:var(--accent);font-size:12.5px;font-weight:560;cursor:pointer;
|
||
background:none;border:0;padding:3px 2px;white-space:nowrap;}
|
||
.mb-sep{width:1px;height:15px;background:var(--hair);flex:none;}
|
||
@media(max-width:1080px){ .msgbar .mb-tabs{display:none;} }
|
||
.mb-panel{position:relative;background:var(--surface);border:1px solid var(--hair);border-radius:12px;
|
||
box-shadow:0 12px 38px rgba(17,17,16,.14),0 2px 8px rgba(17,17,16,.06);
|
||
padding:4px 0;margin:0 0 10px;max-height:392px;overflow:auto;z-index:39;}
|
||
.mb-panel .mb-tabs{padding:7px 14px 8px;border-bottom:1px solid var(--hair);}
|
||
.mb-row{display:flex;align-items:flex-start;gap:9px;padding:6px 14px;font-size:13px;}
|
||
.mb-row:hover{background:var(--surface-2);}
|
||
.mb-kind{width:52px;flex:none;font-size:11px;font-weight:600;padding:1px 0;border-radius:5px;text-align:center;}
|
||
.k-alert{background:var(--accent-weak);color:var(--accent);}
|
||
.k-order{background:var(--down-weak);color:var(--down);}
|
||
.k-prop{background:var(--warn-weak);color:var(--warn);}
|
||
.k-strat{background:var(--stop-weak);color:var(--stop);}
|
||
.mb-row .t{width:42px;flex:none;font-size:11.5px;color:var(--muted);padding-top:1px;}
|
||
.mb-row .body{flex:1;min-width:0;}
|
||
.mb-row .body .why{color:var(--ink-2);font-size:12px;margin-top:1px;line-height:1.5;}
|
||
.mb-row .go{flex:none;font-size:11.5px;color:var(--accent);cursor:pointer;opacity:0;white-space:nowrap;padding-top:2px;}
|
||
.mb-row:hover .go{opacity:1;}
|
||
.mb-empty{color:var(--muted);font-size:12.5px;padding:12px 14px;}
|
||
|
||
/* ── 三栏骨架 ── */
|
||
.shell{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:13px;align-items:start;}
|
||
.side{position:sticky;top:96px;align-self:start;transition:width .16s ease;}
|
||
.side-l,.side-r{width:var(--strip-w);}
|
||
body.dock-l:not(.l-fold) .side-l{width:var(--rail-w);}
|
||
body.dock-r:not(.r-fold) .side-r{width:var(--rail-w-r);}
|
||
.rail{background:var(--surface);border:1px solid var(--hair);border-radius:14px;box-shadow:var(--shadow);
|
||
display:none;flex-direction:column;max-height:calc(100vh - 116px);overflow:hidden;}
|
||
body.dock-l:not(.l-fold) #railL{display:flex;}
|
||
body.dock-r:not(.r-fold) #railR{display:flex;}
|
||
/* 浮层几何刻意只用老写法 (2026-09-01): min()/inset 这类新函数在旧 Safari 上整条声明
|
||
被丢, 浮层就成了没背景没遮罩的"透明面板" (iPad 实锅)。宽度用 width+max-width 等价
|
||
表达; 背景先给一条固定色兜底再让变量覆盖 */
|
||
.rail.open{display:flex;position:fixed;top:96px;bottom:18px;width:560px;max-width:88vw;max-height:none;
|
||
background:#ffffff;background:var(--surface);
|
||
z-index:2010;box-shadow:0 12px 38px rgba(17,17,16,.2),0 2px 8px rgba(17,17,16,.08);}
|
||
#railL.open{left:16px;} #railR.open{right:16px;}
|
||
.rail-scrim{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(17,17,16,.3);z-index:2005;display:none;}
|
||
.rail-scrim.on{display:block;}
|
||
.rail-h{display:flex;align-items:center;gap:7px;padding:9px 11px;border-bottom:1px solid var(--hair);flex:none;}
|
||
.rail-h .ttl{font-size:13.5px;font-weight:660;white-space:nowrap;}
|
||
.rail-h .cnt{font-size:11.5px;color:var(--muted);font-weight:500;white-space:nowrap;}
|
||
.iconbtn{border:1px solid transparent;background:transparent;color:var(--muted);border-radius:6px;
|
||
width:22px;height:22px;line-height:1;font-size:12px;cursor:pointer;flex:none;
|
||
display:inline-flex;align-items:center;justify-content:center;}
|
||
.iconbtn:hover{background:var(--surface-2);color:var(--accent);border-color:var(--hair);}
|
||
.btn-close{display:none;}
|
||
.rail.open .btn-close{display:inline-flex;}
|
||
.rail.open .btn-fold,.rail.open .btn-pull{display:none;}
|
||
.rail-body{flex:1;overflow:auto;padding:9px 11px 12px;}
|
||
/* 窄条: 手动收起和宽度不够共用这一块 */
|
||
.strip{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;
|
||
width:var(--strip-w);min-height:150px;padding:14px 0;background:var(--surface);
|
||
border:1px solid var(--hair);border-radius:14px;box-shadow:var(--shadow);cursor:pointer;
|
||
touch-action:manipulation;user-select:none;-webkit-user-select:none;
|
||
-webkit-tap-highlight-color:rgba(42,120,214,.18);}
|
||
.strip:hover{border-color:var(--accent);}
|
||
/* 触屏端 (iPad 等): 去掉点按歧义等待、命中区加大。鼠标端 (pointer:fine) 完全不受影响 */
|
||
.iconbtn,.railtabs button,.lv-chip,.rail-scrim,.rail-h{touch-action:manipulation;}
|
||
@media (pointer:coarse){
|
||
:root{ --strip-w:48px; }
|
||
.strip{min-height:176px;}
|
||
.iconbtn{width:30px;height:30px;font-size:14px;}
|
||
}
|
||
/* 无悬停设备 (触屏): ? 号的悬停气泡会在点按后粘住不消失 (iPad 截图里那块黑浮块),
|
||
干脆不出气泡 —— 它只是补充说明, 正文都能看到 */
|
||
@media (hover:none){
|
||
.help:hover::after{display:none;}
|
||
}
|
||
body.dock-l:not(.l-fold) .side-l .strip{display:none;}
|
||
body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
||
.strip .vtxt{writing-mode:vertical-rl;font-size:12.5px;font-weight:620;color:var(--ink-2);letter-spacing:2px;white-space:nowrap;}
|
||
.strip .badge{font-size:10.5px;font-weight:700;color:#fff;background:var(--accent);border-radius:999px;padding:1px 5px;}
|
||
.strip .arr{color:var(--muted);font-size:11px;}
|
||
|
||
/* ── 中区 ── */
|
||
.tmain{min-width:0;container-type:inline-size;}
|
||
.tmain .metrics{grid-template-columns:repeat(8,minmax(0,1fr));gap:9px;margin:0 0 13px;}
|
||
.tmain .m{padding:9px 11px;border-radius:11px;}
|
||
.tmain .m .k{font-size:11.5px;white-space:nowrap;display:flex;align-items:center;gap:1px;}
|
||
.tmain .m .v{font-size:18px;margin-top:3px;white-space:nowrap;}
|
||
@container (max-width:1100px){ .tmain .metrics{grid-template-columns:repeat(4,minmax(0,1fr));} }
|
||
@container (max-width:560px){ .tmain .metrics{grid-template-columns:repeat(2,minmax(0,1fr));} }
|
||
.tmain .panel{padding:14px 16px;margin-bottom:13px;}
|
||
/* 持仓表: 操作列用 Element Plus 的 fixed="right" 钉住, 表格自己横向滚动 */
|
||
.postbl .el-table__fixed-right{box-shadow:-9px 0 11px -11px rgba(17,17,16,.4);}
|
||
.act-cell{display:flex;gap:5px;align-items:center;flex-wrap:nowrap;}
|
||
/* 钉住的操作列要不透明, 否则下面那列的文字会透上来 (表格背景被设成了 transparent) */
|
||
.postbl .el-table__fixed-right{background:var(--surface);}
|
||
.postbl .el-scrollbar__wrap{overflow-x:auto;}
|
||
.postbl .el-table__fixed-right .el-table__cell,
|
||
.postbl td.el-table-fixed-column--right{background:var(--surface)!important;}
|
||
.postbl .el-table__fixed-right th.el-table__cell,
|
||
.postbl th.el-table-fixed-column--right{background:var(--surface-2)!important;}
|
||
.postbl .el-table__body tr:hover>td.el-table-fixed-column--right,
|
||
.postbl .el-table__fixed-right .el-table__row:hover>.el-table__cell{background:var(--surface-2)!important;}
|
||
|
||
/* ── 左栏 信号流 ── */
|
||
.railtabs{display:flex;gap:3px;margin-bottom:8px;background:var(--surface-2);border-radius:8px;padding:2px;}
|
||
.railtabs button{flex:1;border:0;background:transparent;padding:5px 6px;font-size:12.5px;
|
||
color:var(--ink-2);border-radius:6px;cursor:pointer;font-weight:560;}
|
||
.railtabs button.on{background:var(--surface);color:var(--ink);box-shadow:var(--shadow);}
|
||
.sigrow2{display:flex;align-items:center;gap:6px;padding:5px 2px;
|
||
border-bottom:1px dashed rgba(128,128,128,.16);font-size:12.5px;}
|
||
.sigrow2:last-child{border-bottom:none;}
|
||
.sigrow2 .t{width:36px;flex:none;font-size:11px;color:var(--muted);}
|
||
.sigrow2 .nm{font-weight:620;white-space:nowrap;}
|
||
.sigrow2 .cat{color:var(--muted);font-size:11.5px;white-space:nowrap;}
|
||
.sigrow2 .sp{flex:1;}
|
||
.sigrow2.old{opacity:.5;}
|
||
.pin{font-size:10px;font-weight:700;color:var(--stop);background:var(--stop-weak);border-radius:4px;padding:0 4px;flex:none;}
|
||
.pin.plan{color:var(--accent);background:var(--accent-weak);}
|
||
.srcgroup{border:1px solid var(--hair);border-radius:10px;margin-bottom:7px;overflow:hidden;background:var(--surface-2);}
|
||
.srch{display:flex;align-items:center;gap:6px;padding:7px 10px;cursor:pointer;
|
||
font-size:12.5px;font-weight:600;color:var(--ink-2);}
|
||
.srch .n{font-weight:500;color:var(--muted);font-size:11.5px;}
|
||
.srcb{padding:2px 10px 8px;background:var(--surface);border-top:1px solid var(--hair);}
|
||
|
||
/* ── 右栏 待办与操作 ── */
|
||
.sect{border-bottom:1px solid var(--hair);padding:2px 0 9px;margin-bottom:9px;}
|
||
.sect:last-child{border-bottom:none;}
|
||
.secth{display:flex;align-items:center;gap:7px;padding:5px 0;cursor:pointer;font-size:13px;font-weight:640;}
|
||
.secth .n{font-weight:500;color:var(--muted);font-size:11.5px;}
|
||
.secth .car{margin-left:auto;color:var(--muted);font-size:10px;}
|
||
.mini{border:1px solid var(--hair);border-radius:9px;padding:8px 10px;margin-bottom:6px;
|
||
background:var(--surface-2);font-size:12.5px;}
|
||
.mini .top{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
|
||
.mini .st{color:var(--ink-2);font-size:12px;margin-top:3px;line-height:1.5;}
|
||
.oprow2{display:flex;align-items:flex-start;gap:8px;padding:6px 0;border-bottom:1px solid var(--hair);}
|
||
.oprow2:last-child{border-bottom:none;}
|
||
.oprow2 .d{font-size:11.5px;color:var(--ink-2);line-height:1.5;}
|
||
.oprow2 .b{flex:none;width:92px;}
|
||
.oprow2 .b .el-button{width:100%;}
|
||
/* 登录遮罩与登录人胶囊 (2026-08 加权限) */
|
||
.login-mask{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;
|
||
background:rgba(20,20,18,.55);backdrop-filter:blur(4px);}
|
||
.login-card{width:320px;background:var(--surface);border:1px solid var(--hair);border-radius:14px;
|
||
box-shadow:0 12px 40px rgba(0,0,0,.28);padding:26px 24px;}
|
||
.login-card h2{margin:0 0 4px;font-size:18px;}
|
||
.login-sub{color:var(--muted);font-size:13px;margin-bottom:16px;}
|
||
.login-in{width:100%;box-sizing:border-box;border:1px solid var(--hair);border-radius:9px;
|
||
padding:10px 12px;font-size:14px;margin-bottom:10px;background:var(--surface-2);color:var(--ink);}
|
||
.login-in:focus{outline:none;border-color:var(--accent);}
|
||
.login-err{color:var(--up);font-size:12.5px;margin:2px 0 10px;}
|
||
.login-btn{width:100%;border:0;border-radius:9px;background:var(--accent);color:#fff;
|
||
padding:11px;font-size:14px;font-weight:600;cursor:pointer;}
|
||
.login-btn:disabled{opacity:.6;cursor:default;}
|
||
.chip.hd-user{background:var(--accent-weak);color:var(--accent);} .chip.hd-user .dot{background:var(--accent);}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="app" class="app" v-cloak>
|
||
|
||
<!-- 登录遮罩: 未登录时盖住整页 (2026-08 加权限) -->
|
||
<div v-if="authReady && !authed" class="login-mask">
|
||
<div class="login-card">
|
||
<h2>PMS 持仓管理系统</h2>
|
||
<div class="login-sub">请用手机号和密码登录</div>
|
||
<input class="login-in" v-model.trim="loginForm.phone" placeholder="手机号"
|
||
@keyup.enter="doLogin" autocomplete="username"/>
|
||
<input class="login-in" v-model="loginForm.password" type="password" placeholder="密码"
|
||
@keyup.enter="doLogin" autocomplete="current-password"/>
|
||
<div class="login-err" v-if="loginErr">{{ loginErr }}</div>
|
||
<button class="login-btn" :disabled="loginBusy" @click="doLogin">{{ loginBusy ? '登录中…' : '登录' }}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="hd">
|
||
<h1>PMS 持仓管理<span class="ver">v{{ health.version || '-' }} · {{ health.now || '' }}</span></h1>
|
||
<span class="chip" :class="marketOpen?'ok':'idle'"><span class="dot"></span>{{ marketState }}</span>
|
||
<span class="chip hd-buy" :class="ov.buy_halt?'warn':'ok'"><span class="dot"></span>买入 {{ ov.buy_halt ? '已暂停':'正常' }}</span>
|
||
<span class="chip hd-exec" :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 hd-plan" :class="planStatus.ok===false?'warn':'ok'"><span class="dot"></span>选股计划<span class="hd-plan-d"> {{ planStatus.ok===false ? '读不到' : (planStatus.date || '-') }}</span></span>
|
||
<el-tooltip placement="bottom-start" effect="light" :show-after="120">
|
||
<template #content>
|
||
<div style="max-width:360px;font-size:12px;line-height:1.75">
|
||
<template v-if="macro.enabled===false"><b>大盘冷热这一层没开</b><div class="muted">在「参数设置」里把 PMS_MACRO_ENABLED 打开</div></template>
|
||
<template v-else-if="!mzSig"><b>大盘冷热</b>:还没有数据(今天还没扫描过,交易日 09:35 自动跑)</template>
|
||
<template v-else>
|
||
<div><b>{{ mzSig.label }}</b>
|
||
<span class="mono" style="font-weight:700" :style="{color: mzColor(mzSig.zone)}">{{ mzSig.value==null ? '—' : (mzSig.value>0?'+':'') + Number(mzSig.value).toFixed(1) }}</span>
|
||
<span class="mz-zone" :style="{background: mzColor(mzSig.zone)}">{{ mzLabel(mzSig.zone) }}</span>
|
||
<span class="muted" v-if="mzSig.ymd"> {{ mzSig.ymd }}</span></div>
|
||
<div class="muted" v-if="mzSig.e_now>0">偏离深度 {{ mzSig.e_now }}<template v-if="mzSig.streak"> · 区内第 {{ mzSig.streak }} 天</template></div>
|
||
<div class="muted" v-if="mzSig.note">{{ mzSig.note }}</div>
|
||
<div class="mz-bars" v-if="(mzSig.history||[]).length">
|
||
<span v-for="(h,i) in mzSig.history" :key="i" class="mz-bar" :style="mzBarStyle(h)"
|
||
:title="h.ymd + ' · ' + (h.value==null ? '不可用' : h.value) + ' · ' + mzLabel(h.zone)"></span>
|
||
</div>
|
||
<div class="muted" style="font-size:11px">近 {{ (mzSig.history||[]).length }} 个交易日 · 热 +{{ (macro.params||{}).hot_th }} / 冷 {{ (macro.params||{}).cold_th }} / 退出带 ±{{ (macro.params||{}).exit_band }} · 档位 {{ macro.autonomy==='full' ? '全自动' : (macro.autonomy==='propose_only' ? '只出建议' : '只算不动') }}</div>
|
||
<div v-if="macroGateOn" style="color:var(--warn)">大盘偏热,系统已经停掉它自己发起的买入,以及策略里的买入动作。卖出不受影响,你自己下的命令也不受影响。</div>
|
||
<div v-if="mzSig.advice && !mzSig.advice.adopted" style="margin-top:4px">
|
||
<b>今日建议:{{ mzSig.advice.direction==='REDUCE' ? '降仓' : '升仓' }} {{ (mzSig.advice.pct*100).toFixed(1) }}%</b>
|
||
<button class="tbtn pri" style="margin-left:8px" @click="macroAdopt(mzSig.key)">采纳并下命令</button>
|
||
<div class="muted" style="font-size:11px">{{ mzSig.advice.why }} · 当日有效,隔日作废</div>
|
||
</div>
|
||
<div class="muted" v-if="mzSig.last_command" style="font-size:11px">最近一条大盘冷热命令 {{ mzSig.last_command.command_id }} · {{ mzSig.last_command.cmd_type==='REDUCE_EXPOSURE' ? '降仓' : '升仓' }} · {{ mzSig.last_command.status }}</div>
|
||
<div v-show="mode==='ops'" style="margin-top:5px">
|
||
<button class="tbtn" @click="macroScan(true)">试算一遍</button>
|
||
<button class="tbtn" @click="macroScan(false)">立即扫描</button>
|
||
</div>
|
||
<div style="color:var(--warn)" v-if="mzSig.error">读取失败: {{ mzSig.error }}</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<span class="chip hd-macro" :class="mzChipCls" :style="mzAdvicePending ? 'cursor:pointer' : ''" @click="macroChipClick"><span class="dot"></span>大盘冷热 {{ mzChipText }}<span class="help" data-tip="看整个市场是偏热还是偏冷,用来决定要不要整体加仓或减仓。偏热时系统会自己停掉新买入。">?</span></span>
|
||
</el-tooltip>
|
||
<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>
|
||
<div class="spacer"></div>
|
||
<div class="seg">
|
||
<button :class="{on:mode==='trader'}" @click="mode='trader'">交易员</button>
|
||
<button v-if="isAdmin" :class="{on:mode==='ops'}" @click="mode='ops'">运维</button>
|
||
</div>
|
||
|
||
<button class="tbtn hd-report" @click="openPlan">选股计划</button>
|
||
<button class="tbtn hd-report" @click="openReport">日报</button>
|
||
<button class="tbtn hd-report" v-if="authed" @click="doExport" title="按公司《量化数据》模板导出当日持仓、平仓与净值 (系统接管后的数据)">导出持仓表</button>
|
||
<button class="tbtn" v-show="mode==='ops'" @click="opsDrawer=true">运维</button>
|
||
<span class="muted hd-refresh-at" style="font-size:11.5px;cursor:pointer" @click="autoRefresh=!autoRefresh"
|
||
:title="autoRefresh?'自动刷新开,点此关闭':'自动刷新关,点此开启'">{{ autoRefresh ? '自动' : '手动' }}<span v-if="lastRefresh"> · {{ lastRefresh }}</span></span>
|
||
<button class="tbtn pri" :disabled="loading" @click="loadAll">{{ loading ? '刷新中…' : '刷新' }}</button>
|
||
<span class="chip hd-user" v-if="authed" :title="me.phone"><span class="dot"></span>{{ me.username || me.phone }} · {{ rolesText }}</span>
|
||
<button class="tbtn sm" v-if="authed" @click="doLogout">退出</button>
|
||
</div>
|
||
|
||
<el-alert v-if="err" class="banner" :title="err" type="error" effect="dark" show-icon
|
||
@close="err=''"></el-alert>
|
||
<el-alert v-if="planStatus.ok===false" class="banner" type="error" effect="dark" show-icon
|
||
:closable="false"
|
||
:title="'上游的选股计划读不到 —— 加仓和建仓这类命令现在没有票可选: ' + (planStatus.hint||'')">
|
||
</el-alert>
|
||
<el-alert v-if="ov.sector_ready===false" class="banner" type="warning" effect="dark" show-icon
|
||
:closable="false"
|
||
title="行业划分的数据源没配好:按行业集中度拦截的那道检查已停用,行业类的命令暂时点不动"></el-alert>
|
||
<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>
|
||
<el-alert v-if="health.calendar_degraded" class="banner" type="warning" effect="dark" show-icon
|
||
:closable="false" title="交易日历不完整:没装 chinesecalendar,认不出法定节假日"></el-alert>
|
||
<el-alert v-if="dm.shadow" class="banner" type="info" effect="dark" show-icon :closable="false"
|
||
:title="'现在是影子模式,只记录不真下单 —— ' + (dm.hint || '')"></el-alert>
|
||
|
||
<el-alert v-if="macroGateOn" class="banner" type="warning" effect="dark" show-icon :closable="false"
|
||
:title="'大盘偏热,系统已经暂停两件事:它自己发起的买入,以及策略里的买入动作。卖出不受影响,你自己下的命令也不受影响。' + ((macro.gate||{}).why||'')"></el-alert>
|
||
<el-alert v-if="macroUnavailable" class="banner" type="warning" show-icon :closable="false"
|
||
:title="'大盘冷热的数据读不到,今天这一层不做任何动作、也不加限制: ' + macroUnavailable"></el-alert>
|
||
|
||
<el-alert v-if="scaleGap" class="banner" type="warning" effect="dark" show-icon :closable="false"
|
||
:title="'你设的总规模和账户实际对不上 —— 参数里写的是 ' + money(ov.scale) + ',账户总资产是 ' + money(ov.total_asset) + '(差 ' + pct(scaleGap) + ')。仓位上限按参数算,钱够不够按账户算。两个数差得远,方案会通过检查但下不出去。'"></el-alert>
|
||
|
||
<!-- ==================== 交易员视图 (2026-08-26 三栏改版) ==================== -->
|
||
<div v-show="mode==='trader'">
|
||
|
||
<!-- 0) 融合消息栏: 行情告警 / 系统下单 / 等我拍板 / 策略与风控, 按时间倒序混一条流。
|
||
点「展开全部」就地往下拉一块面板, 不跳转不滚动。 -->
|
||
<div class="msgbar" :class="'lv-'+msgLevelClass">
|
||
<span class="mb-label"><span class="bell"></span>消息</span>
|
||
<div class="mb-tabs">
|
||
<span v-for="k in MSG_KINDS" :key="k.key" class="mb-tab" :class="{on:msgKind===k.key}"
|
||
@click="setMsgKind(k.key)">{{ k.label }}<span class="n">{{ msgCounts[k.key] }}</span></span>
|
||
</div>
|
||
<span class="mb-sep"></span>
|
||
<div class="mb-latest">
|
||
<template v-if="msgLatest">
|
||
<span class="mono muted" style="font-size:11.5px">{{ msgLatest.time || '—' }}</span>
|
||
<span class="mb-kind" :class="'k-'+msgLatest.kind" style="width:auto;padding:1px 6px">{{ kindLabel(msgLatest.kind) }}</span>
|
||
<span class="txt">
|
||
<b v-if="msgLatest.ts_code">{{ nm(msgLatest.ts_code) }}</b>
|
||
<span v-if="msgLatest.ts_code" class="muted mono" style="font-size:11px">{{ msgLatest.ts_code }}</span>
|
||
<span v-if="msgLatest.dir" :class="dirCls(msgLatest.dir)">{{ msgLatest.dir }}</span>
|
||
<span class="muted">{{ msgLatest.cat }}</span>
|
||
</span>
|
||
<span v-if="msgLatest.level" class="disp" :class="lvClass(msgLatest.level)" style="padding:0 6px">{{ lvLabel(msgLatest.level) }}</span>
|
||
<span v-if="ageText(msgLatest)" class="muted" style="font-size:11.5px">{{ ageText(msgLatest) }}</span>
|
||
<span v-if="msgLatest.stale" class="ab-stale">已过时</span>
|
||
</template>
|
||
<span class="muted" v-else-if="usrc('alerts') && usrc('alerts').ok===false">读取失败:{{ usrc('alerts').error }}</span>
|
||
<span class="muted" v-else>今天还没有消息</span>
|
||
</div>
|
||
<button class="mb-more" @click="msgOpen=!msgOpen">{{ msgOpen ? '收起 ▴' : '展开全部 ▾' }}</button>
|
||
</div>
|
||
|
||
<div class="mb-panel" v-show="msgOpen">
|
||
<div class="mb-tabs">
|
||
<span v-for="k in MSG_KINDS" :key="k.key" class="mb-tab" :class="{on:msgKind===k.key}"
|
||
@click="msgKind=k.key">{{ k.label }}<span class="n">{{ msgCounts[k.key] }}</span></span>
|
||
</div>
|
||
<div class="mb-empty" v-if="!msgShown.length">这一类今天还没有消息。</div>
|
||
<div v-for="(m,i) in msgShown.slice(0,120)" :key="i" class="mb-row" :class="{'alert-stale':m.stale}">
|
||
<span class="t mono">{{ m.time || '—' }}</span>
|
||
<span class="mb-kind" :class="'k-'+m.kind">{{ kindLabel(m.kind) }}</span>
|
||
<div class="body">
|
||
<b v-if="m.ts_code">{{ nm(m.ts_code) }}</b>
|
||
<span v-if="m.ts_code" class="muted mono" style="font-size:11px">{{ m.ts_code }}</span>
|
||
<span v-if="m.dir" :class="dirCls(m.dir)">{{ m.dir }}</span>
|
||
<span :style="m.strong ? 'font-weight:600' : ''" :class="m.strong ? '' : 'muted'">{{ m.cat }}</span>
|
||
<span v-if="m.level" class="disp" :class="lvClass(m.level)" style="padding:0 6px">{{ lvLabel(m.level) }}</span>
|
||
<div class="why" v-if="m.why">{{ m.why }}</div>
|
||
</div>
|
||
<span class="go" @click="msgGo(m)">{{ m.goLabel }} ›</span>
|
||
</div>
|
||
</div>
|
||
|
||
<teleport to="body">
|
||
<div class="rail-scrim" :class="{on:!!railOpen}" @click="closeRail" @touchend="scrimTE"></div>
|
||
<!-- 远程排查小牌: 地址栏加 ?uidbg=1 才出现, 显示浏览器与布局关键量, 只读 -->
|
||
<div v-if="uidbg" class="mono" style="position:fixed;left:8px;bottom:8px;z-index:99990;
|
||
background:#141412;color:#fff;font-size:11px;line-height:1.6;padding:6px 9px;
|
||
border-radius:8px;max-width:86vw;opacity:.88;pointer-events:none">{{ dbgText }}</div>
|
||
</teleport>
|
||
|
||
<!-- ══════════ 三栏 ══════════ -->
|
||
<div class="shell">
|
||
|
||
<!-- ─── 左栏: 信号流 ─── -->
|
||
<div class="side side-l">
|
||
<div class="strip" @click="openRailSide('l')" @touchstart.passive="railTS" @touchend="railTE($event,'l')" title="展开信号流">
|
||
<span class="badge" v-if="sigBriefHeldCount">{{ sigBriefHeldCount }}</span>
|
||
<span class="vtxt">信号流</span><span class="arr">▶</span>
|
||
</div>
|
||
<!-- teleport (2026-09-01): 打开成浮层时把面板挂到 body 下再定位。side 是
|
||
position:sticky, 自成层叠上下文, 固定定位的浮层被困在里面时, 在部分
|
||
Safari 上会与主区表格的浮动列互相穿插 (iPad 实锅)。挂到 body 就没有
|
||
任何祖先能困住它; 常驻停靠时 disabled, 原地不动, 行为与从前逐字节相同 -->
|
||
<teleport to="body" :disabled="railOpen!=='l'">
|
||
<aside class="rail" id="railL" :class="{open:railOpen==='l'}">
|
||
<div class="rail-h">
|
||
<span class="ttl">信号流</span>
|
||
<span class="cnt">今天 {{ sigToday.length }} 条 · 持仓 {{ sigBriefHeldCount }}</span>
|
||
<div class="spacer"></div>
|
||
<button class="iconbtn btn-pull" title="放大成浮层" @click="openRailSide('l',true)">⤢</button>
|
||
<button class="iconbtn btn-fold" title="收成窄条" @click="foldRail('l')">◀</button>
|
||
<button class="iconbtn btn-close" title="关闭(或按 Esc)" @click="closeRail">✕</button>
|
||
</div>
|
||
<div class="rail-body">
|
||
<div class="railtabs">
|
||
<button :class="{on:sigTab==='brief'}" @click="sigTab='brief'">速览</button>
|
||
<button :class="{on:sigTab==='src'}" @click="sigTab='src'">按来源</button>
|
||
</div>
|
||
|
||
<div class="sig-filter" style="margin:0 0 8px">
|
||
<span v-for="L in ALERT_LEVELS" :key="L.key" class="lv-chip"
|
||
:class="levelShown(L.key)?('on lv-'+L.cls):'off'" @click="toggleLevel(L.key)">
|
||
{{ L.label }} <span style="opacity:.7">{{ levelCount(L.key) }}</span></span>
|
||
<span class="help" data-tip="点一下可以把这一档的信号收起来或放出来。级别说的是信号有多强,不是看涨看跌。">?</span>
|
||
</div>
|
||
|
||
<!-- 速览: 持仓票置顶, 其余只收重要和警告 -->
|
||
<template v-if="sigTab==='brief'">
|
||
<div class="muted" v-if="!sigToday.length">持仓的票今天没有信号,全场也没有重要或警告级的信号。</div>
|
||
<template v-if="sigToday.length">
|
||
<div v-for="(g,i) in sigBriefHeld.slice(0,60)" :key="'h'+i" class="sigrow2" :class="{old:g.stale}">
|
||
<span class="t mono">{{ hm(g.time) || '—' }}</span>
|
||
<span class="pin">持仓</span>
|
||
<span class="nm">{{ nm(g.ts_code) }}</span>
|
||
<span class="cat">{{ g.cat }}</span>
|
||
<span v-if="g.direction" :class="dirCls(g.direction)">{{ g.direction }}</span>
|
||
<span class="sp"></span>
|
||
<span class="disp" :class="lvClass(g.level)" style="padding:0 6px">{{ lvLabel(g.level) }}</span>
|
||
</div>
|
||
<div class="muted" v-if="sigBriefOther.length"
|
||
style="margin:10px 0 5px;font-weight:600;font-size:11.5px;border-top:1px dashed var(--hair);padding-top:8px">
|
||
没持仓的票 · 只列重要和警告 · 带「计划」的在今天的选股计划里</div>
|
||
<div v-for="(g,i) in sigBriefOther.slice(0,60)" :key="'o'+i" class="sigrow2" :class="{old:g.stale}">
|
||
<span class="t mono">{{ hm(g.time) || '—' }}</span>
|
||
<span class="pin plan" v-if="g.plan">计划</span>
|
||
<span class="nm">{{ nm(g.ts_code) }}</span>
|
||
<span class="cat">{{ g.cat }}</span>
|
||
<span v-if="g.direction" :class="dirCls(g.direction)">{{ g.direction }}</span>
|
||
<span class="sp"></span>
|
||
<span class="disp" :class="lvClass(g.level)" style="padding:0 6px">{{ lvLabel(g.level) }}</span>
|
||
</div>
|
||
<div class="muted" style="margin-top:9px;font-size:11.5px">
|
||
灰掉的是已经过时的:超过设定的时效还没有新的同类信号盖过它。不隐藏,只是提醒你别再当新消息看。</div>
|
||
</template>
|
||
<div class="muted" v-if="sigOld.length" @click="showOldSig=!showOldSig"
|
||
style="margin:10px 0 5px;font-weight:600;font-size:11.5px;border-top:1px dashed var(--hair);padding-top:8px;cursor:pointer">
|
||
{{ showOldSig ? '▾' : '▸' }} 更早的信号 · {{ sigOld.length }} 条(昨天及以前,默认收起)</div>
|
||
<template v-if="showOldSig">
|
||
<div v-for="(g,i) in sigOld.slice(0,60)" :key="'d'+i" class="sigrow2 old">
|
||
<span class="t mono" style="width:auto">{{ (g.ymd||'').slice(5) }} {{ hm(g.time) || '—' }}</span>
|
||
<span class="pin" v-if="g.held">持仓</span>
|
||
<span class="pin plan" v-if="g.plan && !g.held">计划</span>
|
||
<span class="nm">{{ nm(g.ts_code) }}</span>
|
||
<span class="cat">{{ g.cat }}</span>
|
||
<span v-if="g.direction" :class="dirCls(g.direction)">{{ g.direction }}</span>
|
||
<span class="sp"></span>
|
||
<span class="disp" :class="lvClass(g.level)" style="padding:0 6px">{{ lvLabel(g.level) }}</span>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
|
||
<!-- 按来源: 原来的「完整信号中心」搬到这里, 就地展开不跳转 -->
|
||
<template v-else>
|
||
<div class="muted" style="margin-bottom:8px" v-if="upstream.at">
|
||
最近取数 {{ (upstream.at||'').slice(11,19) }} · 来源是决策系统和盘中择时层,只读参考,不进下单逻辑。</div>
|
||
|
||
<div class="srcgroup">
|
||
<div class="srch" @click="toggleSrc('metrics')">{{ srcOpen.metrics ? '▾' : '▸' }} 盘中资金异动
|
||
<span class="n">· {{ SIG.metrics.length }}</span>
|
||
<span class="help" data-tip="决策系统盘中模块给的:这只票今天的资金进出,和全市场同期比偏离得有多远。偏离越大,强度越高。">?</span>
|
||
<span class="src-err" v-if="usrc('metrics') && usrc('metrics').ok===false"> · 读取失败</span></div>
|
||
<div class="srcb" v-show="srcOpen.metrics">
|
||
<div class="sigrow2" v-for="(r,i) in SIG.metrics.slice(0,20)" :key="i"
|
||
:title="'原始偏离值 = '+(r.z_dd==null?'—':r.z_dd)">
|
||
<span class="nm">{{ nm(r.ts_code) }}</span>
|
||
<span v-if="flowText(r.direction)!=='—'" :class="dirCls(r.direction)"
|
||
style="font-weight:600">{{ flowText(r.direction) }}</span>
|
||
<span class="sp"></span>
|
||
<span class="mono" style="font-weight:600" :class="(r.window_net||0)>=0?'clr-up':'clr-down'">{{ moneyCn(r.window_net) }}</span>
|
||
<span class="intens"><span class="ibar"><i v-for="(on,bi) in zBars(r.z_dd)" :key="bi" :class="on?'on':''"></i></span></span>
|
||
<span class="mono" style="font-weight:700;font-size:11px"
|
||
:class="dirCls(r.direction)">{{ zBand(r.z_dd).label }}</span>
|
||
</div>
|
||
<div class="sig-none" v-if="!SIG.metrics.length">今天没有资金异动</div>
|
||
<div class="muted" style="font-size:11px;margin-top:6px">
|
||
强度分四档:一般、显著、强、极强。鼠标停在行上能看到原始数值。</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="srcgroup">
|
||
<div class="srch" @click="toggleSrc('mr')">{{ srcOpen.mr ? '▾' : '▸' }} 盘中实况评分 · 关注与回避
|
||
<span class="n">· {{ (SIG.mr.watch||[]).length + (SIG.mr.avoid||[]).length }}</span>
|
||
<span class="help" data-tip="决策系统盘中模块给每只票打的实时分。分高的进关注名单,分低的进回避名单。">?</span>
|
||
<span class="src-err" v-if="usrc('mr') && usrc('mr').ok===false"> · 读取失败</span></div>
|
||
<div class="srcb" v-show="srcOpen.mr">
|
||
<div class="muted" style="margin:4px 0 3px">关注(分高)</div>
|
||
<div v-for="(r,i) in (SIG.mr.watch||[]).slice(0,12)" :key="'w'+i" class="sigrow2">
|
||
<span class="nm">{{ nm(r.ts_code) }}</span><span class="sp"></span>
|
||
<span class="mono" style="color:var(--accent)">{{ r.score }}</span></div>
|
||
<div class="sig-none" v-if="!(SIG.mr.watch||[]).length">暂无</div>
|
||
<div class="muted" style="margin:8px 0 3px">回避(分低)</div>
|
||
<div v-for="(r,i) in (SIG.mr.avoid||[]).slice(0,12)" :key="'a'+i" class="sigrow2">
|
||
<span class="nm">{{ nm(r.ts_code) }}</span><span class="sp"></span>
|
||
<span class="mono" style="color:var(--warn)">{{ r.score }}</span></div>
|
||
<div class="sig-none" v-if="!(SIG.mr.avoid||[]).length">暂无</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="srcgroup">
|
||
<div class="srch" @click="toggleSrc('decision')">{{ srcOpen.decision ? '▾' : '▸' }} 决策系统 · 买入与卖出广播
|
||
<span class="n">· {{ SIG.decision.length }}</span>
|
||
<span class="src-err" v-if="usrc('intraday') && usrc('intraday').ok===false"> · 读取失败</span></div>
|
||
<div class="srcb" v-show="srcOpen.decision">
|
||
<div class="sigrow2" v-for="(r,i) in SIG.decision.slice(0,16)" :key="i">
|
||
<span class="nm">{{ nm(r.ts_code) }}</span><span class="sp"></span>
|
||
<span class="disp" :class="r.action==='SELL'?'sig-down':'sig-up'">{{ r.action==='SELL'?'建议卖出':'建议买入' }}</span>
|
||
<span class="mono muted" style="font-size:11px">{{ hm(r.time) }}</span></div>
|
||
<div class="sig-none" v-if="!SIG.decision.length">今天没有买卖广播</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="srcgroup">
|
||
<div class="srch" @click="toggleSrc('qrs')">{{ srcOpen.qrs ? '▾' : '▸' }} 日线形态
|
||
<span class="n">· {{ SIG.qrs.length }}</span>
|
||
<span class="help" data-tip="决策系统的日线形态指标(内部代号 QRS),看多方和空方的力量是否对称。看涨表示多方占优。">?</span></div>
|
||
<div class="srcb" v-show="srcOpen.qrs">
|
||
<div class="sigrow2" v-for="(r,i) in SIG.qrs.slice(0,16)" :key="i" :class="{old:r.stale}">
|
||
<span class="nm">{{ nm(r.ts_code) }}</span>
|
||
<span v-if="r.direction && r.direction!=='—'" :class="dirCls(r.direction)"
|
||
style="font-weight:600">{{ r.direction }}</span>
|
||
<span class="sp"></span>
|
||
<span class="disp" :class="lvClass(r.level)" style="padding:0 6px">{{ lvLabel(r.level) }}</span>
|
||
<span class="mono muted" style="font-size:11px">{{ hm(r.time) }}</span></div>
|
||
<div class="sig-none" v-if="!SIG.qrs.length">今天没有日线形态告警</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="srcgroup">
|
||
<div class="srch" @click="toggleSrc('capital')">{{ srcOpen.capital ? '▾' : '▸' }} 资金分布
|
||
<span class="n">· {{ SIG.capital.length }}</span></div>
|
||
<div class="srcb" v-show="srcOpen.capital">
|
||
<div class="sigrow2" v-for="(r,i) in SIG.capital.slice(0,16)" :key="i" :class="{old:r.stale}">
|
||
<span class="nm">{{ nm(r.ts_code) }}</span>
|
||
<span v-if="r.direction && r.direction!=='—'" :class="dirCls(r.direction)"
|
||
style="font-weight:600">{{ r.direction }}</span>
|
||
<span class="sp"></span>
|
||
<span class="disp" :class="lvClass(r.level)" style="padding:0 6px">{{ lvLabel(r.level) }}</span>
|
||
<span class="mono muted" style="font-size:11px">{{ hm(r.time) }}</span></div>
|
||
<div class="sig-none" v-if="!SIG.capital.length">今天没有资金分布告警</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="srcgroup">
|
||
<div class="srch" @click="toggleSrc('price')">{{ srcOpen.price ? '▾' : '▸' }} 股价异动
|
||
<span class="n">· {{ SIG.price_notice.length }}</span></div>
|
||
<div class="srcb" v-show="srcOpen.price">
|
||
<div class="sigrow2" v-for="(r,i) in SIG.price_notice.slice(0,16)" :key="i" :class="{old:r.stale}">
|
||
<span class="nm">{{ nm(r.ts_code) }}</span>
|
||
<span v-if="r.direction && r.direction!=='—'" :class="dirCls(r.direction)"
|
||
style="font-weight:600">{{ r.direction }}</span>
|
||
<span class="sp"></span>
|
||
<span class="disp" :class="lvClass(r.level)" style="padding:0 6px">{{ lvLabel(r.level) }}</span>
|
||
<span class="mono muted" style="font-size:11px">{{ hm(r.time) }}</span></div>
|
||
<div class="sig-none" v-if="!SIG.price_notice.length">今天没有股价异动</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="srcgroup">
|
||
<div class="srch" @click="toggleSrc('rare')">{{ srcOpen.rare ? '▾' : '▸' }} 少见信号
|
||
<span class="n">· {{ SIG.sell.length + SIG.multi.length + SIG.timing.length + SIG.money_flow.length }}</span>
|
||
<span class="help" data-tip="这几类平时不出现,一出现就要紧:决策系统建议卖出、多个来源同时报警、买入时机、资金流强度。">?</span></div>
|
||
<div class="srcb" v-show="srcOpen.rare">
|
||
<div class="muted" style="margin:4px 0 3px" v-if="SIG.sell.length">决策系统建议卖出</div>
|
||
<div class="sigrow2" v-for="(r,i) in SIG.sell.slice(0,12)" :key="'s'+i"
|
||
style="flex-wrap:wrap;cursor:pointer" @click="toggleSell(r.ts_code+'_'+i)"
|
||
:class="{old: r.today===false}"
|
||
:title="r.reason ? '点一下看理由' : '这条没有理由'">
|
||
<span class="muted mono" style="font-size:10px;width:9px;flex:none"
|
||
:style="{visibility: r.reason ? 'visible' : 'hidden'}">{{ sellOpen[r.ts_code+'_'+i] ? '▾' : '▸' }}</span>
|
||
<span class="nm">{{ nm(r.ts_code) }}</span>
|
||
<span class="disp sig-down">{{ r.dominant_signal==='take_profit'?'止盈':'建议卖出' }}</span>
|
||
<span class="muted" style="font-size:11.5px" v-if="confPct(r.confidence)!=null">把握 {{ confPct(r.confidence) }}</span>
|
||
<span class="sp"></span>
|
||
<span class="mono muted" style="font-size:11px">{{ r.today===false ? ((r.ymd||'').slice(5)+' ') : '' }}{{ hm(r.time) }}</span>
|
||
<div style="flex-basis:100%;font-size:12px;line-height:1.45" class="muted"
|
||
v-if="r.reason && sellOpen[r.ts_code+'_'+i]" @click.stop>{{ r.reason }}</div>
|
||
</div>
|
||
<div class="muted" style="margin:8px 0 3px" v-if="SIG.multi.length">多个来源同时报警</div>
|
||
<div class="sigrow2" v-for="(r,i) in SIG.multi.slice(0,10)" :key="'m'+i" :class="{old:r.stale}">
|
||
<span class="nm">{{ nm(r.ts_code) }}</span>
|
||
<span v-if="r.direction && r.direction!=='—'" :class="dirCls(r.direction)"
|
||
style="font-weight:600">{{ r.direction }}</span>
|
||
<span class="sp"></span>
|
||
<span class="disp" :class="lvClass(r.level)" style="padding:0 6px">{{ lvLabel(r.level) }}</span>
|
||
<span class="mono muted" style="font-size:11px">{{ hm(r.time) }}</span></div>
|
||
<div class="muted" style="margin:8px 0 3px" v-if="SIG.timing.length">买入时机</div>
|
||
<div class="sigrow2" v-for="(r,i) in SIG.timing.slice(0,10)" :key="'t'+i">
|
||
<span class="nm">{{ nm(r.ts_code) }}</span><span class="sp"></span>
|
||
<span class="muted" style="font-size:11.5px">参考价 <span class="mono" style="color:var(--ink)">{{ r.price==null?'—':r.price }}</span></span>
|
||
<span class="muted" style="font-size:11.5px">把握 <span class="mono" style="color:var(--ink)">{{ confPct(r.confidence)==null?'—':confPct(r.confidence) }}</span></span>
|
||
<span class="mono muted" style="font-size:11px">{{ hm(r.time) }}</span></div>
|
||
<div class="muted" style="margin:8px 0 3px" v-if="SIG.money_flow.length">资金流强度</div>
|
||
<div class="sigrow2" v-for="(r,i) in SIG.money_flow.slice(0,10)" :key="'f'+i" :class="{old:r.stale}">
|
||
<span class="nm">{{ nm(r.ts_code) }}</span>
|
||
<span v-if="r.direction && r.direction!=='—'" :class="dirCls(r.direction)"
|
||
style="font-weight:600">{{ r.direction }}</span>
|
||
<span class="sp"></span>
|
||
<span class="disp" :class="lvClass(r.level)" style="padding:0 6px">{{ lvLabel(r.level) }}</span>
|
||
<span class="mono muted" style="font-size:11px">{{ hm(r.time) }}</span></div>
|
||
<div class="muted" style="margin-top:8px;font-size:11.5px" v-if="eventsQuiet.length">
|
||
今天还没触发:{{ eventsQuiet.map(e=>e.label).join('、') }}</div>
|
||
<div class="muted" style="margin-top:6px;font-size:11.5px" v-if="SIG.other.length">
|
||
还有 {{ SIG.other.length }} 条来自没登记过的来源,需要和决策系统核对。</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</aside>
|
||
</teleport>
|
||
</div>
|
||
|
||
<!-- ─── 中区: 一览 / 需要注意 / 我的持仓 / 今日在办 ─── -->
|
||
<main class="tmain">
|
||
|
||
<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">仓位<span class="muted" style="margin-left:4px">上限 {{ pct(ov.portfolio_cap) }}</span></div>
|
||
<div class="v">{{ pct(ov.portfolio_pct) }}</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:12px"> {{ ov.float_pnl_pct==null?'':pct(ov.float_pnl_pct) }}</span></div></div>
|
||
<div class="m"><div class="k">持仓只数<span class="muted" style="margin-left:4px">上限 {{ ov.max_names }}</span></div>
|
||
<div class="v">{{ ov.names_count }}</div></div>
|
||
<div class="m" style="cursor:pointer" @click="openRailSide('r')"><div class="k">等我拍板</div>
|
||
<div class="v" :class="proposals.length?'up':''">{{ proposals.length }}<span class="muted" style="font-size:12px"> 条</span></div></div>
|
||
<div class="m"><div class="k">自主档位<span class="help" data-tip="系统能自己做主到什么程度。全自动=各项检查通过就直接下单;只提议=先在「等我拍板」问过你;暂停=系统不自己动。">?</span></div>
|
||
<div class="v" style="font-size:14px;padding-top:3px">{{ tx('autonomy', ov.autonomy) }}</div></div>
|
||
</div>
|
||
|
||
<div class="panel" v-if="attention.length" style="border-left:3px solid var(--warn)">
|
||
<h3 style="color:var(--warn);margin-bottom:6px">需要注意</h3>
|
||
<div v-for="(a,i) in attention" :key="i" class="attn">· {{ a }}</div>
|
||
</div>
|
||
|
||
<!-- 我的持仓: 操作列钉在右边, 表格横向再挤也不会被切掉 -->
|
||
<div class="panel tblk postbl" id="t-pos">
|
||
<h3>我的持仓 <span class="muted" style="font-weight:400">· {{ heldPositions.length }} 只</span></h3>
|
||
<div class="sub2">安全垫<span class="help" data-tip="现在的浮盈够不够厚,够不够扛一次回撤。厚垫=赚得多,可以考虑加仓;薄垫=刚开始赚;浮亏无垫=还在亏,不加仓。">?</span>
|
||
看的是浮盈厚不厚:厚垫可以考虑加仓,薄垫先放着,浮亏无垫不加仓。
|
||
成本是各批买入价的加权平均,占比是占总操作规模的百分比。
|
||
「我设的价」这一列是你自己下命令定的止损价与目标价,一直在,你改了才变;
|
||
系统每天早上自己算的参考位是另一回事,在管理视图的持仓总览里看。</div>
|
||
<el-table :data="heldPositions" size="small" border max-height="480" @expand-change="onPosExpand">
|
||
<el-table-column type="expand" width="40">
|
||
<template #default="s">
|
||
<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="!ledgerToday.filter(x=>x.ts_code===s.row.ts_code).length" style="padding:4px 0">今天还没有记录</div>
|
||
<div v-for="(l,i) in ledgerToday.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>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="股票" width="120">
|
||
<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="96">
|
||
<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>
|
||
<!-- 我设的止损价与目标价: 读命令表带出来的 user_stop_price / user_target_price,
|
||
不是持仓行上的 stop_ref。stop_ref 每天早上会被系统重算的参考位整列盖掉,
|
||
拿它显示会让人以为自己设的价没生效。 -->
|
||
<el-table-column label="我设的价" width="118">
|
||
<template #default="s">
|
||
<span class="muted">止损 </span><span v-if="s.row.user_stop_price!=null">{{ s.row.user_stop_price }}</span><span
|
||
v-else class="muted">未设</span>
|
||
<br>
|
||
<span class="muted">目标 </span><span v-if="s.row.user_target_price!=null">{{ s.row.user_target_price }}</span><span
|
||
v-else class="muted">未设</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="浮动盈亏" width="76">
|
||
<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="100">
|
||
<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="100">
|
||
<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) }}</div>
|
||
<div class="muted" v-if="s.row.neg_cushion_days>0">连亏 {{ s.row.neg_cushion_days }} 日</div></template>
|
||
</el-table-column>
|
||
<el-table-column label="今天的信号" width="108">
|
||
<template #default="s">
|
||
<template v-if="heldSigOf(s.row.ts_code).length">
|
||
<span style="cursor:default"
|
||
:title="heldSigOf(s.row.ts_code).slice(0,3).map(g=>(g.time||'')+' '+g.cat+' '+(g.direction||'')).join('\n')">
|
||
<span :class="dirCls(heldSigOf(s.row.ts_code)[0].direction)">
|
||
{{ heldSigOf(s.row.ts_code)[0].cat }} {{ heldSigOf(s.row.ts_code)[0].direction || '' }}</span>
|
||
<br><span class="muted mono" style="font-size:11px">{{ (heldSigOf(s.row.ts_code)[0].time||'').slice(0,5) }}<template
|
||
v-if="heldSigOf(s.row.ts_code).length>1"> +{{ heldSigOf(s.row.ts_code).length-1 }}</template></span>
|
||
</span>
|
||
</template>
|
||
<span v-else class="muted">今天没有</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态 · 下一步" min-width="130">
|
||
<template #default="s">
|
||
<span class="chip idle"><span class="dot"></span>{{ tx('posStatus', s.row.status) }}</span>
|
||
<span v-if="s.row.frozen_reason && s.row.frozen_reason!=='NONE'" class="chip warn" style="margin-left:5px"><span class="dot"></span>{{ tx('frozen', s.row.frozen_reason) }}</span>
|
||
<div class="muted" style="margin-top:3px">{{ nextStep(s.row) }}</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="172" fixed="right">
|
||
<template #default="s">
|
||
<div class="act-cell">
|
||
<el-button size="small" @click="actReduceStock(s.row)">减仓</el-button>
|
||
<el-button size="small" type="danger" @click="actExitStock(s.row)">清仓</el-button>
|
||
<el-dropdown @command="c=>rowMore(c,s.row)">
|
||
<el-button size="small" title="更多操作">⋯</el-button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item command="stop">设止损价</el-dropdown-item>
|
||
<el-dropdown-item command="target">设目标价</el-dropdown-item>
|
||
<el-dropdown-item command="freeze">{{ (s.row.frozen_reason && s.row.frozen_reason!=='NONE') ? '恢复买入' : '改成只卖不买' }}</el-dropdown-item>
|
||
<el-dropdown-item command="black">加入黑名单</el-dropdown-item>
|
||
<el-dropdown-item command="strategy" divided>挂交易方案(做T / 网格 / 跟踪止盈)</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
|
||
<!-- 今日在办 -->
|
||
<div class="panel tblk" id="t-ins">
|
||
<h3>今日在办</h3>
|
||
<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="280" 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="130" 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="进展" min-width="130"><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="280" 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="130" 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="进展" min-width="130"><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="280" 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>
|
||
<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="380"><template #default="s"><div class="why" style="margin:0">{{ s.row.why || '—' }}</div></template></el-table-column>
|
||
</el-table>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
|
||
<!-- 今天发生了什么: 账本 + 操作记录, 默认收起 -->
|
||
<div class="panel tblk">
|
||
<h3 style="cursor:pointer" @click="showLedger=!showLedger">今天发生了什么
|
||
<span class="muted" style="font-weight:400">· {{ ledgerToday.length }} 条</span>
|
||
<span class="muted" style="font-size:12px;font-weight:normal">{{ showLedger?'▲ 收起':'▼ 展开' }}</span></h3>
|
||
<template v-if="showLedger">
|
||
<div class="muted" style="margin-bottom:6px">系统的每一次判断都留一行。</div>
|
||
<div class="muted" v-if="!ledgerToday.length">今天还没有记录。</div>
|
||
<div v-for="(l,i) in ledgerToday" :key="i" class="feed-row">
|
||
<span class="muted mono">{{ (l.decided_at||'').slice(11,16) }}</span>
|
||
<b> {{ nm(l.ts_code) }}</b>:{{ tx('arbiter', l.arbiter) }}{{ tx('verdict', l.verdict) }} {{ tx('action', l.action) }}<span v-if="l.reason" class="muted"> —— {{ l.reason }}</span>
|
||
</div>
|
||
</template>
|
||
<h3 style="margin-top:12px;cursor:pointer" @click="showOpLog=!showOpLog">你做过的操作
|
||
<span class="muted" style="font-weight:400">· {{ opLog.length }} 条</span>
|
||
<span class="muted" style="font-size:12px;font-weight:normal">{{ showOpLog?'▲ 收起':'▼ 展开' }}</span></h3>
|
||
<template v-if="showOpLog">
|
||
<div class="muted" style="margin-bottom:6px">你每做一次操作留一行;被规则挡下的也记下原因。</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==='OK'?'已执行':(o.result==='BLOCKED'?'被挡下':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>
|
||
</div>
|
||
|
||
</main>
|
||
|
||
<!-- ─── 右栏: 待办与操作 ─── -->
|
||
<div class="side side-r">
|
||
<div class="strip" @click="openRailSide('r')" @touchstart.passive="railTS" @touchend="railTE($event,'r')" title="展开待办与操作">
|
||
<span class="arr">◀</span><span class="vtxt">待办与操作</span>
|
||
<span class="badge" v-if="proposals.length">{{ proposals.length }}</span>
|
||
</div>
|
||
<teleport to="body" :disabled="railOpen!=='r'">
|
||
<aside class="rail" id="railR" :class="{open:railOpen==='r'}">
|
||
<div class="rail-h">
|
||
<button class="iconbtn btn-close" title="关闭(或按 Esc)" @click="closeRail">✕</button>
|
||
<button class="iconbtn btn-fold" title="收成窄条" @click="foldRail('r')">▶</button>
|
||
<button class="iconbtn btn-pull" title="放大成浮层" @click="openRailSide('r',true)">⤢</button>
|
||
<div class="spacer"></div>
|
||
<span class="ttl">待办与操作</span>
|
||
</div>
|
||
<div class="rail-body">
|
||
|
||
<!-- 等我拍板 -->
|
||
<div class="sect" :class="{closed:sectClosed.prop}" id="t-prop">
|
||
<div class="secth" @click="toggleSect('prop')">等我拍板
|
||
<span class="n">· {{ proposals.length }} 条</span><span class="car">{{ sectClosed.prop?'▸':'▾' }}</span></div>
|
||
<div class="sectb">
|
||
<div class="muted" v-if="!proposals.length">现在没有要你确认的事。</div>
|
||
<div v-for="p in proposals" :key="p.proposal_id" class="mini">
|
||
<div class="top"><b>{{ nm(p.ts_code) }}</b>
|
||
<span class="muted mono" style="font-size:11px">{{ p.ts_code }}</span>
|
||
<el-tag size="small" effect="dark" type="primary">{{ tx('action', p.action) }}</el-tag>
|
||
<span class="spacer"></span><span class="muted" style="font-size:11px">{{ p.expire_at }} 前有效</span></div>
|
||
<div class="st" v-if="p.judge_reason"><span class="muted">决策系统:</span>{{ p.judge_reason }}<span class="muted" v-if="(p.hard_numbers||{}).judge_conf!=null">(置信度 {{ (p.hard_numbers||{}).judge_conf }})</span></div>
|
||
<div class="st" v-if="(p.hard_numbers||{}).verdict"><span class="muted">选股系统:</span>判为「{{ (p.hard_numbers||{}).verdict }}」<span v-if="propReasons(p).length">,{{ propReasons(p).join(';') }}</span></div>
|
||
<div class="st muted" v-if="propBasis(p)">{{ propBasis(p) }}</div>
|
||
<div class="st" v-if="propLogic(p)"><span class="muted">研报说:</span>{{ propLogic(p) }}<span class="muted" v-if="propLogicMore(p)">(另有 {{ propLogicMore(p) }} 条)</span></div>
|
||
<div class="st muted">{{ propWhy(p) }}</div>
|
||
<div class="st">约 {{ p.qty }} 股 · 参考价 {{ (p.hard_numbers||{}).price==null ? '—' : (p.hard_numbers||{}).price }}
|
||
· 约需 {{ money(((p.hard_numbers||{}).price||0)*(p.qty||0)) }}</div>
|
||
<div style="margin-top:6px">
|
||
<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>
|
||
</div>
|
||
|
||
<!-- 我的策略 -->
|
||
<div class="sect" :class="{closed:sectClosed.strat}" id="t-strat">
|
||
<div class="secth" @click="toggleSect('strat')">我的策略
|
||
<span class="n">· {{ strategies.length }} 个</span><span class="car">{{ sectClosed.strat?'▸':'▾' }}</span></div>
|
||
<div class="sectb">
|
||
<div class="muted" style="margin-bottom:6px">
|
||
{{ stratEnabled ? '策略功能已开启' : '策略功能没开(参数 PMS_STRATEGY_ENABLED)' }}<template v-if="autoInfo"> ·
|
||
{{ autoInfo.enabled ? '自动挂方案已开启' : '自动挂方案没开' }}</template>
|
||
<template v-if="autoInfo && autoInfo.ran_today && autoInfo.last_scan"> · 今天
|
||
{{ (autoInfo.last_scan.at||'').slice(11,16) }} 跑过一轮</template>
|
||
<template v-else-if="autoInfo"> · 今天还没跑(交易日 09:40 自动跑)</template>
|
||
</div>
|
||
<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="row" style="margin-bottom:8px">
|
||
<el-checkbox v-model="showAllStrategies" @change="loadStrategies" size="small" style="font-weight:normal">显示已撤下和已完成</el-checkbox>
|
||
<el-button v-if="isAdmin" size="small" :loading="autoScanBusy" @click="autoScanPreview">试算一遍</el-button>
|
||
<span class="help" data-tip="按现在的行情和信号算一遍,看这一轮自动挂方案会做什么。只算给你看,不执行、不留任何记录。">?</span>
|
||
</div>
|
||
|
||
<div v-if="autoScanRes" class="mini">
|
||
<div class="muted" style="margin-bottom:4px">试算结果(没有执行)· 检查 <b>{{ autoScanRes.checked }}</b> 只持仓</div>
|
||
<div v-for="(x,i) in (autoScanRes.stages||[])" :key="'stg'+i" style="margin:2px 0">
|
||
<el-tag size="small" effect="dark" :type="stageTagType(x.stage)">{{ x.stage }}</el-tag>
|
||
<b> {{ nm(x.ts_code) }}</b>
|
||
<span class="muted"> {{ x.why }}
|
||
<template v-if="x.heat!=null"> · 热度 {{ Number(x.heat).toFixed(2) }}</template>
|
||
<template v-if="x.cushion_pct!=null"> · 浮盈 {{ pct(x.cushion_pct) }}</template>
|
||
<template v-if="x.accum_state"> · 形态判断「{{ x.accum_state }}」</template>
|
||
</span>
|
||
</div>
|
||
<div v-for="(x,i) in (autoScanRes.attached||[])" :key="'aa'+i">
|
||
<el-tag size="small" type="success" effect="dark">想挂上</el-tag>
|
||
<b> {{ nm(x.ts_code) }}</b> {{ tx('stype', x.type) }} —— {{ x.why }}</div>
|
||
<div v-for="(x,i) in (autoScanRes.handoffs||[])" :key="'ah'+i">
|
||
<el-tag size="small" type="success" effect="dark">想换方案</el-tag>
|
||
<b> {{ nm(x.ts_code) }}</b> 网格换成跟踪止盈 —— {{ x.why }}</div>
|
||
<div v-for="(x,i) in (autoScanRes.paused||[])" :key="'ap'+i">
|
||
<el-tag size="small" type="warning" effect="dark">想停买入</el-tag>
|
||
<b> {{ nm(x.ts_code) }}</b> —— {{ x.why }}</div>
|
||
<div v-for="(x,i) in (autoScanRes.resumed||[])" :key="'ar'+i">
|
||
<el-tag size="small" type="success" effect="dark">想恢复买入</el-tag>
|
||
<b> {{ nm(x.ts_code) }}</b></div>
|
||
<div v-for="(x,i) in (autoScanRes.blocked||[])" :key="'ab'+i" style="color:var(--warn)">
|
||
<el-tag size="small" type="warning" effect="dark">被挡下</el-tag>
|
||
<b> {{ nm(x.ts_code) }}</b> —— {{ x.why }}</div>
|
||
<div v-for="(x,i) in (autoScanRes.skipped||[])" :key="'as'+i" class="muted">
|
||
· <template v-if="x.ts_code">{{ nm(x.ts_code) }}:</template>{{ x.why }}</div>
|
||
<div v-for="(e,i) in (autoScanRes.errors||[])" :key="'ae'+i" style="color:var(--stop)">✗ {{ e }}</div>
|
||
<div v-if="(autoScanRes.unknown_states||[]).length" style="color:var(--warn)">
|
||
{{ autoScanRes.unknown_states.length }} 只票的形态判断超出约定的词表(已按没有标志处理),
|
||
例如:{{ autoScanRes.unknown_states.slice(0,3).map(u=>u.state).join('、') }}。需要和决策系统核对。</div>
|
||
<div class="muted" v-if="!(autoScanRes.attached||[]).length && !(autoScanRes.handoffs||[]).length
|
||
&& !(autoScanRes.paused||[]).length && !(autoScanRes.resumed||[]).length
|
||
&& !(autoScanRes.blocked||[]).length">
|
||
这一轮不会做任何事:没有票符合触发条件。</div>
|
||
</div>
|
||
|
||
<div class="muted" v-if="!strategies.length">还没有挂过交易方案。在「我的持仓」行里点「⋯ → 挂交易方案」创建。</div>
|
||
<div v-for="s in strategiesShown" :key="s.strategy_id" class="mini">
|
||
<div class="top"><b>{{ nm(s.ts_code) }}</b>
|
||
<el-tag size="small" effect="dark">{{ tx('stype', s.type) }}</el-tag>
|
||
<span class="muted" style="font-size:11px">{{ s.autonomy==='auto'?'自动执行':'待我拍板' }}</span>
|
||
<span class="spacer"></span>
|
||
<el-tag size="small" :type="s.status==='ACTIVE'?'primary':'info'">{{ tx('stratStatus', s.status) }}</el-tag></div>
|
||
<div class="st">{{ stratStateText(s) }}</div>
|
||
<div class="st" style="color:var(--warn)" v-if="s.buy_paused">
|
||
这只票现在只卖不买。决策系统提示{{ s.buy_paused.reason || '资金异动' }},已经停掉买入;
|
||
卖出、以及把当天做T买的那部分卖回去,都不受影响。</div>
|
||
<div style="margin-top:6px">
|
||
<el-button v-if="s.status==='ACTIVE'" size="small" @click="setStrategyStatus(s.strategy_id,'PAUSED')">暂停</el-button>
|
||
<el-button v-else-if="s.status==='PAUSED'" size="small" type="success" @click="setStrategyStatus(s.strategy_id,'ACTIVE')">恢复</el-button>
|
||
<el-button v-if="s.buy_paused" size="small" type="warning" @click="resumeBuy(s)">恢复买入</el-button>
|
||
<el-button v-if="!stratArchivable(s)" size="small" type="danger" @click="setStrategyStatus(s.strategy_id,'CANCELLED')">撤下</el-button>
|
||
<el-button v-if="stratArchivable(s) && !s.archived_at" size="small" @click="archiveRow('strategies', s.strategy_id, true)">移除</el-button>
|
||
<el-button v-if="s.archived_at" size="small" type="info" @click="archiveRow('strategies', s.strategy_id, false)">恢复显示</el-button>
|
||
</div>
|
||
</div>
|
||
<div class="muted" v-if="strategies.length && !strategiesShown.length">没有正在跑的策略。勾上「显示已撤下和已完成」看全部。</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 系统在盯的候选 -->
|
||
<div class="sect" :class="{closed:sectClosed.pool}">
|
||
<div class="secth" @click="toggleSect('pool')">系统在盯的候选
|
||
<span class="n">· {{ watchCandidates.length }} 只</span><span class="car">{{ sectClosed.pool?'▸':'▾' }}</span></div>
|
||
<div class="sectb">
|
||
<div class="muted" style="margin-bottom:5px">今天选股计划里、系统还盯着、可能会建仓的票。计划日 {{ planStatus.date || '—' }}。
|
||
已经下单的、等你拍板的、被否决的,分别在「今日在办」和「等我拍板」。</div>
|
||
<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>
|
||
<div class="muted" v-if="planStatus.ok===false">上游的选股计划现在读不到。</div>
|
||
<div class="muted" v-else-if="!watchCandidates.length">今天没有还在盯着待建仓的候选,都已经处理过了。</div>
|
||
<div v-else v-for="r in watchCandidates" :key="r.ts_code" class="sigrow2">
|
||
<span class="muted mono t">第{{ r.rank }}</span>
|
||
<span class="nm">{{ r.name || nm(r.ts_code) }}</span>
|
||
<span class="cat" v-if="r.theme">{{ r.theme }}</span>
|
||
<span class="sp"></span>
|
||
<span class="disp" :class="dispOf(r.ts_code).cls" style="padding:0 6px">{{ dispOf(r.ts_code).label }}</span>
|
||
</div>
|
||
<div class="muted" style="margin-top:6px;font-size:11.5px">
|
||
受益层级<span class="help" data-tip="这只票离主题多近:直接受益还是间接受益。上游的选股计划给的。">?</span>
|
||
和完整明细,在「上游计划」按钮里。</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 整个组合的操作 -->
|
||
<div class="sect" :class="{closed:sectClosed.ops}">
|
||
<div class="secth" @click="toggleSect('ops')">整个组合的操作<span class="car">{{ sectClosed.ops?'▸':'▾' }}</span></div>
|
||
<div class="sectb">
|
||
<div class="muted" style="margin-bottom:6px">数值都按当前实际持仓算好并预览影响金额,不用盲填;危险动作要确认两次。</div>
|
||
<div class="oprow2">
|
||
<span class="b"><el-button size="small" :type="ov.buy_halt?'success':'warning'" @click="ov.buy_halt?pResumeBuy():pHaltBuy()">{{ ov.buy_halt?'恢复买入':'暂停买入' }}</el-button></span>
|
||
<span class="d">停掉所有新的买入,并撤掉已经挂出去、还没成交的买单。卖出和止损照常。现在:{{ ov.buy_halt?'已暂停买入':'买入正常' }}。</span>
|
||
</div>
|
||
<div class="oprow2">
|
||
<span class="b"><el-button size="small" :type="ov.exec_halt?'success':''" @click="ov.exec_halt?pResumeAll():pHaltAll()">{{ ov.exec_halt?'结束休假':'休假模式' }}</el-button></span>
|
||
<span class="d">系统的自动动作全部停下,只保留每天的对账和日报。现在:{{ ov.exec_halt?'休假中':'正常运行' }}。</span>
|
||
</div>
|
||
<div class="oprow2">
|
||
<span class="b"><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) }}。
|
||
<br>目标 <el-input v-model="tgtPos" size="small" style="width:88px"><template #append>%</template></el-input>
|
||
<span v-if="posMovePreview" class="muted"> {{ posMovePreview }}</span>
|
||
</span>
|
||
</div>
|
||
<div class="oprow2">
|
||
<span class="b"><el-button size="small" :disabled="!secSel" @click="doSectorExit()">清仓某行业</el-button></span>
|
||
<span class="d">
|
||
把某个行业的持仓全部卖掉,从下拉里选,不用手打。
|
||
<br><el-select v-model="secSel" size="small" style="width:100%" 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>
|
||
</div>
|
||
<div class="oprow2">
|
||
<span class="b"><el-button size="small" type="danger" @click="pLiquidate()">一键清仓</el-button></span>
|
||
<span class="d">紧急离场用:全部持仓({{ heldPositions.length }} 只,市值 {{ money(ov.portfolio_mv) }})按最快速度卖出,不再挑时间点。要确认两次。</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 设置 -->
|
||
<div class="sect" :class="{closed:sectClosed.set}">
|
||
<div class="secth" @click="toggleSect('set')">设置<span class="car">{{ sectClosed.set?'▸':'▾' }}</span></div>
|
||
<div class="sectb">
|
||
<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:118px"><template #append>元</template></el-input></span>
|
||
<span>总仓上限 <el-input v-model="pmap['PMS_PORTFOLIO_CAP'].value" size="small" style="width:78px" placeholder="0~1"></el-input></span>
|
||
<span>单股上限 <el-input v-model="pmap['PMS_STOCK_CAP'].value" size="small" style="width:78px" placeholder="0~1"></el-input></span>
|
||
<span>最多持仓 <el-input v-model="pmap['PMS_MAX_NAMES'].value" size="small" style="width:76px"><template #append>只</template></el-input></span>
|
||
<span>预留现金 <el-input v-model="pmap['PMS_CASH_RESERVE'].value" size="small" style="width:78px" 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:78px" placeholder="0~1"></el-input></span>
|
||
<span>亏到多少开始评估 <el-input v-model="pmap['PMS_DCA_TRIGGERS'].value" size="small" style="width:118px" placeholder="-0.08,-0.15"></el-input></span>
|
||
<span>更深那档要确认 <el-input v-model="pmap['PMS_DCA_DEEP_CONFIRM'].value" size="small" style="width:78px"></el-input></span>
|
||
<span>回落补足的止损 <el-input v-model="pmap['PMS_FILL_MAX_LOSS'].value" size="small" style="width:78px"></el-input></span>
|
||
</template>
|
||
</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:180px">
|
||
<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">
|
||
<el-button size="small" type="primary" :disabled="!dirtyCount" @click="saveParams">保存修改({{ dirtyCount }})</el-button>
|
||
<el-button size="small" @click="loadParams">放弃</el-button>
|
||
</div>
|
||
<div class="muted" style="margin-top:6px;font-size:11.5px">
|
||
不想补仓就把「最多补到首批的」设成 0;调低总仓上限可能会让系统提议降仓。</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</aside>
|
||
</teleport>
|
||
</div>
|
||
</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> 股(明天才能卖的不算,现在能卖 <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="每轮拿多少">
|
||
<el-input v-model="stratDlg.p.t_ratio" style="width:120px"><template #append>×持仓</template></el-input>
|
||
<span class="muted" style="margin-left:8px">不超过三分之一;每轮约 <b>{{ stratQty }}</b> 股</span>
|
||
</el-form-item>
|
||
<el-form-item label="怎么做">
|
||
<span class="muted">两种做法。一是先买后卖:股价回落到支撑位附近买入,涨到目标价差再卖掉。
|
||
二是先卖后买:股价冲到压力位、或者涨不动了,先卖掉一部分,跌下来再买回来。
|
||
每天最多做 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>
|
||
<span class="muted" style="margin-left:8px">要落在单只股票的持仓上限之内,合规检查会再拦一道</span>
|
||
</el-form-item>
|
||
<el-form-item label="怎么做">
|
||
<span class="muted">跌破一个还没买过的档就买一份,涨破一个已经买过的档就卖一份(从现在能卖的存量里卖)。跌破下界就只拿着,不再买。</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>
|
||
|
||
</div>
|
||
|
||
<div v-show="mode==='ops'" class="ops-wrap">
|
||
<div class="metrics">
|
||
<div class="m"><div class="k">总规模</div><div class="v">{{ money(ov.scale) }}</div></div>
|
||
<div class="m"><div class="k">组合市值</div><div class="v">{{ money(ov.portfolio_mv) }}</div></div>
|
||
<div class="m"><div class="k">总仓位</div>
|
||
<div class="v">{{ pct(ov.portfolio_pct) }}<span class="muted"> / {{ pct(ov.portfolio_cap) }}</span></div></div>
|
||
<div class="m"><div class="k">可用额度</div><div class="v">{{ money(ov.cap_room) }}</div></div>
|
||
<div class="m"><div class="k">账户可用<span class="muted" v-if="ov.cash_source!=='ws'"> (估算)</span></div>
|
||
<div class="v" :class="ov.cash_source==='ws'?'':'muted'">{{ money(ov.cash_source==='ws' ? ov.cash_avail : ov.cash_est) }}</div></div>
|
||
<div class="m"><div class="k">持仓数</div>
|
||
<div class="v">{{ ov.names_count }}<span class="muted"> / {{ ov.max_names }}</span></div></div>
|
||
<div class="m"><div class="k">浮动盈亏</div>
|
||
<div class="v" :class="(ov.float_pnl||0)>=0?'up':'down'">{{ money(ov.float_pnl) }}</div></div>
|
||
<div class="m"><div class="k">厚垫 / 负垫</div>
|
||
<div class="v">{{ ov.solid_names }} / {{ ov.neg_names }}</div></div>
|
||
</div>
|
||
|
||
<el-tabs v-model="tab" type="border-card">
|
||
|
||
<!-- ==================== ① 参数设置 ==================== -->
|
||
<el-tab-pane label="参数设置" name="params">
|
||
<div class="row" style="margin-bottom:10px">
|
||
<span class="muted">页面改动持久化到 pms_runtime_param, 优先于 settings.py 初值; 基础设施连接串只在 .env 维护。</span>
|
||
<div class="spacer"></div>
|
||
<el-button size="small" type="primary" :disabled="!dirtyCount" @click="saveParams">
|
||
保存修改 ({{ dirtyCount }})</el-button>
|
||
<el-button size="small" @click="loadParams">放弃修改</el-button>
|
||
</div>
|
||
<el-table :data="params" size="small" height="560" border>
|
||
<el-table-column prop="key" label="参数" width="240" class-name="mono"></el-table-column>
|
||
<el-table-column label="当前值" width="200">
|
||
<template #default="s">
|
||
<el-select v-if="s.row.key==='PMS_AUTONOMY'" v-model="s.row.value" size="small">
|
||
<el-option v-for="o in ['full','propose_only','off']" :key="o" :label="o" :value="o"></el-option>
|
||
</el-select>
|
||
<el-select v-else-if="s.row.key==='PMS_SECTOR_SOURCE'" v-model="s.row.value" size="small">
|
||
<el-option label="(空) 停用行业约束" value=""></el-option>
|
||
<el-option label="custom_table" value="custom_table"></el-option>
|
||
<el-option label="gp_stock_category" value="gp_stock_category"></el-option>
|
||
</el-select>
|
||
<el-switch v-else-if="s.row.type==='bool'" v-model="s.row.value"></el-switch>
|
||
<el-input v-else v-model="s.row.value" size="small"></el-input>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="file_default" label="文件初值" width="130"></el-table-column>
|
||
<el-table-column label="来源" width="80">
|
||
<template #default="s">
|
||
<el-tag size="small" :type="s.row.source==='table'?'success':'info'" effect="dark">
|
||
{{ s.row.source==='table' ? '页面' : '文件' }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="desc" label="说明" min-width="260"></el-table-column>
|
||
<el-table-column prop="updated_at" label="更新时间" width="160" class-name="muted"></el-table-column>
|
||
</el-table>
|
||
</el-tab-pane>
|
||
|
||
<!-- ==================== ② 命令台 ==================== -->
|
||
<el-tab-pane label="命令台" name="cmd">
|
||
<div class="panel">
|
||
<h3>下达命令</h3>
|
||
<div class="row">
|
||
<el-select v-model="form.cmd_type" filterable placeholder="选择命令" style="width:300px"
|
||
@change="onCmdChange">
|
||
<el-option-group v-for="g in ['A','B','C']" :key="g" :label="groupLabel(g)">
|
||
<el-option v-for="c in catalog.filter(x=>x.group===g)" :key="c.cmd_type"
|
||
:label="c.label + ' · ' + c.cmd_type" :value="c.cmd_type"
|
||
:disabled="c.disabled">
|
||
<span>{{ c.label }}</span>
|
||
<span class="muted" style="float:right">{{ c.cls==='param'?'参数':'任务' }}</span>
|
||
</el-option>
|
||
</el-option-group>
|
||
</el-select>
|
||
<el-tag v-if="curSpec" size="small" effect="dark"
|
||
:type="curSpec.cls==='param'?'warning':'primary'">
|
||
{{ curSpec.cls==='param' ? '参数命令 (立即生效)' : '任务命令 (生成方案)' }}</el-tag>
|
||
<el-tag v-if="curSpec && curSpec.danger" size="small" type="danger" effect="dark">高危</el-tag>
|
||
</div>
|
||
<div class="muted" v-if="curSpec && curSpec.note" style="margin:8px 0">{{ curSpec.note }}</div>
|
||
<div class="row" style="margin-top:10px" v-if="curSpec">
|
||
<div v-for="(f,name) in curSpec.fields" :key="name" class="row">
|
||
<span class="muted">{{ fieldLabel(name) }}</span>
|
||
<el-select v-if="f.type==='enum'" v-model="form.params[name]" size="small" style="width:150px">
|
||
<el-option v-for="o in f.choices" :key="o" :label="o" :value="o"></el-option>
|
||
</el-select>
|
||
<el-input v-else v-model="form.params[name]" size="small" style="width:150px"
|
||
:placeholder="f.required===false ? '可留空(默认)' : ''">
|
||
<template v-if="f.type==='pct'" #append>%</template>
|
||
<template v-else-if="f.type==='money'" #append>元</template>
|
||
<template v-else-if="f.type==='price'" #append>元</template>
|
||
</el-input>
|
||
</div>
|
||
<el-input v-model="form.note" size="small" style="width:200px" placeholder="备注(可选)"></el-input>
|
||
<el-button type="primary" size="small" :loading="issuing" @click="issue(false)">下达</el-button>
|
||
</div>
|
||
<div class="muted" style="margin-top:8px" v-if="curSpec && curSpec.cls==='task'">
|
||
任务命令下达后立即生成方案并落 pms_plan; 指令下发由择时执行器负责 (下一批交付)。
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h3>在途命令与进度
|
||
<el-checkbox v-model="showAllCommands" @change="loadCommands" size="small" style="margin-left:12px;font-weight:normal">显示已完成/已移除</el-checkbox>
|
||
</h3>
|
||
<el-table :data="commandsShown" size="small" border max-height="420"
|
||
:row-class-name="()=>''">
|
||
<el-table-column type="expand" width="40">
|
||
<template #default="s">
|
||
<div style="padding:8px 16px">
|
||
<div class="muted">参数: <span class="mono">{{ JSON.stringify(s.row.params) }}</span></div>
|
||
<div class="muted" v-if="s.row.progress && s.row.progress.notes">
|
||
说明: {{ (s.row.progress.notes||[]).join(' | ') }}</div>
|
||
<el-button size="small" style="margin:8px 0" @click="loadPlans(s.row.command_id)">
|
||
查看方案明细</el-button>
|
||
<el-table v-if="plansOf===s.row.command_id" :data="plans" size="small" border>
|
||
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
|
||
<el-table-column prop="action" label="动作" width="80"></el-table-column>
|
||
<el-table-column prop="qty" label="数量" width="90"></el-table-column>
|
||
<el-table-column label="金额" width="110">
|
||
<template #default="p">{{ money(p.row.amount) }}</template></el-table-column>
|
||
<el-table-column prop="priority" label="优先级" width="80"></el-table-column>
|
||
<el-table-column prop="status" label="状态" width="90"></el-table-column>
|
||
<el-table-column prop="deadline" label="截止" width="110"></el-table-column>
|
||
<el-table-column prop="reason" label="理由" min-width="260"></el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="command_id" label="命令号" width="160" class-name="mono"></el-table-column>
|
||
<el-table-column prop="cmd_type" label="类型" width="160"></el-table-column>
|
||
<el-table-column prop="ts_code" label="标的" width="110" class-name="mono"></el-table-column>
|
||
<el-table-column label="状态" width="100">
|
||
<template #default="s">
|
||
<el-tag size="small" effect="dark" :type="stTag(s.row.status)">{{ s.row.status }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="进度" min-width="220">
|
||
<template #default="s">
|
||
<div v-if="s.row.progress && s.row.progress.target_amount">
|
||
<el-progress :percentage="progPct(s.row.progress)" :stroke-width="10"
|
||
:text-inside="true" status="success"></el-progress>
|
||
<span class="muted">{{ money(s.row.progress.done_amount) }} /
|
||
{{ money(s.row.progress.target_amount) }}
|
||
<template v-if="s.row.progress.gap"> · 缺口 {{ money(s.row.progress.gap) }}</template>
|
||
</span>
|
||
</div>
|
||
<span v-else class="muted">{{ (s.row.progress||{}).plan_count || 0 }} 条方案</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="issued_at" label="下达时间" width="160" class-name="muted"></el-table-column>
|
||
<el-table-column label="操作" width="220">
|
||
<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>
|
||
<el-button v-if="cmdArchivable(s.row) && !s.row.archived_at" size="small"
|
||
@click="archiveRow('commands', s.row.command_id, true)">移除</el-button>
|
||
<el-button v-if="s.row.archived_at" size="small" type="info"
|
||
@click="archiveRow('commands', s.row.command_id, false)">恢复显示</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" width="40">
|
||
<template #default="s">
|
||
<div style="padding:8px 16px">
|
||
<el-button size="small" @click="loadLots(s.row.ts_code)">查看批次</el-button>
|
||
<el-table v-if="lotsOf===s.row.ts_code" :data="lots" size="small" border
|
||
style="margin-top:8px">
|
||
<el-table-column prop="lot_type" label="批次" width="90"></el-table-column>
|
||
<el-table-column prop="qty" label="剩余" width="90"></el-table-column>
|
||
<el-table-column prop="closed_qty" label="已核销" width="90"></el-table-column>
|
||
<el-table-column prop="open_price" label="开仓价" width="100"></el-table-column>
|
||
<el-table-column prop="close_avg_price" label="核销均价" width="100"></el-table-column>
|
||
<el-table-column prop="realized_pnl" label="已实现" width="100"></el-table-column>
|
||
<el-table-column prop="open_date" label="开仓日" width="110"></el-table-column>
|
||
<el-table-column prop="instruction_id" label="来源指令" min-width="180"
|
||
class-name="mono"></el-table-column>
|
||
<el-table-column prop="note" label="备注" min-width="160"></el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
|
||
<el-table-column prop="sector" label="行业" width="100"></el-table-column>
|
||
<el-table-column prop="status" label="状态" width="90"></el-table-column>
|
||
<el-table-column label="冻结" width="110">
|
||
<template #default="s">
|
||
<el-tag v-if="s.row.frozen_reason && s.row.frozen_reason!=='NONE'" size="small"
|
||
type="danger" effect="dark">{{ s.row.frozen_reason }}</el-tag>
|
||
<span v-else class="muted">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="total_qty" label="持仓" width="90"></el-table-column>
|
||
<el-table-column prop="avail_qty" label="可卖" width="90"></el-table-column>
|
||
<el-table-column label="批次(底/补/加/补仓/T)" width="170">
|
||
<template #default="s"><span class="mono nowrap">{{ s.row.base_qty }}/{{ s.row.fill_qty }}/{{ s.row.add_qty }}/{{ s.row.dca_qty }}/{{ s.row.t0_qty }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column prop="avg_cost" label="摊薄成本" width="100"></el-table-column>
|
||
<el-table-column prop="price" label="现价" width="90"></el-table-column>
|
||
<el-table-column label="安全垫" width="130">
|
||
<template #default="s">
|
||
<el-tag size="small" effect="dark" :type="cuTag(s.row.cushion_state)">
|
||
{{ pct(s.row.cushion_pct) }} {{ s.row.cushion_state }}</el-tag>
|
||
<span v-if="s.row.neg_cushion_days>0" class="muted"> 连负{{ s.row.neg_cushion_days }}日</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="占规模" width="90">
|
||
<template #default="s">{{ pct(s.row.pct_of_scale) }}</template></el-table-column>
|
||
<el-table-column label="市值" width="110">
|
||
<template #default="s">{{ money(s.row.market_value) }}</template></el-table-column>
|
||
<!-- 参考位是系统每天早上盘前重算并整列重写的数, 来源逐行标出来 (原来直接印
|
||
bionic / self_calc 这种内部值, 看不出是谁给的)。用户自己设的止损价与目标价
|
||
不在这一列, 在右边「我设的」那一列 —— 两个数不是一回事, 分开摆。 -->
|
||
<el-table-column label="参考位·系统算的(支/压/止损)" width="220">
|
||
<template #default="s"><span class="mono nowrap">{{ s.row.support_ref||'-' }}/{{ s.row.pressure_ref||'-' }}/{{ s.row.stop_ref||'-' }}</span>
|
||
<br><span class="muted">{{ tx('refSource', s.row.ref_source) }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column label="我设的(止损/目标)" width="150">
|
||
<template #default="s">
|
||
<span class="mono nowrap"
|
||
v-if="s.row.user_stop_price!=null || s.row.user_target_price!=null">{{ s.row.user_stop_price!=null ? s.row.user_stop_price : '-' }}/{{ s.row.user_target_price!=null ? s.row.user_target_price : '-' }}</span>
|
||
<span v-else class="muted">未设</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>在途指令
|
||
<el-checkbox v-model="showAllInstr" size="small" style="margin-left:12px;font-weight:normal">显示已了结</el-checkbox>
|
||
</h3>
|
||
<el-table :data="instrLive" size="small" border max-height="320" empty-text="没有在途指令">
|
||
<el-table-column type="expand" width="40">
|
||
<template #default="s">
|
||
<div style="padding:6px 16px">
|
||
<div class="muted" v-if="(s.row.progress||{}).last_decision">
|
||
最近判定 [{{ s.row.progress.last_decision.at }}]
|
||
<b>{{ s.row.progress.last_decision.action }}</b> —
|
||
{{ s.row.progress.last_decision.reason }}
|
||
</div>
|
||
<div class="muted" v-if="(s.row.progress||{}).deadline">
|
||
执行窗口截止: {{ s.row.progress.deadline }}
|
||
<template v-if="(s.row.progress||{}).window_verdict">
|
||
· {{ s.row.progress.window_verdict.note }}</template>
|
||
</div>
|
||
<el-table v-if="((s.row.progress||{}).children||[]).length"
|
||
:data="s.row.progress.children" size="small" border
|
||
style="margin-top:6px">
|
||
<el-table-column prop="ymd" label="日期" width="100"></el-table-column>
|
||
<el-table-column prop="at" label="时点" width="90"></el-table-column>
|
||
<el-table-column prop="qty" label="数量" width="90"></el-table-column>
|
||
<el-table-column prop="limit" label="限价" width="90"></el-table-column>
|
||
<el-table-column prop="mode" label="通道" width="100"></el-table-column>
|
||
<el-table-column label="兜底" width="70">
|
||
<template #default="c">{{ c.row.forced ? '是' : '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="reason" label="出手依据" min-width="260"></el-table-column>
|
||
</el-table>
|
||
<div v-else class="muted" style="margin-top:6px">尚无出手记录</div>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="instruction_id" label="指令号" width="230" class-name="mono"></el-table-column>
|
||
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
|
||
<el-table-column prop="action" label="动作" width="80"></el-table-column>
|
||
<el-table-column prop="side" label="方向" width="70"></el-table-column>
|
||
<el-table-column prop="qty" label="数量" width="80"></el-table-column>
|
||
<el-table-column prop="exec_qty" label="已成交" width="80"></el-table-column>
|
||
<el-table-column label="进度" width="130">
|
||
<template #default="s">
|
||
<el-progress :percentage="insPct(s.row)" :stroke-width="10" :text-inside="true"></el-progress>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="status" label="状态" width="115"></el-table-column>
|
||
<el-table-column prop="origin_type" label="来源" width="80"></el-table-column>
|
||
<el-table-column label="操作" width="90">
|
||
<template #default="s">
|
||
<el-button size="small" @click="cancelIns(s.row.instruction_id)">撤销</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<div class="muted" style="margin-top:6px">
|
||
{{ dm.hint }} 展开可看每日子单与择时依据; 成交量以下游回放为准, 不拿下发量当成交量。
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h3>评审账本 (规则闸 / 研判闸 / 人工裁决全量留痕)</h3>
|
||
<el-table :data="ledger" size="small" border max-height="260">
|
||
<el-table-column prop="decided_at" label="时间" width="160"></el-table-column>
|
||
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
|
||
<el-table-column prop="action" label="动作" width="90"></el-table-column>
|
||
<el-table-column prop="arbiter" label="评审方" width="90"></el-table-column>
|
||
<el-table-column label="结论" width="90">
|
||
<template #default="s">
|
||
<el-tag size="small" effect="dark" :type="s.row.verdict==='PASS'?'success':'danger'">
|
||
{{ s.row.verdict }}</el-tag></template>
|
||
</el-table-column>
|
||
<el-table-column prop="price_at" label="评审价" width="90"></el-table-column>
|
||
<el-table-column label="未通过项" min-width="240">
|
||
<template #default="s"><span class="muted">{{ (s.row.failed_checks||[]).join(' | ') }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column prop="reason" label="理由" min-width="200"></el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- ==================== ④ 提议确认 ==================== -->
|
||
<el-tab-pane name="prop">
|
||
<template #label>
|
||
提议确认
|
||
<el-badge v-if="proposals.length" :value="proposals.length" class="ml"></el-badge>
|
||
</template>
|
||
<div class="panel">
|
||
<h3>待确认队列 (自主档位 {{ ov.autonomy }})</h3>
|
||
<div class="muted" style="margin-bottom:8px">
|
||
propose_only 档位下, 增持类自主动作全部在此等待裁决; 减持方向不设确认门槛。
|
||
采纳后先落指令表 (先记账后动作), 由择时执行器分日出手。
|
||
</div>
|
||
<el-alert v-if="dm.judge && !dm.judge.available" type="warning" effect="dark" show-icon
|
||
:closable="false" style="margin-bottom:10px"
|
||
:title="'研判闸未接通 —— ' + dm.judge.reason + ' (补足/加仓/补仓一律降级为人工确认)'"></el-alert>
|
||
<el-table :data="proposals" size="small" border max-height="420">
|
||
<el-table-column prop="proposal_id" label="提议号" width="230" class-name="mono"></el-table-column>
|
||
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
|
||
<el-table-column prop="action" label="动作" width="90"></el-table-column>
|
||
<el-table-column prop="qty" label="数量" width="90"></el-table-column>
|
||
<el-table-column label="硬数字" min-width="320">
|
||
<template #default="s"><span class="mono muted">{{ JSON.stringify(s.row.hard_numbers) }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column prop="judge_verdict" label="研判" width="100"></el-table-column>
|
||
<el-table-column label="选股判决" min-width="220">
|
||
<template #default="s">
|
||
<span v-if="(s.row.hard_numbers||{}).verdict">「{{ s.row.hard_numbers.verdict }}」 {{ propReasons(s.row).join(';') }}</span>
|
||
<span v-else class="muted">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="expire_at" label="失效时间" width="160"></el-table-column>
|
||
<el-table-column label="裁决" width="170">
|
||
<template #default="s">
|
||
<el-button size="small" type="success" @click="decide(s.row,'ACCEPTED')">采纳</el-button>
|
||
<el-button size="small" type="danger" @click="decide(s.row,'DECLINED')">驳回</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- ==================== ④ ws 通道 (只读监控) ====================
|
||
只读: 任何会产生新委托的动作都不放在页面上。联调发单仍走
|
||
scripts/ws_smoke.py place --yes —— 那条路径有显式确认、有参数校验、
|
||
有"这张单会真成交"的警告, 且不受 PMS_DISPATCH_MODE 影响。页面上加个
|
||
发单按钮等于在生产界面开了第二条绕过开关的下单入口, 不值当。 -->
|
||
<el-tab-pane label="ws 通道" name="ws">
|
||
<div class="row" style="margin-bottom:10px">
|
||
<el-tag :type="wsc.process_alive ? 'success':'danger'" effect="dark" size="small">
|
||
进程 {{ wsc.process_alive ? '在线':'不在线' }}</el-tag>
|
||
<el-tag :type="wsc.conn_state==='ONLINE' ? 'success':'warning'" effect="dark" size="small">
|
||
{{ wsc.conn_state || '-' }}</el-tag>
|
||
<span class="muted">心跳 {{ wsc.heartbeat_age_sec==null ? '从未' : wsc.heartbeat_age_sec+'s 前' }}</span>
|
||
<div class="spacer"></div>
|
||
<el-checkbox v-model="wsAuto" size="small">每 3 秒自动刷新</el-checkbox>
|
||
<el-button size="small" @click="loadWs">刷新</el-button>
|
||
</div>
|
||
|
||
<!-- 异常自检: 与 scripts/ws_smoke.py 用同一套判据, 页面和命令行的结论必须一致,
|
||
否则联调时两边打架比没有告警更糟 -->
|
||
<el-alert v-for="(w,i) in wsWarnings" :key="i" class="banner" type="warning" effect="dark"
|
||
show-icon :closable="false" :title="w"></el-alert>
|
||
|
||
<div class="metrics">
|
||
<div class="m"><div class="k">seq 已落库</div><div class="v">{{ wsc.last_seq }}</div></div>
|
||
<div class="m"><div class="k">seq 已确认</div><div class="v">{{ wsc.acked_seq }}</div></div>
|
||
<div class="m"><div class="k">待入账上行</div><div class="v">{{ wsc.inbox_pending }}</div></div>
|
||
<div class="m"><div class="k">收 / 发</div>
|
||
<div class="v">{{ wst.rx||0 }} / {{ wst.tx||0 }}</div></div>
|
||
<div class="m"><div class="k">成交 / 拒绝</div>
|
||
<div class="v">{{ wst.trades||0 }} / {{ wst.rejects||0 }}</div></div>
|
||
<div class="m"><div class="k">重连</div><div class="v">{{ wst.reconnects||0 }}</div></div>
|
||
<div class="m"><div class="k">对端时钟偏差</div>
|
||
<div class="v" :class="Math.abs(wst.peer_skew_ms||0)>20000?'up':''">
|
||
{{ wst.peer_skew_ms==null ? '—' : (wst.peer_skew_ms>0?'+':'') + wst.peer_skew_ms + 'ms' }}</div></div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h3>通道明细</h3>
|
||
<div class="row" style="gap:24px">
|
||
<span class="muted">下发模式 <span class="mono">{{ wsMode || '-' }}</span></span>
|
||
<span class="muted">上次连上 {{ wsc.connected_at || '—' }}</span>
|
||
<span class="muted">出口队列 <span class="mono">{{ JSON.stringify(wsc.queue||{}) }}</span></span>
|
||
</div>
|
||
<div v-if="wsc.last_error" class="muted" style="margin-top:8px;color:#e6a23c">
|
||
最后错误: {{ wsc.last_error }}</div>
|
||
<div v-if="wst.last_reject" class="muted" style="margin-top:6px">
|
||
最后一条拒绝: <span class="mono">{{ wst.last_reject }}</span></div>
|
||
<div v-if="wst.last_close" class="muted" style="margin-top:6px">
|
||
最后断开原因: <span class="mono">{{ wst.last_close }}</span></div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h3>出口委托 (pms_qmt_order)</h3>
|
||
<el-table :data="wsOrders" size="small" border max-height="300" empty-text="暂无委托">
|
||
<el-table-column prop="instruction_id" label="指令号" width="230" class-name="mono"></el-table-column>
|
||
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
|
||
<el-table-column prop="side" label="方向" width="70"></el-table-column>
|
||
<el-table-column prop="qty" label="数量" width="80"></el-table-column>
|
||
<el-table-column prop="limit_price" label="限价" width="90"></el-table-column>
|
||
<el-table-column prop="status" label="状态" width="120"></el-table-column>
|
||
<el-table-column prop="cum_qty" label="已成" width="80"></el-table-column>
|
||
<el-table-column prop="cancel_state" label="撤单" width="90"></el-table-column>
|
||
<el-table-column prop="reject_code" label="拒绝码" min-width="140"></el-table-column>
|
||
</el-table>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h3>上行消息 (pms_qmt_inbox)
|
||
<span class="muted" style="font-weight:400">· 新→旧</span></h3>
|
||
<div class="row" style="margin-bottom:8px">
|
||
<el-checkbox v-model="wsHidePong" size="small">隐藏 pong</el-checkbox>
|
||
<span class="muted">pong 每 5 秒一条且按协议 §5 带 seq, 不隐藏会把成交冲没</span>
|
||
</div>
|
||
<el-table :data="wsInbox" size="small" border max-height="380" empty-text="还没收到上行消息">
|
||
<el-table-column prop="seq" label="seq" width="90" class-name="mono"></el-table-column>
|
||
<el-table-column prop="msg_type" label="类型" width="130"></el-table-column>
|
||
<el-table-column label="入账" width="80">
|
||
<template #default="s">
|
||
<span class="muted">{{ ({0:'待入账',1:'已入账',2:'已消化'})[s.row.processed] || '?' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="内容" min-width="380">
|
||
<template #default="s">
|
||
<span class="mono muted">{{ JSON.stringify(s.row.payload || {}) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
|
||
</div>
|
||
|
||
<!-- 运维抽屉 -->
|
||
<el-drawer v-model="opsDrawer" title="运维操作 (与调度器同一份实现)" size="46%">
|
||
<div class="muted" style="margin-bottom:10px">
|
||
调度器 (--profile sched) 会自动按调度总表执行; 此处按钮用于未接调度或临时验证时手动触发。
|
||
</div>
|
||
<div class="row">
|
||
<el-button @click="ops('plan-pending')" :loading="opsLoading">推进待规划命令</el-button>
|
||
<el-button @click="ops('materialize')" :loading="opsLoading">方案转指令</el-button>
|
||
<el-button type="primary" @click="ops('exec-tick?dry_run=true')" :loading="opsLoading">
|
||
出手试算 (不下发)</el-button>
|
||
<el-button @click="ops('exec-tick')" :loading="opsLoading">出手一跳</el-button>
|
||
<el-button @click="ops('sweep-windows')" :loading="opsLoading">窗口收口</el-button>
|
||
</div>
|
||
<div class="row" style="margin-top:8px">
|
||
<el-button type="primary" @click="ops('scan-proposals?dry_run=true')" :loading="opsLoading">
|
||
提议扫描试算 (不落表)</el-button>
|
||
<el-button @click="ops('scan-proposals')" :loading="opsLoading">提议扫描</el-button>
|
||
</div>
|
||
<div class="row" style="margin-top:8px">
|
||
<el-button @click="ops('replay')" :loading="opsLoading">成交回放</el-button>
|
||
<el-button @click="ops('reconcile')" :loading="opsLoading">账本对账</el-button>
|
||
<el-button @click="ops('premarket')" :loading="opsLoading">盘前准备</el-button>
|
||
<el-button @click="ops('daily-settle')" :loading="opsLoading">日终结算</el-button>
|
||
<el-button @click="ops('report')" :loading="opsLoading">生成日报</el-button>
|
||
<el-button @click="loadSchema" :loading="opsLoading">导出下游表结构</el-button>
|
||
</div>
|
||
<div class="row" style="margin-top:8px" v-if="(wsc||{}).resync_required">
|
||
<el-button type="warning" @click="clearResync" :loading="opsLoading">清除通道对账标记</el-button>
|
||
<span class="muted" style="flex:1;min-width:240px">通道曾经补发不全(顶部横幅「通道要人工做一次
|
||
全量对账」就是它)。处理顺序:先点上面的「账本对账」,确认结果里差异为零,再点这里清除提醒。</span>
|
||
</div>
|
||
<div class="muted" style="margin-top:10px">
|
||
当前下发通道: <b>{{ dm.mode }}</b> —— {{ dm.hint }}
|
||
(通道在「参数设置 → PMS_DISPATCH_MODE」切换)
|
||
</div>
|
||
<pre class="json" v-if="opsResult">{{ opsResult }}</pre>
|
||
</el-drawer>
|
||
|
||
<!-- 上游选股计划抽屉 -->
|
||
<el-drawer v-model="planDrawer" title="上游选股计划 (候选池的事实源)" size="56%">
|
||
<div class="row" style="margin-bottom:8px">
|
||
<el-button v-if="isAdmin" type="primary" :loading="planLoading" @click="refreshPlan">
|
||
强刷 + 灌行业映射</el-button>
|
||
<el-button :loading="planLoading" @click="loadPlan">只重读</el-button>
|
||
<span class="muted" style="margin-left:8px">
|
||
计划不带价格与金额 —— 买多少/什么价一律 PMS 自己算; 行情取不到价的票会被剔除。
|
||
</span>
|
||
</div>
|
||
<el-alert v-if="planStatus.ok===false" type="error" effect="dark" show-icon :closable="false"
|
||
:title="planStatus.hint || '不可用'" style="margin-bottom:8px"></el-alert>
|
||
<div class="muted" v-else style="margin-bottom:8px">
|
||
计划日 <b>{{ planStatus.date }}</b> (日龄 {{ planStatus.age_tdays }} 交易日) ·
|
||
热度日 {{ planStatus.heat_date || '-' }} · 快照 {{ (planStatus.market_snapshot_days||[]).join(',') }} ·
|
||
上游主题上限 {{ planStatus.theme_cap }} · theme 灌映射 {{ JSON.stringify(planStatus.theme_sync||{}) }}
|
||
<br>请求 {{ JSON.stringify(planStatus.query||{}) }} · 漏斗: 打分池
|
||
主榜 {{ (planStatus.funnel||{}).scored_main }} / 观察 {{ (planStatus.funnel||{}).scored_observe }}
|
||
→(券商预期 + 目标价≥现价 + 主题限额 + top)→ 实收
|
||
主榜 {{ (planStatus.funnel||{}).returned_main }} / 观察 {{ (planStatus.funnel||{}).returned_observe }}
|
||
</div>
|
||
<el-alert v-if="planCand && planCand.tier_complete===false" type="warning" effect="dark"
|
||
show-icon :closable="false" style="margin-bottom:8px"
|
||
:title="'返回的全是白名单档位 (top=' + ((planStatus.requested||{}).top)
|
||
+ ' 吃满了) —— 白名单档位可能没取全, 调大 PMS_PLAN_TOP'">
|
||
</el-alert>
|
||
<div class="muted" v-if="planCand" style="margin-bottom:8px">
|
||
按当前参数筛选: 排序池 {{ planCand.considered }} → 合格 {{ planCand.eligible }} →
|
||
取 {{ (planCand.items||[]).length }} 只 ·
|
||
白名单档位取全 {{ planCand.tier_complete===null ? '不适用' : (planCand.tier_complete ? '是':'否') }} ·
|
||
丢弃 {{ JSON.stringify(planCand.dropped||{}) }}
|
||
<span v-if="(planCand.st_unknown||[]).length" style="color:#E6A23C">·
|
||
{{ planCand.st_unknown.length }} 只无名称, ST 判不了已放行</span>
|
||
<br><span class="mono">{{ (planCand.items||[]).map(x=>x.ts_code).join(' ') }}</span>
|
||
</div>
|
||
<!-- 榜单变化 (PMS 自算; 上游 changes 恒为 null) -->
|
||
<el-divider content-position="left">榜单变化</el-divider>
|
||
<div class="row" style="margin-bottom:6px">
|
||
<el-button size="small" :loading="chgLoading" @click="loadChanges">重算变化</el-button>
|
||
<span class="muted" style="margin-left:8px" v-if="chgSt.hint">{{ chgSt.hint }}</span>
|
||
</div>
|
||
<el-alert v-if="chgSt.in_sync === false" type="error" effect="dark" show-icon
|
||
:closable="false" style="margin-bottom:8px"
|
||
title="手上这份计划没落进快照 —— 下面比的是两个旧版本, 与当前候选池不是同一份">
|
||
</el-alert>
|
||
<el-alert v-else-if="chg && chg.kind === 'same_date_revision'" type="warning" effect="dark"
|
||
show-icon :closable="false" style="margin-bottom:8px"
|
||
:title="'同一计划日 ' + chg.curr_date + ' 的新版本 —— 上游重算过, '
|
||
+ '此前拿到的候选池与现在不是同一份 (上游 /plan 是实时算的, 没有版本戳)'">
|
||
</el-alert>
|
||
<el-alert v-if="chg && chg.prev_broken" type="error" effect="dark" show-icon
|
||
:closable="false" style="margin-bottom:8px"
|
||
title="上一版名册读不出来 (快照损坏) —— 这次比不了, 下面的空白不代表没有变化">
|
||
</el-alert>
|
||
<el-alert v-if="chgHeld.n_watch" type="error" effect="dark" show-icon :closable="false"
|
||
style="margin-bottom:8px"
|
||
:title="'持仓票 ' + chgHeld.n_watch + ' 只掉榜 / 降档 / 丢了券商覆盖 —— 见下表红字'">
|
||
</el-alert>
|
||
<el-alert v-if="chg && chg.date_regressed" type="warning" effect="dark" show-icon
|
||
:closable="false" style="margin-bottom:8px"
|
||
title="计划日往回走了 (拉过一份旧计划?) —— 新进/掉榜的方向是反的">
|
||
</el-alert>
|
||
<div class="muted" v-if="chg" style="margin-bottom:8px">
|
||
{{ chg.kind === 'first' ? '首次落快照' : (chg.prev_date + ' → ' + chg.curr_date) }} ·
|
||
名册 {{ chg.roster_size.prev }} → {{ chg.roster_size.curr }} ·
|
||
新进 {{ chg.counts.entered }} · 掉榜 {{ chg.counts.exited }} ·
|
||
升档 {{ chg.counts.tier_up }} · 降档 {{ chg.counts.tier_down }} ·
|
||
覆盖翻转 {{ chg.counts.bucket_moved }} · 名次跳变 {{ chg.counts.rank_jump }}
|
||
<span v-if="(chg.tail_churn.entered + chg.tail_churn.exited)">·
|
||
榜尾进出 {{ chg.tail_churn.entered + chg.tail_churn.exited }} 只未计入 (那一版吃满 top,
|
||
尾部是截断噪音不是观点变化)</span>
|
||
<span v-if="chg.tail_churn.held_exempt">· 其中
|
||
<b>{{ chg.tail_churn.held_exempt }} 只因是持仓票照报</b> (持仓票不受榜尾闸约束 ——
|
||
漏报一条就是一个仓位)</span>
|
||
<span v-if="chgRevised.length">· <b>同一计划日出现多版</b>:
|
||
{{ chgRevised.map(x => x[0] + '×' + x[1]).join(', ') }}</span>
|
||
</div>
|
||
<el-tabs v-model="chgTab" v-if="chg">
|
||
<el-tab-pane name="held" :label="'持仓票 (' + chgHeld.n_total + ')'">
|
||
<el-table :data="chgWatch.filter(x => x.held)" size="small" border max-height="220"
|
||
empty-text="持仓票没被摘也没降档">
|
||
<el-table-column prop="ts_code" label="代码" width="104"></el-table-column>
|
||
<el-table-column prop="name" label="名称" width="92"></el-table-column>
|
||
<el-table-column prop="why" label="发生了什么" min-width="200">
|
||
<template #default="s"><span style="color:#F56C6C">{{ s.row.why }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column prop="rank" label="上版 rank" width="90"></el-table-column>
|
||
<el-table-column prop="theme" label="主题" width="120"></el-table-column>
|
||
</el-table>
|
||
<div class="muted" style="margin-top:6px">
|
||
上游把一只<b>持仓票</b>摘出榜或降了档, 是"还该不该继续拿着"的直接信号 ——
|
||
单独拎出来免得被几十条新进榜淹掉。<b>它只是提示, 不自动产生任何指令</b>:
|
||
要减要清仍走命令台或提议确认。
|
||
</div>
|
||
</el-tab-pane>
|
||
<el-tab-pane name="watch" :label="'掉榜与降档 (' + chgWatch.length + ')'">
|
||
<el-table :data="chgWatch" size="small" border max-height="220" empty-text="无">
|
||
<el-table-column prop="ts_code" label="代码" width="104"></el-table-column>
|
||
<el-table-column prop="name" label="名称" width="92"></el-table-column>
|
||
<el-table-column prop="why" label="发生了什么" min-width="180"></el-table-column>
|
||
<el-table-column prop="rank" label="上版 rank" width="90"></el-table-column>
|
||
<el-table-column label="持仓" width="70">
|
||
<template #default="s">
|
||
<el-tag v-if="s.row.held" type="danger" size="small">持仓</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</el-tab-pane>
|
||
<el-tab-pane name="in" :label="'新进榜 (' + chg.counts.entered + ')'">
|
||
<el-table :data="chg.entered" size="small" border max-height="220" empty-text="无">
|
||
<el-table-column prop="rank" label="rank" width="62"></el-table-column>
|
||
<el-table-column prop="ts_code" label="代码" width="104"></el-table-column>
|
||
<el-table-column prop="name" label="名称" width="92"></el-table-column>
|
||
<el-table-column prop="tier" label="传导档" width="82"></el-table-column>
|
||
<el-table-column prop="theme" label="主题" width="120"></el-table-column>
|
||
<el-table-column prop="score" label="score" width="82"></el-table-column>
|
||
</el-table>
|
||
</el-tab-pane>
|
||
<el-tab-pane name="up" :label="'升档 (' + chg.counts.tier_up + ')'">
|
||
<el-table :data="chg.tier_up" size="small" border max-height="220" empty-text="无">
|
||
<el-table-column prop="rank" label="rank" width="62"></el-table-column>
|
||
<el-table-column prop="ts_code" label="代码" width="104"></el-table-column>
|
||
<el-table-column prop="name" label="名称" width="92"></el-table-column>
|
||
<el-table-column label="档位" min-width="150">
|
||
<template #default="s">{{ s.row.tier_from }} → <b>{{ s.row.tier_to }}</b></template>
|
||
</el-table-column>
|
||
<el-table-column prop="theme" label="主题" width="120"></el-table-column>
|
||
</el-table>
|
||
</el-tab-pane>
|
||
<el-tab-pane name="jump" :label="'名次跳变 (' + chg.counts.rank_jump + ')'">
|
||
<el-table :data="chg.rank_jump" size="small" border max-height="220" empty-text="无">
|
||
<el-table-column prop="ts_code" label="代码" width="104"></el-table-column>
|
||
<el-table-column prop="name" label="名称" width="92"></el-table-column>
|
||
<el-table-column label="名次" min-width="150">
|
||
<template #default="s">
|
||
{{ s.row.rank_from }} → {{ s.row.rank_to }}
|
||
<span :style="{color: s.row.rank_delta > 0 ? '#F56C6C' : '#67C23A'}">
|
||
({{ s.row.rank_delta > 0 ? '+' : '' }}{{ s.row.rank_delta }})</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="tier" label="传导档" width="82"></el-table-column>
|
||
<el-table-column prop="theme" label="主题" width="120"></el-table-column>
|
||
</el-table>
|
||
</el-tab-pane>
|
||
<el-tab-pane name="log" :label="'快照台账 (' + (chgLog.rows||[]).length + ')'">
|
||
<el-table :data="chgLog.rows" size="small" border max-height="220" empty-text="还没有快照">
|
||
<el-table-column prop="plan_date" label="计划日" width="104"></el-table-column>
|
||
<el-table-column prop="fetched_at" label="拉到的时刻" width="170"></el-table-column>
|
||
<el-table-column prop="n_main" label="主榜" width="70"></el-table-column>
|
||
<el-table-column prop="n_observe" label="观察" width="70"></el-table-column>
|
||
<el-table-column label="吃满 top" width="90">
|
||
<template #default="s">
|
||
<el-tag v-if="s.row.capped_main" type="warning" size="small">主榜</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="digest" label="指纹" min-width="140"></el-table-column>
|
||
</el-table>
|
||
<div class="muted" style="margin-top:6px">
|
||
<b>同一「计划日」出现多行 = 上游那天重算过多版</b>(UPSTREAM_PLAN_API.md §7.3:
|
||
`/plan` 是实时算的, 应答里没有 generated_at, 下游本来没办法判断手上这份是哪一版)。
|
||
指纹不含 score —— 分数抖动但次序没变不算新版本, 否则表白涨而信息量为零。
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
<el-divider content-position="left">主榜明细</el-divider>
|
||
<el-table :data="planRows" size="small" border height="420">
|
||
<el-table-column prop="rank" label="rank" width="62"></el-table-column>
|
||
<el-table-column prop="ts_code" label="代码" width="104"></el-table-column>
|
||
<el-table-column prop="name" label="名称" width="92"></el-table-column>
|
||
<el-table-column prop="score" label="score" width="82"></el-table-column>
|
||
<el-table-column prop="tier" label="传导档" width="82"></el-table-column>
|
||
<el-table-column prop="theme" label="主题" width="120"></el-table-column>
|
||
<el-table-column prop="heat" label="heat" width="78"></el-table-column>
|
||
<el-table-column label="预期空间" width="96">
|
||
<template #default="s">
|
||
<span class="muted">{{ s.row.upside==null ? '-' :
|
||
((s.row.upside>0?'+':'') + (s.row.upside*100).toFixed(0) + '%') }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="n_sources" label="源数" width="66"></el-table-column>
|
||
</el-table>
|
||
<div class="muted" style="margin-top:6px">
|
||
「预期空间」= 券商目标价相对现价的空间 (上游 upside 字段, 2.12 → +212%)。噪音大,
|
||
所以<b>永不参与排序</b> —— 排序始终按 score; 只在 PMS_PLAN_MIN_UPSIDE 上做下限过滤。
|
||
筛选参数在「参数设置」里: PMS_PLAN_TOP_N / PMS_PLAN_TIERS / PMS_PLAN_INCLUDE_OBSERVE /
|
||
PMS_PLAN_MIN_SCORE / PMS_PLAN_MIN_SOURCES / PMS_PLAN_MIN_UPSIDE / PMS_PLAN_STALE_TDAYS。
|
||
<br>发给上游的三个: PMS_PLAN_TOP / PMS_PLAN_OBS_TOP / PMS_PLAN_THEME_CAP (0=不传, 用上游默认
|
||
20/10/5)。<b>PMS_PLAN_THEME_CAP_LOCAL</b> 是 PMS 侧的同主题限额, 在 TOP_N 截断之前生效 ——
|
||
让上游别裁 (THEME_CAP 设大) + PMS 自己裁, 前 N 名才不会被单一主题垄断。
|
||
<br><b>PMS_PLAN_EXCLUDE_ST</b> 默认开: 上游名单明确不剔除 ST/*ST, 这道闸在 PMS 侧。
|
||
「主题」是<b>传导主题</b>(事件驱动、天天变、覆盖约三成), <b>不是行业</b> —— 行业硬拦截
|
||
请把 PMS_SECTOR_SOURCE 设成 gp_stock_category, 别用 PMS_PLAN_THEME_SYNC。
|
||
</div>
|
||
<pre class="json" v-if="planResult">{{ planResult }}</pre>
|
||
</el-drawer>
|
||
|
||
<!-- 日报抽屉 -->
|
||
<el-drawer v-model="reportDrawer" :title="'运营日报 ' + (report.ymd||'')" size="52%">
|
||
<div v-if="(report.report||{}).attention">
|
||
<h3 style="color:#409EFF;font-size:14px">关注区</h3>
|
||
<el-table :data="report.report.attention" size="small" border>
|
||
<el-table-column prop="type" label="事项" width="130"></el-table-column>
|
||
<el-table-column label="内容">
|
||
<template #default="s"><span class="mono muted">{{ JSON.stringify(s.row) }}</span></template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
<pre class="json">{{ JSON.stringify(report.report||{}, null, 2) }}</pre>
|
||
</el-drawer>
|
||
</div>
|
||
|
||
<script>
|
||
const { createApp, ref, reactive, computed, onMounted, onUnmounted, watch } = Vue;
|
||
const api = axios.create({ timeout: 60000 });
|
||
// 收到 401 就弹登录框: setup() 里把 _onUnauth 挂上 (拦截器在 setup 外, 够不到 authed)。
|
||
let _onUnauth = null;
|
||
api.interceptors.response.use(r => r, e => {
|
||
if (e && e.response && e.response.status === 401 && _onUnauth) _onUnauth();
|
||
return Promise.reject(e);
|
||
});
|
||
|
||
createApp({
|
||
setup() {
|
||
const tab = ref('cmd'), loading = ref(false), err = ref('');
|
||
const mode = ref('trader');
|
||
// ---- 登录与角色 (2026-08 加权限)
|
||
const authed = ref(false), authReady = ref(false);
|
||
const me = ref({ phone: '', username: '', roles: [] });
|
||
const isAdmin = computed(() => (me.value.roles || []).includes('系统管理员'));
|
||
const rolesText = computed(() => (me.value.roles || []).join(' · '));
|
||
const loginForm = reactive({ phone: '', password: '' });
|
||
const loginBusy = ref(false), loginErr = ref('');
|
||
_onUnauth = () => { authed.value = false; };
|
||
const health = ref({}), ov = ref({}), params = ref([]), paramsRaw = ref({});
|
||
const catalog = ref([]), commands = ref([]), plans = ref([]), plansOf = ref('');
|
||
const positions = ref([]), lots = ref([]), lotsOf = ref('');
|
||
const instructions = ref([]), ledger = ref([]), proposals = ref([]);
|
||
const strategies = ref([]), stratEnabled = ref(false), opLog = ref([]);
|
||
const autoScanRes = ref(null), autoScanBusy = ref(false); // 自动挂载试算 (2026-08-25)
|
||
const autoInfo = ref(null); // 自动挂载仪表: 总开关 + 今天正式一跳跑没跑 (2026-08-26)
|
||
// 「显示已完成/已移除」开关 (默认关: 只看进行中)。命令与策略勾选时会拉全量含已归档 (loadX 传
|
||
// include_archived); 在途指令纯前端过滤, 不重拉。
|
||
const showAllCommands = ref(false), showAllStrategies = ref(false), showAllInstr = ref(false);
|
||
const showLedger = ref(false); // 「今天发生了什么」默认折叠
|
||
const showOpLog = ref(false); // 操作日志默认折叠
|
||
const showSettings = ref(false); // 设置默认折叠
|
||
const lastRefresh = ref('');
|
||
const autoRefresh = ref(true);
|
||
const upstream = ref({ sources: {} });
|
||
const showUpstream = ref(false); // 信号中心默认收起; 常驻速览带在持仓下方 (2026-08-26)
|
||
const showAlerts = ref(false);
|
||
const alertShowMap = reactive({ money_flow:true, capital:true, qrs:true, volume:true, multi:true, price_notice:false, buy_emitted:false, other:true });
|
||
// 级别筛选记忆: 存 sessionStorage —— 普通刷新保留, 关标签页/新开标签=回默认。默认关 普通/提示。
|
||
const LV_STORE_KEY = 'pms_level_show';
|
||
const _lvDefault = { DANGER:true, WARNING:true, INFO:false, NORMAL:false };
|
||
const _lvLoad = () => { try { const v = JSON.parse(sessionStorage.getItem(LV_STORE_KEY)); if (v && typeof v === 'object') return { ..._lvDefault, ...v }; } catch (e) {} return { ..._lvDefault }; };
|
||
const levelShow = reactive(_lvLoad());
|
||
const openScan = ref({ by_code: {}, notes: [] });
|
||
const insTab = ref('live');
|
||
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 } });
|
||
const report = ref({}), reportDrawer = ref(false), dm = ref({});
|
||
const opsDrawer = ref(false), opsResult = ref(''), opsLoading = ref(false);
|
||
// 上游选股计划: 单独取数, 不塞进 /api/overview —— overview 是轮询的, 上游不通时
|
||
// 会把 10 秒超时拖进每一次页面刷新。
|
||
const planDrawer = ref(false), planLoading = ref(false), planRaw = ref({}),
|
||
planResult = ref('');
|
||
const planStatus = computed(() => planRaw.value.status || {});
|
||
const planRows = computed(() => planRaw.value.rows || []);
|
||
const planCand = computed(() => planRaw.value.candidates_raw || null);
|
||
// 榜单变化 (PMS 自算 —— 上游 changes 字段恒为 null)。另起一个接口, 不塞进 /api/upstream/plan:
|
||
// 它要读库, 而计划预览要读上游, 两者的故障域不同, 混在一起会互相拖垮。
|
||
const chgRaw = ref({}), chgLoading = ref(false), chgTab = ref('held');
|
||
const chgSt = computed(() => chgRaw.value.changes || {});
|
||
const chg = computed(() => chgSt.value.diff || null);
|
||
const chgHeld = computed(() => (chg.value || {}).held || {});
|
||
const chgLog = computed(() => chgRaw.value.log || {});
|
||
const chgRevised = computed(() => Object.entries(chgLog.value.revised || {}));
|
||
// 掉榜/降档合成一张表 —— 「上游把这只票摘了或降了档」是同一个意思的两种说法
|
||
const chgWatch = computed(() => {
|
||
const d = chg.value; if (!d) return [];
|
||
// 三类都算「上游把这只票看淡了」: 掉榜 / 降档 / 掉进观察档(=没券商覆盖了, 估值锚没了)。
|
||
// 与 plan_diff 的 held.n_watch 口径一致 —— 两边分家的话红条会亮而表里没有对应行。
|
||
return (d.exited || []).map(x => ({ ...x, why: '掉榜' }))
|
||
.concat((d.tier_down || []).map(x => ({ ...x, why: '降档 ' + x.tier_from + '→' + x.tier_to })))
|
||
.concat((d.bucket_moved || []).filter(x => x.moved_to === 'observe')
|
||
.map(x => ({ ...x, why: '券商覆盖没了 (主榜→观察档)' })));
|
||
});
|
||
const issuing = ref(false);
|
||
const form = reactive({ cmd_type: '', params: {}, note: '' });
|
||
|
||
// ---- ws 通道监控 (只读)
|
||
const wsRaw = ref({}), wsAuto = ref(true), wsHidePong = ref(true);
|
||
let wsTimer = null;
|
||
// 总规模(用户命令参数) 与 账户实际总资产(ws 资金快照) 的偏差。三道仓位闸都按 scale 算,
|
||
// 资金校验按账户算 —— 差得远时方案会过闸但下不出去, 而页面上原来完全看不出来。
|
||
const scaleGap = computed(() => {
|
||
const ta = Number(ov.value.total_asset || 0), s = Number(ov.value.scale || 0);
|
||
if (!ta || !s) return 0;
|
||
const g = ta / s - 1;
|
||
return Math.abs(g) > 0.10 ? g : 0;
|
||
});
|
||
|
||
// 通道状态优先取 ws-channel (管理员接口), 取不到回落 dispatch-mode 里的 channel ——
|
||
// 纯交易员没有 ws-channel 权限, 原来「通道没连上」这类告警对他们永远不亮 (2026-08-28)。
|
||
const wsc = computed(() => wsRaw.value.channel || (dm.value || {}).channel || {});
|
||
const wst = computed(() => wsc.value.stat || {});
|
||
const wsMode = computed(() => wsRaw.value.mode || '');
|
||
const wsOrders = computed(() => wsRaw.value.orders || []);
|
||
const wsInbox = computed(() => (wsRaw.value.inbox || [])
|
||
.filter(r => !(wsHidePong.value && r.msg_type === 'pong')));
|
||
|
||
// 判据与 scripts/ws_smoke.py 的自检逐条对应。改一边就要改另一边 ——
|
||
// 页面说"正常"而命令行说"异常"(或反过来), 比两边都没有告警更能误导人。
|
||
const wsWarnings = computed(() => {
|
||
const c = wsc.value, s = wst.value, out = [];
|
||
if (!c.process_alive)
|
||
out.push('pms-ws 进程不在线 (心跳陈旧) —— dispatcher 会一律拒发。'
|
||
+ '起进程: docker compose --profile ws up -d pms-ws');
|
||
const hsSrv = +(s.hs_server_seq || 0), hsLast = +(s.hs_last_seq || 0);
|
||
if (hsSrv && hsSrv < hsLast)
|
||
out.push(`序号倒挂: 握手时对端自报 ${hsSrv}, 本端水位已到 ${hsLast}。对端此后发的每一条`
|
||
+ `(含成交)都会因「不高于水位」被静默丢弃。不要自行下调水位 —— 会把 `
|
||
+ `${hsSrv}..${hsLast} 重复入账。须先与 QMT 侧对齐 seq 语义`);
|
||
if (+(s.dropped || 0))
|
||
out.push(`已丢弃 ${s.dropped} 条上行 (seq 不高于水位)。补发时命中是正常的, `
|
||
+ `持续增长说明序号语义对不上`);
|
||
if (s.ack_seq_degraded)
|
||
out.push('ack_seq 已降级停发 (对端不认这个类型, 协议 §4.5)。水位照常落库、重连补发靠 '
|
||
+ 'hello.last_seq, 不丢成交; 代价是 QMT 侧 Redis 清不掉');
|
||
if (+(s.proto_rejects || 0))
|
||
out.push(`协议级 reject ${s.proto_rejects} 条 (不带 instruction_id, 拒的不是委托而是`
|
||
+ `我们发的协议消息)。若收发计数同步增长, 基本是「ack→reject→再 ack」的自激循环`);
|
||
if (s.peer_skew_ms != null && Math.abs(+s.peer_skew_ms) > 20000)
|
||
out.push(`对端时钟偏差 ${s.peer_skew_ms} ms, 已逼近 QMT 侧 ±30 秒时间窗。再漂下去连 `
|
||
+ `place_order 都会被 TS_SKEW 拒, 现象是「下单没反应」。两台机器都对一下 NTP`);
|
||
if (c.resync_required)
|
||
out.push('resync_flag=1: 对端补发不全, 须走全量对账后经运维抽屉清除标记');
|
||
if (c.error) out.push(c.error);
|
||
return out;
|
||
});
|
||
|
||
const curSpec = computed(() => catalog.value.find(c => c.cmd_type === form.cmd_type));
|
||
const dirtyCount = computed(() =>
|
||
params.value.filter(p => String(p.value) !== String(paramsRaw.value[p.key])).length);
|
||
|
||
const money = v => (v === null || v === undefined || v === '') ? '—'
|
||
: Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
|
||
const pct = v => (v === null || v === undefined || v === '') ? '—'
|
||
: (Number(v) * 100).toFixed(2) + '%';
|
||
const groupLabel = g => ({ A: 'A 资金与总体参数', B: 'B 组合级动作', C: 'C 个股级动作' }[g]);
|
||
const fieldLabel = n => ({
|
||
pct: '比例', scale: '总规模', cap: '上限', target: '目标', n: '数量', mode: '档位',
|
||
ratio: '比例', window_tdays: '窗口(交易日)', ts_code: '股票代码', target_pct: '目标仓位',
|
||
sector: '行业', t_ratio: 'T仓比例', price: '价格', confirm: '二次确认',
|
||
target_command_id: '目标命令号'
|
||
}[n] || n);
|
||
const stTag = s => ({ EXECUTING: 'primary', DONE: 'success', PARTIAL: 'warning',
|
||
CANCELLED: 'info', EFFECTIVE: 'success', SUPERSEDED: 'info' }[s] || 'warning');
|
||
const cuTag = s => ({ SOLID: 'info', THIN: 'warning', NONE: 'warning' }[s] || 'info');
|
||
const canCancel = r => r.cmd_class === 'task' &&
|
||
['PENDING', 'PLANNING', 'EXECUTING', 'PARTIAL'].includes(r.status);
|
||
const progPct = p => {
|
||
const t = Number(p.target_amount || 0), d = Number(p.done_amount || 0);
|
||
return t > 0 ? Math.min(100, Math.round(d / t * 100)) : 0;
|
||
};
|
||
const insPct = r => {
|
||
const q = Number(r.qty || 0), e = Number(r.exec_qty || 0);
|
||
return q > 0 ? Math.min(100, Math.round(e / q * 100)) : 0;
|
||
};
|
||
// ===================== 交易员视图:翻译层 + 名称 + 动作 =====================
|
||
const T = {
|
||
action: { OPEN:'建新仓', FILL:'回落补足', ADD:'盈利加仓', DCA:'浮亏补仓', TRIM:'保盈减仓',
|
||
EXIT:'清仓', T0:'做T', T0_ROUND:'做T', SWITCH:'换仓', HALT:'停手' },
|
||
side: { buy:'买入', sell:'卖出' },
|
||
autonomy: { full:'全自动', propose_only:'只提议,等你拍板', off:'暂停' },
|
||
cushion: { SOLID:'厚垫', THIN:'薄垫', NONE:'浮亏无垫' },
|
||
lot: { BASE:'首批', FILL:'回落补足', ADD:'盈利加仓', DCA:'浮亏补仓', T0:'做T仓', RECON:'对账修正' },
|
||
frozen: { NONE:'', COMMAND_HALT:'命令要求只卖不买', BRAKE:'组合刹车,只卖不买', MANUAL:'你设的只卖不买' },
|
||
posStatus: { PLANNED:'计划建仓', OPENING:'建仓中', HOLDING:'持有中', EXITING:'清仓中', CLOSED:'已清仓' },
|
||
insStatus: { PROPOSED:'等着校验', RULE_PASSED:'合规检查已过', JUDGE_PASSED:'决策系统复核已过', DISPATCHED:'已挂单,等成交',
|
||
CONFIRMED:'已成交', REJECTED:'被挡下', EXPIRED:'到期作废', CANCELLED:'已撤销' },
|
||
arbiter: { rule:'合规检查', judge:'决策系统', user:'你' },
|
||
verdict: { PASS:'放行', REJECT:'驳回', NOTE:'记一笔', UNAVAILABLE:'决策系统现在读不到' },
|
||
// 参考位这一列的数是谁给的。系统每个交易日盘前重算一遍并把整列重写, 所以它基本上
|
||
// 永远是系统口径; 用户设的止损价只在下达当天投影到这一列上, 第二天早上就换回系统值了。
|
||
// 用户自己设的两个价另有「我设的」一列, 那一列读命令表, 不会被盖。
|
||
refSource: { bionic:'决策系统给的', self_calc:'系统自算', user:'你今天设的', none:'取不到' },
|
||
stype: { T0:'做T', GRID:'网格', TRAIL:'跟踪止盈' },
|
||
stratStatus: { ACTIVE:'运行中', PAUSED:'已暂停', CANCELLED:'已撤下', DONE:'已完成' },
|
||
};
|
||
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 normCode = c => {
|
||
// mtf 用 SH688662/SZ300404 前缀格式, 名称库是 688662.SH 点分格式; 归一后才查得到名字。
|
||
const m = String(c || '').match(/^(SH|SZ|BJ)(\d{6})$/i);
|
||
return m ? (m[2] + '.' + m[1].toUpperCase()) : String(c || '');
|
||
};
|
||
const nm = c => { const k = normCode(c); return (nameMap.value[k] && nameMap.value[k].name) || c; };
|
||
const fullName = c => { const k = normCode(c); return (nameMap.value[k] && nameMap.value[k].full) || ''; };
|
||
async function loadNames() {
|
||
const set = new Set();
|
||
const push = c => { if (c) set.add(normCode(c)); };
|
||
(positions.value || []).forEach(p => push(p.ts_code));
|
||
(proposals.value || []).forEach(p => push(p.ts_code));
|
||
(instructions.value || []).forEach(p => push(p.ts_code));
|
||
(ledger.value || []).forEach(p => push(p.ts_code));
|
||
// 上游信号面板的代码原来没送去查名 —— 只有恰好在持仓里的才带名。这里一并收进来 (含 mtf 前缀码归一)。
|
||
const up = (upstream.value && upstream.value.sources) || {};
|
||
((up.alerts && up.alerts.items) || []).forEach(x => push(x.ts_code));
|
||
((up.intraday && up.intraday.decision) || []).forEach(x => push(x.ts_code));
|
||
((up.intraday && up.intraday.timing) || []).forEach(x => push(x.ts_code));
|
||
((up.metrics && up.metrics.items) || []).forEach(x => push(x.ts_code));
|
||
((up.sell_actions && up.sell_actions.items) || []).forEach(x => push(x.ts_code));
|
||
((up.mr && up.mr.watch) || []).forEach(x => push(x.ts_code));
|
||
((up.mr && up.mr.avoid) || []).forEach(x => push(x.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 重构新增) ----
|
||
const todayYmd = computed(() => (String(health.value.now || '').slice(0, 10).replace(/-/g, '')) || '');
|
||
const todayDateStr = computed(() => String(health.value.now || '').slice(0, 10));
|
||
// 「今天发生了什么 / 这只票今天的记录」只收 decided_at 是今天的行 (2026-08-28):
|
||
// /api/ledger 按 id 倒序取近 60 条不带日期条件, 当天记录少时列表被前几天的行填满,
|
||
// 主面板又只显示时分 —— 昨天 14:31 的驳回看起来就是今天 14:31 发生的。
|
||
const ledgerToday = computed(() => {
|
||
const t = todayDateStr.value;
|
||
if (!t) return ledger.value || [];
|
||
return (ledger.value || []).filter(l => String(l.decided_at || '').slice(0, 10) === t);
|
||
});
|
||
// 是否处于连续交易时段 (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 ? '盘中' : '休市'));
|
||
const _LIVE_INS = ['PROPOSED', 'RULE_PASSED', 'JUDGE_PASSED', 'DISPATCHED'];
|
||
const _isArch = r => !!(r && r.archived_at); // 软归档: 有 archived_at = 已从视图移除 (行仍在库)
|
||
const _sameDayIns = r => { const y = todayYmd.value;
|
||
const id = String(r.instruction_id || '');
|
||
// 策略指令的前缀是 STR{ymd} (strategy_runner), 不是 INS_ —— 原来它们一进终态就从
|
||
// 「今日成交/已经结束」和消息栏同时消失, 交易员眼看着自己的单子凭空不见 (2026-08-28)
|
||
return !y || id.indexOf('INS_' + y + '_') === 0 || id.indexOf('STR' + y) === 0; };
|
||
// 已归档的一律不进「今日在办」三档 —— 归档=从所有面板隐藏。
|
||
const insLive = computed(() => (instructions.value || []).filter(r => _LIVE_INS.includes(r.status) && !_isArch(r)));
|
||
const insDone = computed(() => (instructions.value || []).filter(r => r.status === 'CONFIRMED' && _sameDayIns(r) && !_isArch(r)));
|
||
const insEnd = computed(() => (instructions.value || []).filter(r => ['EXPIRED', 'CANCELLED', 'REJECTED'].includes(r.status) && _sameDayIns(r) && !_isArch(r)));
|
||
// 在办/在途/策略列表: 默认只看进行中; 「显示已完成」开关放开终态。命令与策略开关还会拉出已归档 (带恢复按钮)。
|
||
const _CMD_ACTIVE = ['PENDING', 'PLANNING', 'EXECUTING', 'PARTIAL', 'EFFECTIVE'];
|
||
const _STRAT_ACTIVE = ['ACTIVE', 'PAUSED'];
|
||
const commandsShown = computed(() => (commands.value || []).filter(
|
||
r => showAllCommands.value ? true : (_CMD_ACTIVE.includes(r.status) && !_isArch(r))));
|
||
const strategiesShown = computed(() => (strategies.value || []).filter(
|
||
r => showAllStrategies.value ? true : (_STRAT_ACTIVE.includes(r.status) && !_isArch(r))));
|
||
const instrLive = computed(() => (instructions.value || []).filter(
|
||
r => showAllInstr.value ? true : (_LIVE_INS.includes(r.status) && !_isArch(r))));
|
||
// 哪些行可「移除」(只终态): 与后端 archive_* 的终态集一致。
|
||
const cmdArchivable = r => ['DONE', 'CANCELLED', 'SUPERSEDED'].includes(r.status);
|
||
const stratArchivable = r => ['CANCELLED', 'DONE'].includes(r.status);
|
||
// 每只候选今天为什么下单/没下单: 已落地的事实(指令/提议)优先, 其余看 /api/open-scan 的只读处置。
|
||
function dispOf(code) {
|
||
const inst = (instructions.value || []).find(i => i.ts_code === code && i.action === 'OPEN'
|
||
&& (_LIVE_INS.includes(i.status) || _sameDayIns(i))); // 旧的终态单不算「已下单」(2026-08-28)
|
||
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: '' };
|
||
}
|
||
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));
|
||
const watchCandidates = computed(() => (planRows.value || []).filter(r =>
|
||
['would', 'wait'].includes(dispOf(r.ts_code).cls)).slice(0, 20));
|
||
|
||
const settingKeys = ['PMS_TOTAL_SCALE', 'PMS_PORTFOLIO_CAP', 'PMS_STOCK_CAP',
|
||
'PMS_MAX_NAMES', 'PMS_CASH_RESERVE',
|
||
'PMS_DCA_MAX_RATIO', 'PMS_DCA_TRIGGERS', 'PMS_DCA_DEEP_CONFIRM',
|
||
'PMS_FILL_MAX_LOSS', 'PMS_AUTONOMY'];
|
||
const traderParams = computed(() => {
|
||
const ix = {}; settingKeys.forEach((k, i) => ix[k] = i);
|
||
return (params.value || []).filter(p => settingKeys.includes(p.key)).sort((a, b) => ix[a.key] - ix[b.key]);
|
||
});
|
||
|
||
const attention = computed(() => {
|
||
const o = ov.value || {}, out = [];
|
||
if (o.buy_halt) out.push('买入已经暂停,卖出和止损照常');
|
||
if (o.exec_halt) out.push('休假模式:系统的自动动作全停了,只留每天的对账和日报');
|
||
if (o.brake_until) out.push('组合刹车中,连续亏损后系统自己停了手,' + o.brake_until + ' 解除');
|
||
if (marketOpen.value && (o.price_missing || []).length)
|
||
out.push(o.price_missing.length + ' 只取不到实时价(停牌或者行情断了),它们的盈亏和安全垫暂时算不出来');
|
||
if (o.sector_ready === false) out.push('行业数据还没就绪,按行业的操作暂时点不动');
|
||
const j = (dm.value || {}).judge;
|
||
if (j && j.available === false) out.push('决策系统现在读不到:回落补足、盈利加仓、浮亏补仓一律改成要你拍板');
|
||
const c = wsc.value || {};
|
||
if (c.conn_state && c.conn_state !== 'ONLINE') out.push('行情和成交的通道没连上(' + c.conn_state + ')');
|
||
if (c.resync_required) out.push('通道要人工做一次全量对账');
|
||
return out;
|
||
});
|
||
|
||
function scrollTo(id) { const e = document.getElementById(id); if (e) e.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
|
||
function propWhy(p) {
|
||
const h = p.hard_numbers || {}, bits = [];
|
||
// 键名是 plan_rank (动作引擎的硬数字里从来没有 rank 这个键, 2026-09-03 修); 更早的旧提议兜底看 rank
|
||
const rk = (h.plan_rank != null) ? h.plan_rank : h.rank;
|
||
if (rk != null) bits.push('候选榜第 ' + rk);
|
||
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(' · ') || '—';
|
||
}
|
||
// 选股系统候选卡的理由 (硬数字 reasons, 后端已截到四条), 提议卡上只显示前两条
|
||
function propReasons(p) {
|
||
const r = (p.hard_numbers || {}).reasons;
|
||
return Array.isArray(r) ? r.slice(0, 2) : [];
|
||
}
|
||
// 研究理由 (2026-09-03): 判决依据是上游对这条判决的一句话解释; 因果论断是研报里说这家
|
||
// 公司好在哪, 每行自带「——出处:《研报标题》 披露日」。卡片窄, 只摆第一条论断,
|
||
// 但那一条必须原样显示到出处与日期为止 —— 一条 2024 年的论断与一条上周的, 人得一眼分得出。
|
||
function propBasis(p) { return (p.hard_numbers || {}).basis || ''; }
|
||
function propLogic(p) {
|
||
const l = (p.hard_numbers || {}).logic;
|
||
return Array.isArray(l) && l.length ? l[0] : '';
|
||
}
|
||
function propLogicMore(p) {
|
||
const l = (p.hard_numbers || {}).logic;
|
||
return Array.isArray(l) && l.length > 1 ? (l.length - 1) : 0;
|
||
}
|
||
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; }
|
||
}
|
||
const quickBusy = ref(false); // 防重复下达: 双击会发出两条相同的资金命令 (2026-08-28)
|
||
async function quickCmd(cmd_type, params, confirmMsg) {
|
||
if (quickBusy.value) { ElementPlus.ElMessage.info('上一条命令还在下达,请稍候'); return; }
|
||
quickBusy.value = true;
|
||
try {
|
||
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 && String(d.status || '') === 'CANCELLED') {
|
||
// 后端对「该产方案却一条没产」的命令回 ok:true + status=CANCELLED ——
|
||
// 原来只看 ok 报「已下达」, 用户以为风险在削减, 实际命令已自我作废 (2026-08-28)
|
||
const why = ((d.plan || {}).reject_summary) || ((d.plan || {}).notes || []).join(';')
|
||
|| '没有产出任何可执行的方案';
|
||
ElementPlus.ElMessage.warning('命令没有生效(已自动作废):' + why);
|
||
await loadAll();
|
||
} else if (d.ok) {
|
||
ElementPlus.ElMessage.success('已下达 ' + (d.command_id || '')); await loadAll();
|
||
} else {
|
||
ElementPlus.ElMessage.error((d.errors || [d.error]).join('; '));
|
||
}
|
||
} finally { quickBusy.value = false; }
|
||
}
|
||
// 个股
|
||
function actExitStock(row) { quickCmd('EXIT_STOCK', { ts_code: row.ts_code }, '清仓 ' + nm(row.ts_code) + '?系统会在允许下单的时间段里挑时机把它卖光。'); }
|
||
async function actReduceStock(row) {
|
||
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));
|
||
if (v != null) quickCmd('REDUCE_STOCK', { ts_code: row.ts_code, target_pct: v + '%' });
|
||
}
|
||
// 弹框里先把「你现在设的是多少」摆出来, 再让人填新值 —— 不写的话只能凭记忆改价。
|
||
// 读的是 user_stop_price / user_target_price (来自命令表), 不是持仓行上的 stop_ref。
|
||
const curSet = v => (v != null ? '你现在设的是 ' + v + ' 元。' : '你还没设过。');
|
||
async function actStop(row) {
|
||
const v = await promptNum(curSet(row.user_stop_price)
|
||
+ '现价 ' + row.price + ',成本 ' + (row.avg_cost || '—')
|
||
+ '。止损价只做提醒:跌破了会在合规检查里留一条记录,系统不会替你挂单卖出。'
|
||
+ '真要卖请下清仓或减持命令。填多少元?', '设止损 ' + nm(row.ts_code));
|
||
if (v != null) quickCmd('SET_STOP_PRICE', { ts_code: row.ts_code, price: v });
|
||
}
|
||
async function actTargetPrice(row) {
|
||
const v = await promptNum(curSet(row.user_target_price)
|
||
+ '现价 ' + row.price + ',成本 ' + (row.avg_cost || '—')
|
||
+ '。目标价(元):到价后生成一条清仓提议放进「等我拍板」,卖不卖由你点头,'
|
||
+ '系统不自动卖。不想要了就再设一次、填 0 即取消。', '设目标价 ' + nm(row.ts_code));
|
||
if (v != null) quickCmd('SET_TARGET_PRICE', { ts_code: row.ts_code, price: v });
|
||
}
|
||
function actFreeze(row) {
|
||
const on = row.frozen_reason && row.frozen_reason !== 'NONE';
|
||
quickCmd(on ? 'UNFREEZE_STOCK' : 'FREEZE_STOCK', { ts_code: row.ts_code });
|
||
}
|
||
function actBlacklist(row) { quickCmd('BLACKLIST_ADD', { ts_code: row.ts_code }, '把 ' + nm(row.ts_code) + ' 加入黑名单(永不买入)?'); }
|
||
function rowMore(c, row) {
|
||
if (c === 'stop') actStop(row);
|
||
else if (c === 'target') actTargetPrice(row);
|
||
else if (c === 'freeze') actFreeze(row);
|
||
else if (c === 'black') actBlacklist(row);
|
||
else if (c === 'strategy') openStrategy(row);
|
||
}
|
||
// 组合
|
||
function pHaltBuy() { quickCmd('HALT_BUY', {}, '暂停一切新增买入并撤销在途买入指令?卖出与止损不受影响。'); }
|
||
function pResumeBuy() { quickCmd('RESUME_BUY', {}); }
|
||
function pHaltAll() { quickCmd('HALT_ALL', {}, '进入休假模式:所有自动动作暂停,只保留对账与日报?'); }
|
||
function pResumeAll() { quickCmd('RESUME_ALL', {}); }
|
||
function pLiquidate() { quickCmd('LIQUIDATE_ALL', { confirm: 'YES' }, '一键清仓:全部持仓按最快速度卖出,不再挑时间点。确定要这么做吗?'); }
|
||
async function pReduce() { const v = await promptNum('降低敞口(占总规模,%)', '降仓'); if (v != null) quickCmd('REDUCE_EXPOSURE', { pct: v + '%' }); }
|
||
async function pIncrease() { const v = await promptNum('提高敞口(占总规模,%)', '升仓'); if (v != null) quickCmd('INCREASE_EXPOSURE', { pct: v + '%' }); }
|
||
async function pSectorExit() {
|
||
let name; try { name = (await ElementPlus.ElMessageBox.prompt('要清仓的行业名称', '清仓某行业', {})).value; } catch (e) { return; }
|
||
if (name) quickCmd('SECTOR_EXIT', { sector: name }, '清仓行业「' + name + '」的全部持仓?');
|
||
}
|
||
// ---- 组合仓位调整: 显示当前 + 算出金额, 不盲填 ----
|
||
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') || '—')));
|
||
// ===================== /交易员视图 =====================
|
||
|
||
|
||
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) {
|
||
const st = e && e.response && e.response.status;
|
||
if (st === 401) { authed.value = false; return { ok: false, error: '未登录' }; }
|
||
if (st === 403) {
|
||
const m = (e.response && e.response.data && e.response.data.error) || '无权限: 需要系统管理员';
|
||
ElementPlus.ElMessage.error(m); return { ok: false, error: m };
|
||
}
|
||
err.value = url + ' → ' + e.message; return { ok: false, error: e.message };
|
||
}
|
||
}
|
||
|
||
// ---- 登录闭环 (2026-08 加权限)
|
||
async function checkMe() {
|
||
try {
|
||
const d = (await api.get('/api/me')).data || {};
|
||
if (d.ok) {
|
||
me.value = { phone: d.user.phone, username: d.user.username, roles: d.roles || [] };
|
||
authed.value = true;
|
||
if (!isAdmin.value && mode.value === 'ops') mode.value = 'trader';
|
||
} else { authed.value = false; }
|
||
} catch (e) { authed.value = false; }
|
||
authReady.value = true;
|
||
}
|
||
async function doLogin() {
|
||
loginErr.value = '';
|
||
if (!loginForm.phone || !loginForm.password) { loginErr.value = '手机号和密码都要填'; return; }
|
||
loginBusy.value = true;
|
||
try {
|
||
const d = (await api.post('/api/auth/login',
|
||
{ phone: loginForm.phone, password: loginForm.password })).data || {};
|
||
if (d.ok) { loginForm.password = ''; await checkMe(); loadAll(); loadPlan(); startAutoRefresh(); }
|
||
else { loginErr.value = d.error || '登录失败'; }
|
||
} catch (e) {
|
||
loginErr.value = (e.response && e.response.data && e.response.data.error)
|
||
|| ('登录失败: ' + e.message);
|
||
} finally { loginBusy.value = false; }
|
||
}
|
||
async function doLogout() {
|
||
try { await api.post('/api/auth/logout', {}); } catch (e) {}
|
||
authed.value = false;
|
||
me.value = { phone: '', username: '', roles: [] };
|
||
}
|
||
|
||
// 公示表导出 (2026-08-28): 直接开新页下载, 登录票在 cookie 里自然带上。
|
||
// 后端失败按站内惯例回 JSON (HTTP 200), 新页会显示错误原因, 不做弹窗翻译。
|
||
function doExport() { window.open('/api/export/publish-xlsx', '_blank'); }
|
||
|
||
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 });
|
||
if (d.ok === false && !d.results) {
|
||
// 传输层失败 (超时/断网): 没有 results 不等于全成功 —— 原来这里报「已保存」
|
||
// 还立刻用服务器旧值把你刚改的输入冲掉 (2026-08-28)。保留输入, 由你重试。
|
||
ElementPlus.ElMessage.error('保存失败(网络或服务不可用):' + (d.error || '') + ',你的修改还在输入框里,请重试');
|
||
return;
|
||
}
|
||
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 q = '/api/commands?limit=60' + (showAllCommands.value ? '&include_archived=1' : '');
|
||
const d = await call('get', q); commands.value = d.data || [];
|
||
}
|
||
async function loadPositions() {
|
||
const d = await call('get', '/api/positions'); positions.value = d.positions || [];
|
||
}
|
||
async function loadInstructions() {
|
||
const d = await call('get',
|
||
'/api/instructions?status=PROPOSED,RULE_PASSED,JUDGE_PASSED,DISPATCHED,CONFIRMED,EXPIRED,CANCELLED,REJECTED&limit=200');
|
||
instructions.value = d.data || [];
|
||
}
|
||
async function loadLedger() {
|
||
const d = await call('get', '/api/ledger?limit=60'); ledger.value = d.data || [];
|
||
}
|
||
async function loadProposals() {
|
||
const d = await call('get', '/api/proposals'); proposals.value = d.data || [];
|
||
}
|
||
async function loadOpenScan() {
|
||
const d = await call('get', '/api/open-scan');
|
||
openScan.value = (d && d.by_code) ? d : { by_code: {}, notes: [] };
|
||
}
|
||
const usrc = k => (upstream.value.sources || {})[k];
|
||
const alertItems = computed(() => { const a = usrc('alerts'); return (a && Array.isArray(a.items)) ? a.items : []; });
|
||
// 级别筛选 (纯前端): 级别是「信号强度分档」不是方向。DANGER 是最强、最该看的那一档,
|
||
// 决策系统甚至把它转述成「极值偏离」; 盘中择时层与决策系统两套来源口径一致。原来显示成
|
||
// 「危险」是误译 —— 一条 DANGER 级看涨信号其实是很强的买入信号, 标红会被误读成会亏。
|
||
// 2026-08-25: DANGER 显示为「重要」并用蓝色强调色; 空的 IMPORTANT 并入重要档 (真实数据
|
||
// 里两套系统都不产出 IMPORTANT, 它是永远匹配不到的空档)。已知级别按开关过滤, 未知放行。
|
||
const normLv = v => { const x = String(v == null ? '' : v); return x === 'IMPORTANT' ? 'DANGER' : x; };
|
||
const KNOWN_LV = new Set(['DANGER','WARNING','INFO','NORMAL']);
|
||
const levelShown = k => levelShow[k] !== false;
|
||
function toggleLevel(k){ levelShow[k] = !levelShown(k); try { sessionStorage.setItem(LV_STORE_KEY, JSON.stringify(levelShow)); } catch (e) {} }
|
||
// 风控卖出: 每只股票的详情(卖出理由)默认折叠, 点该行展开这一只 (key = 代码_序号)。
|
||
const sellOpen = reactive({});
|
||
function toggleSell(k){ sellOpen[k] = !sellOpen[k]; }
|
||
const passLevel = lv => { const x = normLv(lv); return !KNOWN_LV.has(x) || levelShow[x] !== false; };
|
||
const alertItemsF = computed(() => alertItems.value.filter(a => passLevel(a.level)));
|
||
const levelCount = k => alertItems.value.reduce((n, a) => n + (normLv(a.level) === k ? 1 : 0), 0);
|
||
const ALERT_LEVELS = [
|
||
{ key:'DANGER', label:'重要', cls:'strong' }, { key:'WARNING', label:'警告', cls:'warn' },
|
||
{ key:'INFO', label:'提示', cls:'info' }, { key:'NORMAL', label:'普通', cls:'info' }];
|
||
const LV_LABEL = { DANGER:'重要', WARNING:'警告', IMPORTANT:'重要', INFO:'提示', NORMAL:'普通' };
|
||
const lvLabel = v => LV_LABEL[normLv(v)] || v || '—';
|
||
const lvClass = v => { const x = normLv(v); return x === 'DANGER' ? 'strong' : (x === 'WARNING' ? 'queue' : 'wait'); };
|
||
const flowText = d => d === 'inflow' ? '流入' : (d === 'outflow' ? '流出' : (d || '—'));
|
||
const flowClass = d => d === 'inflow' ? 'clr-up' : (d === 'outflow' ? 'clr-down' : 'muted');
|
||
// ── 上游信号改版 (2026-08-18): 净额领读 + 强度带 + 按重要度分档 + 待触发条 ──
|
||
// 净额折成万/亿 (带正负号); 原始 z 值移进悬浮提示, 正文不堆数字。
|
||
const moneyCn = v => {
|
||
if (v === null || v === undefined || v === '') return '—';
|
||
const n = Number(v); if (!isFinite(n)) return '—';
|
||
const s = n < 0 ? '-' : '+', a = Math.abs(n);
|
||
if (a >= 1e8) return s + (a / 1e8).toFixed(2) + '亿';
|
||
if (a >= 1e4) return s + Math.round(a / 1e4) + '万';
|
||
return s + Math.round(a);
|
||
};
|
||
// 强度带: 横截面异动 z_dd 绝对值分档。显著(2–3) / 强(3–5) / 极强(≥5)。
|
||
const zBand = z => {
|
||
const a = Math.abs(Number(z) || 0);
|
||
if (a >= 5) return { label: '极强', on: 4 };
|
||
if (a >= 3) return { label: '强', on: 3 };
|
||
if (a >= 2) return { label: '显著', on: 2 };
|
||
return { label: '一般', on: 1 };
|
||
};
|
||
const zBars = z => { const on = zBand(z).on; return [1, 2, 3, 4].map(i => i <= on); };
|
||
const dirUp = d => ['看涨', '涨', 'inflow', '流入'].includes(d);
|
||
// 信号方向配色 (2026-08-28): A股口径 —— 买入/看涨/流入类一律红, 卖出/看跌/流出类一律绿。
|
||
// 原来模板里全写 dirUp(x)?'clr-up':'clr-down', 而词表里没有「买入/卖出」: 买入落到
|
||
// else 分支被涂成绿色, 方向色恰好用反。认不出的词给中性灰, 不硬猜方向。
|
||
const DIR_UP_SET = new Set(['看涨', '涨', 'inflow', '流入', '买入', '建议买入', 'BUY', '转多']);
|
||
const DIR_DOWN_SET = new Set(['看跌', '跌', 'outflow', '流出', '卖出', '建议卖出', 'SELL', '减仓', '清仓']);
|
||
const dirCls = d => DIR_UP_SET.has(String(d || '')) ? 'clr-up'
|
||
: (DIR_DOWN_SET.has(String(d || '')) ? 'clr-down' : 'muted');
|
||
// 把握值统一显示成百分比: 上游有的发 0.72、有的发 72, 折成同一口径, 不再出现「72(满分 1)」。
|
||
const confPct = v => { const n = Number(v); if (v == null || v === '' || !isFinite(n)) return null;
|
||
return Math.round((n > 1 ? n / 100 : n) * 100) + '%'; };
|
||
// 一处组装: 各信号当前条目 (告警按级别筛选后的口径, 与筛选条联动)。
|
||
// 已下线的 volume(放量异动) 不再单列 —— 若流里还有历史残留键, 归入 other。
|
||
const SIG = computed(() => {
|
||
const up = upstream.value.sources || {};
|
||
const by = {}; for (const a of alertItemsF.value) (by[a.cat] || (by[a.cat] = [])).push(a);
|
||
const KNOWN = new Set(['money_flow', 'capital', 'qrs', 'multi', 'price_notice']);
|
||
const other = []; Object.keys(by).forEach(k => { if (!KNOWN.has(k)) other.push(...(by[k] || [])); });
|
||
return {
|
||
decision: ((up.intraday || {}).decision) || [], timing: ((up.intraday || {}).timing) || [],
|
||
sell: ((up.sell_actions || {}).items) || [], multi: by.multi || [],
|
||
metrics: ((up.metrics || {}).items) || [], qrs: by.qrs || [], capital: by.capital || [],
|
||
money_flow: by.money_flow || [], price_notice: by.price_notice || [],
|
||
mr: up.mr || { watch: [], avoid: [] }, other,
|
||
};
|
||
});
|
||
// 改版二 (2026-08-18): 六个常用框固定成对常驻; 四个稀有/条件触发信号进"事件"区,
|
||
// 触发了出卡、没触发的收进一条待触发条。money_flow(资金流强度)频繁为空, 归入事件区。
|
||
const EVENT_SIGS = [
|
||
{ key: 'sell', label: '决策系统建议卖出' }, { key: 'multi', label: '多个来源同时报警' },
|
||
{ key: 'timing', label: '买入时机' }, { key: 'money_flow', label: '资金流强度' }];
|
||
const eventsQuiet = computed(() => EVENT_SIGS.filter(e => !SIG.value[e.key].length));
|
||
// 告警固定分类格子: 6 类常驻 + 其他(catch-all), 永远这几个框、空的显示暂无 —— 不再随数据冒出冒消。
|
||
const ALERT_CATS_FIXED = [
|
||
{ key:'money_flow', label:'资金流强度' }, { key:'capital', label:'资金分布' }, { key:'qrs', label:'日线形态' },
|
||
{ key:'volume', label:'放量异动' }, { key:'multi', label:'多个来源同时报警' }, { key:'price_notice', label:'股价异动' }];
|
||
const alertGridFixed = computed(() => {
|
||
const by = {};
|
||
for (const a of alertItemsF.value) { (by[a.cat] || (by[a.cat] = [])).push(a); }
|
||
const fixed = new Set(ALERT_CATS_FIXED.map(c => c.key));
|
||
const boxes = ALERT_CATS_FIXED.map(c => ({ key:c.key, label:c.label, items: by[c.key] || [] }));
|
||
const other = [];
|
||
Object.keys(by).forEach(k => { if (!fixed.has(k)) other.push(...(by[k] || [])); });
|
||
boxes.push({ key:'other', label:'其他', items: other });
|
||
return boxes;
|
||
});
|
||
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 };
|
||
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(() => alertItemsF.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-26 重设计): 交易员要随时看到的不是整个信号中心, 而是
|
||
// 「持仓票的信号 + 全场重要/警告级」。持仓票置顶; 决策系统买卖/风控卖出/择时
|
||
// 入场三路独立信号也并进来, 免得只看告警漏了真正会动手的那几条。 ──
|
||
const code6 = s => (String(s || '').match(/\d{6}/) || [''])[0];
|
||
const heldCodeSet = computed(() => new Set(heldPositions.value.map(p => code6(p.ts_code))));
|
||
// 今天选股计划里的票 (上游 /plan 主榜, 登录时随 loadPlan 加载): 速览里没持仓的
|
||
// 信号若命中计划, 挂「计划」标 —— 交易员一眼分清哪些噪音其实是候选池的动静 (2026-08-31)
|
||
const planCodeSet = computed(() => new Set((planRows.value || []).map(r => code6(r.ts_code))));
|
||
const sigBrief = computed(() => {
|
||
const held = heldCodeSet.value;
|
||
const out = [];
|
||
for (const a of alertItems.value) {
|
||
const isHeld = held.has(code6(a.ts_code));
|
||
if (!isHeld && !['DANGER', 'WARNING'].includes(normLv(a.level))) continue;
|
||
out.push({ ts_code: a.ts_code, time: a.time || '', cat: a.cat_label || a.cat,
|
||
direction: a.direction, level: a.level, held: isHeld, stale: a.stale });
|
||
}
|
||
const s = SIG.value;
|
||
for (const r of (s.sell || [])) out.push({
|
||
ts_code: r.ts_code, time: r.time || '', cat: '建议卖出',
|
||
direction: '卖出', level: 'DANGER', held: held.has(code6(r.ts_code)),
|
||
ymd: r.ymd || '', today: r.today !== false });
|
||
for (const r of (s.decision || [])) out.push({
|
||
ts_code: r.ts_code, time: r.time || '', cat: '决策系统',
|
||
direction: r.action === 'SELL' ? '卖出' : '买入', level: 'WARNING',
|
||
held: held.has(code6(r.ts_code)) });
|
||
for (const r of (s.timing || [])) out.push({
|
||
ts_code: r.ts_code, time: r.time || '', cat: '买入时机',
|
||
direction: '买入', level: 'INFO', held: held.has(code6(r.ts_code)) });
|
||
const inPlan = planCodeSet.value;
|
||
for (const g of out) g.plan = inPlan.has(code6(g.ts_code));
|
||
out.sort((a, b) => (b.held - a.held) || String(b.time).localeCompare(String(a.time)));
|
||
return out;
|
||
});
|
||
// 「今天的信号」列与持仓角标都只数今天的 (2026-08-28): 昨天的风控卖出不再冒充今天
|
||
const sigBriefHeldCount = computed(() => sigBrief.value.filter(x => x.held && x.today !== false).length);
|
||
const heldSigMap = computed(() => {
|
||
const m = {};
|
||
for (const x of sigBrief.value) {
|
||
if (!x.held || x.today === false) continue;
|
||
const k = code6(x.ts_code);
|
||
(m[k] || (m[k] = [])).push(x);
|
||
}
|
||
return m;
|
||
});
|
||
function heldSigOf(code) { return heldSigMap.value[code6(code)] || []; }
|
||
function openSigCenter() { showUpstream.value = true; scrollTo('t-sig'); }
|
||
const alertLevelClass = computed(() => {
|
||
const it = alertStripItems.value;
|
||
if (it.some(x => normLv(x.level) === 'DANGER')) return 'strong';
|
||
if (it.some(x => x.level === 'WARNING')) return 'warn';
|
||
return 'none';
|
||
});
|
||
// 告警分钟龄文案 + 陈旧行置灰。龄由后端按 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' : '';
|
||
// —— 宏观择时 (MACRO_TIMING_PLAN.md): 面板取数 / 手动扫描 / 建议采纳 ——
|
||
const macro = ref({});
|
||
const macroGateOn = computed(() => !!(((macro.value || {}).gate || {}).active));
|
||
const macroUnavailable = computed(() => {
|
||
const m = macro.value || {};
|
||
if (m.enabled === false) return '';
|
||
const bad = (m.signals || []).filter(s => s.zone === 'UNAVAILABLE');
|
||
if (!bad.length) return '';
|
||
return bad.map(s => (s.label || s.key) + (s.note ? (':' + s.note) : '')).join(';');
|
||
});
|
||
function mzLabel(z) { return { HOT: '偏热', COLD: '偏冷', NEUTRAL: '中性', UNAVAILABLE: '数据不可用' }[z] || (z || '—'); }
|
||
function mzColor(z) { return { HOT: '#d8574a', COLD: '#3d7ff0', NEUTRAL: '#8a8f9c', UNAVAILABLE: '#d0a53c' }[z] || '#8a8f9c'; }
|
||
function mzBarStyle(h) {
|
||
const v = (h && h.value != null) ? Math.max(-40, Math.min(40, Number(h.value))) : 0;
|
||
return { height: (3 + Math.round(Math.abs(v) / 40 * 27)) + 'px', background: mzColor(h && h.zone) };
|
||
}
|
||
async function loadMacro() { const d = await call('get', '/api/macro/status'); macro.value = d || {}; }
|
||
async function macroScan(dry) {
|
||
const d = await call('post', '/api/ops/macro-scan?dry_run=' + (dry ? 'true' : 'false'), {});
|
||
ElementPlus.ElMessage[(d && d.ok !== false) ? 'success' : 'error'](
|
||
(dry ? '试算完成' : '扫描完成') + ((d && d.error) ? (':' + d.error) : ''));
|
||
await loadMacro();
|
||
if (!dry) await Promise.all([loadCommands(), loadLedger()]);
|
||
}
|
||
async function macroAdopt(key) {
|
||
try { await ElementPlus.ElMessageBox.confirm('按今日宏观建议下达仓位命令?', '采纳建议', { type: 'warning' }); }
|
||
catch (e) { return; }
|
||
const d = await call('post', '/api/macro/adopt', { signal_key: key });
|
||
ElementPlus.ElMessage[(d && d.ok) ? 'success' : 'error']((d && d.ok) ? ('已下达 ' + (d.command_id || '')) : ((d && d.error) || '采纳失败'));
|
||
await Promise.all([loadMacro(), loadCommands()]);
|
||
}
|
||
// 首行徽标 (2026-08-19 用户要求由大面板改为首行紧凑显示, 详情进悬浮提示)
|
||
const mzSig = computed(() => (((macro.value || {}).signals) || [])[0] || null);
|
||
const mzAdvicePending = computed(() => { const s = mzSig.value; return !!(s && s.advice && !s.advice.adopted); });
|
||
const mzChipCls = computed(() => {
|
||
const m = macro.value || {};
|
||
if (m.enabled === false) return 'idle';
|
||
const s = mzSig.value;
|
||
if (!s) return 'idle';
|
||
if (mzAdvicePending.value || macroGateOn.value || s.zone === 'UNAVAILABLE') return 'warn';
|
||
return 'ok';
|
||
});
|
||
const mzChipText = computed(() => {
|
||
const m = macro.value || {};
|
||
if (m.enabled === false) return '未启用';
|
||
const s = mzSig.value;
|
||
if (!s) return '—';
|
||
if (s.zone === 'UNAVAILABLE') return '数据不可用';
|
||
const v = s.value == null ? '—' : ((s.value > 0 ? '+' : '') + Number(s.value).toFixed(1));
|
||
let t = v + ' ' + mzLabel(s.zone);
|
||
if (mzAdvicePending.value) t += ' · 有建议';
|
||
return t;
|
||
});
|
||
function macroChipClick() { if (mzAdvicePending.value && mzSig.value) macroAdopt(mzSig.value.key); }
|
||
|
||
async function loadUpstream() {
|
||
const d = await call('get', '/api/upstream-signals');
|
||
upstream.value = (d && d.sources) ? d : { sources: {} };
|
||
}
|
||
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 || [];
|
||
}
|
||
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);
|
||
}
|
||
async function loadDispatchMode() {
|
||
const d = await call('get', '/api/dispatch-mode'); dm.value = d.data || d || {};
|
||
}
|
||
async function loadWs() {
|
||
const d = await call('get', '/api/ws-channel?limit=60');
|
||
wsRaw.value = d.data || d || {};
|
||
}
|
||
// 只在 ws 页签可见时轮询。3 秒一次对一个只读接口不算什么, 但让它在后台一直跑
|
||
// 会把日志和连接数刷得很难看, 排查问题时反而碍事。
|
||
function wsPollSync() {
|
||
const want = tab.value === 'ws' && wsAuto.value;
|
||
if (want && !wsTimer) { loadWs(); wsTimer = setInterval(() => { if (authed.value) loadWs(); }, 3000); }
|
||
if (!want && wsTimer) { clearInterval(wsTimer); wsTimer = null; }
|
||
}
|
||
watch([tab, wsAuto], wsPollSync);
|
||
onUnmounted(() => { if (wsTimer) clearInterval(wsTimer); });
|
||
|
||
async function loadAll() {
|
||
loading.value = true; err.value = '';
|
||
await Promise.all([loadOverview(), loadParams(), loadCatalog(), loadCommands(),
|
||
loadPositions(), loadInstructions(), loadLedger(), loadProposals(),
|
||
loadDispatchMode(),
|
||
// ws-channel 是管理员专属接口: 交易员不调它, 免得每次刷新都弹一条「无权限」
|
||
// (通道告警对交易员改由 dispatch-mode 里的 channel 兜底, 见 wsc 的注释)
|
||
(isAdmin.value ? loadWs() : Promise.resolve()),
|
||
loadStrategies(), loadOpLog(), loadOpenScan(), loadUpstream(),
|
||
loadMacro()]);
|
||
await loadNames();
|
||
lastRefresh.value = new Date().toTimeString().slice(0, 8);
|
||
loading.value = false;
|
||
wsPollSync();
|
||
}
|
||
async function cancelIns(iid) {
|
||
try {
|
||
await ElementPlus.ElMessageBox.confirm('撤销指令 ' + iid + '?', '确认',
|
||
{ type: 'warning' });
|
||
} catch (e) { return; }
|
||
const d = await call('post', '/api/instructions/' + iid + '/cancel', {});
|
||
ElementPlus.ElMessage[(d.ok ? 'success' : 'error')](d.message || d.error);
|
||
await Promise.all([loadInstructions(), loadLedger()]);
|
||
}
|
||
|
||
function onCmdChange() {
|
||
form.params = {};
|
||
const s = curSpec.value; if (!s) return;
|
||
Object.entries(s.fields).forEach(([k, f]) => {
|
||
if (f.default !== undefined) form.params[k] = f.default;
|
||
if (f.type === 'enum' && f.choices && f.choices.length === 1) form.params[k] = f.choices[0];
|
||
});
|
||
}
|
||
function buildParams() {
|
||
const s = curSpec.value, out = {};
|
||
Object.entries(s.fields).forEach(([k, f]) => {
|
||
let v = form.params[k];
|
||
if (v === undefined || v === null || v === '') return;
|
||
out[k] = (f.type === 'pct') ? (String(v).trim() + '%') : v;
|
||
});
|
||
return out;
|
||
}
|
||
async function issue(force) {
|
||
const s = curSpec.value;
|
||
if (!s) { ElementPlus.ElMessage.warning('请先选择命令'); return; }
|
||
if (s.danger && !force) {
|
||
try {
|
||
await ElementPlus.ElMessageBox.confirm(
|
||
'【' + s.label + '】是高危命令, 将对全部持仓生效。确认执行?', '二次确认',
|
||
{ type: 'warning', confirmButtonText: '确认执行', cancelButtonText: '取消' });
|
||
} catch (e) { return; }
|
||
}
|
||
issuing.value = true;
|
||
const body = { cmd_type: s.cmd_type, params: buildParams(), note: form.note, force: !!force };
|
||
const d = await call('post', '/api/commands', body);
|
||
issuing.value = false;
|
||
if (d.ok) {
|
||
ElementPlus.ElMessage.success('命令 ' + d.command_id + ' 已下达 · ' + (d.status || ''));
|
||
const notes = ((d.plan || {}).notes || []); if (notes.length)
|
||
ElementPlus.ElMessage.info(notes.join(' | '));
|
||
err.value = '';
|
||
await Promise.all([loadCommands(), loadOverview(), loadPositions(), loadParams()]);
|
||
} else if ((d.conflicts || []).length) {
|
||
const txt = d.conflicts.map(c => c.with_cmd_type + '(' + c.with_command_id + '): ' + c.reason)
|
||
.join('\n');
|
||
try {
|
||
await ElementPlus.ElMessageBox.confirm(
|
||
'与在途命令冲突:\n' + txt + '\n\n命令冲突由用户裁决 —— 仍要并行下达?', '命令冲突',
|
||
{ type: 'warning', confirmButtonText: '仍然下达', cancelButtonText: '放弃' });
|
||
await issue(true);
|
||
} catch (e) { /* 用户放弃 */ }
|
||
} else {
|
||
ElementPlus.ElMessage.error((d.errors || [d.error]).join('; '));
|
||
}
|
||
}
|
||
async function cancelCmd(cid) {
|
||
const d = await call('post', '/api/commands/' + cid + '/cancel');
|
||
ElementPlus.ElMessage[(d.ok ? 'success' : 'error')](d.message || d.error);
|
||
await loadCommands();
|
||
}
|
||
async function replan(cid) {
|
||
const d = await call('post', '/api/commands/' + cid + '/replan');
|
||
ElementPlus.ElMessage[(d.ok ? 'success' : 'error')](d.ok ? '已重新生成方案' :
|
||
(d.errors || [d.error]).join('; '));
|
||
await loadCommands(); if (plansOf.value === cid) await loadPlans(cid);
|
||
}
|
||
async function decide(row, decision) {
|
||
// 采纳与驳回都必须写理由 (2026-09-03): 理由随请求体进评审账本, 复盘时「人为什么这么判」
|
||
// 才查得到。空理由不提交; 点取消就什么都不发。后端 /decide 早已接受 reason, 不必改。
|
||
const accepted = decision === 'ACCEPTED';
|
||
let reason = '';
|
||
try {
|
||
const r = await ElementPlus.ElMessageBox.prompt(
|
||
(accepted ? '采纳' : '驳回') + ' ' + nm(row.ts_code) + ' 的' + tx('action', row.action) + '提议。请写下理由(必填,会记进评审账本)',
|
||
accepted ? '采纳理由' : '驳回理由',
|
||
{ inputType: 'textarea', inputPlaceholder: '例如:候选卡理由仍成立、今天量能配合 / 上游确认线缺失、先观望',
|
||
inputValidator: v => (v && v.trim()) ? true : '理由不能为空',
|
||
confirmButtonText: accepted ? '确认采纳' : '确认驳回', cancelButtonText: '取消' });
|
||
reason = ((r && r.value) || '').trim();
|
||
} catch (e) { return; }
|
||
if (!reason) { ElementPlus.ElMessage.warning('理由不能为空,未提交'); return; }
|
||
const d = await call('post', '/api/proposals/' + row.proposal_id + '/decide', { decision, reason });
|
||
if (d.ok) {
|
||
ElementPlus.ElMessage.success(decision === 'ACCEPTED'
|
||
? ('已采纳, 指令 ' + d.instruction_id) : '已驳回');
|
||
if (d.warning) ElementPlus.ElMessage.warning(d.warning); // 一次性纪律失效等提示 (2026-08-28)
|
||
}
|
||
else ElementPlus.ElMessage.error(d.error);
|
||
await Promise.all([loadProposals(), loadInstructions(), loadLedger()]);
|
||
}
|
||
async function loadStrategies() {
|
||
const q = '/api/strategies' + (showAllStrategies.value ? '?include_archived=1' : '');
|
||
const d = await call('get', q);
|
||
strategies.value = d.strategies || (d.data && d.data.strategies) || [];
|
||
stratEnabled.value = !!(d.enabled != null ? d.enabled : (d.data && d.data.enabled));
|
||
autoInfo.value = d.auto || (d.data && d.data.auto) || null;
|
||
}
|
||
// 五阶段标签配色 (阶段字面量与 strategy_advisor.stage_of 是同一份契约)
|
||
function stageTagType(s) {
|
||
return { '吸筹震荡': 'primary', '启动拉升': 'success', '高位派发': 'danger',
|
||
'深亏修复': 'warning', '中性': 'info' }[s] || 'info';
|
||
}
|
||
// 自动挂载试算: 只判断不落任何东西 (与 09:40 调度位同一份实现, 带 dry_run)
|
||
async function autoScanPreview() {
|
||
autoScanBusy.value = true;
|
||
try {
|
||
const d = await call('post', '/api/ops/strategy-attach-scan?dry_run=true', {}) || {};
|
||
if (d.checked == null) d.checked = 0; // 后端不可达时也别渲染出 undefined
|
||
if (d.error && !(d.errors || []).length) d.errors = [d.error];
|
||
autoScanRes.value = d;
|
||
} finally { autoScanBusy.value = false; }
|
||
}
|
||
// 软归档: 把终态记录从在办视图移除 (on=true) 或恢复显示 (on=false)。kind = commands/strategies/instructions/proposals
|
||
async function archiveRow(kind, id, on) {
|
||
const d = await call('post', '/api/' + kind + '/' + id + '/archive', { archived: on });
|
||
if (d && d.ok) ElementPlus.ElMessage.success(on ? '已移除' : '已恢复显示');
|
||
else ElementPlus.ElMessage.info((d && (d.note || d.error)) || '未改动');
|
||
const reload = { commands: loadCommands, strategies: loadStrategies,
|
||
instructions: loadInstructions, proposals: loadProposals };
|
||
if (reload[kind]) await reload[kind]();
|
||
}
|
||
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()]);
|
||
}
|
||
async function resumeBuy(row) {
|
||
try { await ElementPlus.ElMessageBox.confirm('恢复 ' + nm(row.ts_code) + ' 的策略买入?之前是决策系统的风控提示让它自动停买的,卖出、以及把当天做T买的那部分卖回去,一直都正常。', '确认', { 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()]);
|
||
}
|
||
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 次'
|
||
+ (st.open_leg ? (' · 有在手T仓(' + (st.open_leg.dir === 'long' ? '已买待卖' : '已卖待买回') + ')') : ' · 今日无在手T仓');
|
||
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 '';
|
||
}
|
||
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();
|
||
}
|
||
// 清通道对账标记 (2026-08-31): 后端接口早就有 (POST /api/ws-channel/clear-resync),
|
||
// 页面一直没有入口, 横幅让人"经运维抽屉清除"却无处可点。语义见后端注释: 标记只能
|
||
// 人清, 清之前必须先做完全量对账 (上面「账本对账」按钮) 且差异为零。
|
||
async function clearResync() {
|
||
try {
|
||
await ElementPlus.ElMessageBox.confirm(
|
||
'清除前请确认两件事:一,已点过「账本对账」;二,对账结果 diffs 为空(差异为零)。'
|
||
+ '清除只是去掉「通道要人工做一次全量对账」的提醒,不改任何账目。',
|
||
'清除通道对账标记', { type: 'warning', confirmButtonText: '已对账,清除', cancelButtonText: '先不清' });
|
||
} catch (e) { return; }
|
||
opsLoading.value = true;
|
||
const d = await call('post', '/api/ws-channel/clear-resync');
|
||
opsResult.value = JSON.stringify(d, null, 2); opsLoading.value = false;
|
||
await Promise.all([loadWs(), loadAll()]);
|
||
}
|
||
async function loadSchema() {
|
||
opsLoading.value = true;
|
||
const d = await call('get', '/api/ops/downstream-schema');
|
||
opsResult.value = JSON.stringify(d, null, 2); opsLoading.value = false;
|
||
}
|
||
async function loadPlan() {
|
||
planLoading.value = true;
|
||
const d = await call('get', '/api/upstream/plan?limit=50');
|
||
planRaw.value = (d && d.ok) ? d : { status: { ok: false, hint: (d||{}).error || '取数失败' } };
|
||
planLoading.value = false;
|
||
}
|
||
async function loadChanges() {
|
||
chgLoading.value = true;
|
||
const d = await call('get', '/api/upstream/plan-changes?limit=50');
|
||
chgRaw.value = (d && d.ok) ? d
|
||
: { changes: { ok: false, hint: (d || {}).error || '取数失败' }, log: {} };
|
||
chgLoading.value = false;
|
||
}
|
||
async function openPlan() { planDrawer.value = true; await Promise.all([loadPlan(), loadChanges()]); }
|
||
async function refreshPlan() {
|
||
planLoading.value = true;
|
||
const d = await call('post', '/api/ops/plan-refresh');
|
||
planResult.value = JSON.stringify(d, null, 2);
|
||
planLoading.value = false;
|
||
await Promise.all([loadPlan(), loadChanges(), loadOverview()]);
|
||
}
|
||
async function openReport() {
|
||
const d = await call('get', '/api/report'); report.value = d.data || d || {};
|
||
reportDrawer.value = true;
|
||
}
|
||
|
||
// —— 自动刷新: 盘中 30s、休市 5min; 只拉快变数据, 不动 params(免得冲掉设置里在改的输入) ——
|
||
let refreshTimer = null;
|
||
async function refreshLive() {
|
||
// allSettled: 单个 loader 失败/超时(axios 60s 兜底)不拖累其余与时间戳更新。
|
||
await Promise.allSettled([loadOverview(), loadPositions(), loadInstructions(), loadLedger(),
|
||
loadProposals(), loadStrategies(), loadOpLog(), loadOpenScan(), loadUpstream(), loadMacro()]);
|
||
try { await loadNames(); } catch (e) {}
|
||
// 用客户端钟, 保证"自动·HH:MM:SS"稳定跳动, 不依赖 /health 的 now。
|
||
lastRefresh.value = new Date().toTimeString().slice(0, 8);
|
||
}
|
||
function startAutoRefresh() {
|
||
if (refreshTimer) return;
|
||
// 固定 30s 自动刷新(开关开时)。不再按 marketOpen 分档 —— 交易日历对模拟/未来日期可能
|
||
// 误判成非交易日, 那样会把刷新降到 5 分钟、体感像"卡住不刷"。休市时多刷几次也无害。
|
||
refreshTimer = setInterval(() => { if (autoRefresh.value && authed.value) refreshLive(); }, 30000);
|
||
}
|
||
onUnmounted(() => { if (refreshTimer) clearInterval(refreshTimer); });
|
||
onMounted(async () => {
|
||
await checkMe();
|
||
if (authed.value) { loadAll(); loadPlan(); startAutoRefresh(); }
|
||
}); // 首屏: 先验登录, 已登录才拉数据与启动自动刷新
|
||
// ═══════════ 交易员工作台改版 (2026-08-26) ═══════════
|
||
// 一、融合消息栏: 把四类消息合成一条按时间倒序的流。
|
||
// 数据全部来自页面已经在拉的接口, 后端一个字都不用改。
|
||
const MSG_KINDS = [
|
||
{ key:'all', label:'全部' },
|
||
{ key:'alert', label:'行情告警' },
|
||
{ key:'order', label:'系统下单' },
|
||
{ key:'prop', label:'等我拍板' },
|
||
{ key:'strat', label:'策略与风控' },
|
||
];
|
||
const KIND_LABEL = { alert:'告警', order:'下单', prop:'待办', strat:'风控' };
|
||
const kindLabel = k => KIND_LABEL[k] || k;
|
||
const hm = v => { const m = String(v || '').match(/\d{2}:\d{2}/); return m ? m[0] : (v || ''); };
|
||
const msgOpen = ref(false), msgKind = ref('all');
|
||
// 时间字段各表叫法不一, 取到哪个算哪个; 排序用 HHMMSS, 同一天内够用。
|
||
const _at = (r, keys) => { for (const k of keys) { const v = r && r[k]; if (v) return String(v); } return ''; };
|
||
const _hm = s => { const m = String(s || '').match(/\d{2}:\d{2}/); return m ? m[0] : ''; };
|
||
const _hms = s => { const m = String(s || '').match(/(\d{2}):(\d{2})(?::(\d{2}))?/);
|
||
return m ? (m[1] + m[2] + (m[3] || '00')) : ''; };
|
||
|
||
function insWhy(r) {
|
||
const bits = [];
|
||
if (r.qty != null) bits.push('下单 ' + r.qty + ' 股');
|
||
if (r.exec_qty) bits.push('已成交 ' + r.exec_qty + ' 股');
|
||
if (r.limit_price) bits.push('限价 ' + r.limit_price);
|
||
if (r.reason) bits.push(r.reason);
|
||
return bits.join(',');
|
||
}
|
||
|
||
const msgFeed = computed(() => {
|
||
const out = [];
|
||
// 1) 行情告警 —— 跟着左栏的级别筛选走, 你关掉的档这里也不吵你
|
||
for (const a of alertItemsF.value) {
|
||
out.push({ kind:'alert', time:_hm(a.time), sort:_hms(a.time), ts_code:a.ts_code,
|
||
cat:(a.cat_label || a.cat), dir:a.direction, level:a.level,
|
||
stale:a.stale, age_min:a.age_min, why:'', goLabel:'看信号', go:'sig' });
|
||
}
|
||
// 2) 系统下单动作 —— 建仓 / 补足 / 加仓 / 补仓 / 减仓 / 清仓, 以及成交回报
|
||
for (const r of (instructions.value || [])) {
|
||
if (_isArch(r) || !_sameDayIns(r)) continue;
|
||
const t = _at(r, ['updated_at', 'created_at', 'at', 'dispatched_at']);
|
||
out.push({ kind:'order', time:_hm(t), sort:_hms(t), ts_code:r.ts_code,
|
||
cat:tx('action', r.action) + ' · ' + tx('insStatus', r.status),
|
||
strong:true, why:insWhy(r), goLabel:'看在办', go:'ins' });
|
||
}
|
||
// 3) 等我拍板 —— 自主档位是「只提议」时, 新提议在这里也提醒一次
|
||
for (const p of (proposals.value || [])) {
|
||
const t = _at(p, ['created_at', 'at', 'decided_at']);
|
||
out.push({ kind:'prop', time:_hm(t), sort:_hms(t) || '999999', ts_code:p.ts_code,
|
||
cat:tx('action', p.action) + ' · 等你拍板', strong:true,
|
||
why:(p.judge_reason || propWhy(p)), goLabel:'去处理', go:'prop' });
|
||
}
|
||
// 4) 策略与风控动向
|
||
const sc = (autoInfo.value || {}).last_scan;
|
||
if ((autoInfo.value || {}).ran_today && sc) {
|
||
const c = sc.counts || {};
|
||
out.push({ kind:'strat', time:_hm(sc.at), sort:_hms(sc.at), ts_code:'',
|
||
cat:'自动挂方案跑过一轮', strong:true,
|
||
why:'检查 ' + (sc.checked || 0) + ' 只,新挂 ' + (c.attached || 0)
|
||
+ ',换方案 ' + (c.handoffs || 0) + ',停买 ' + (c.paused || 0)
|
||
+ ',被挡 ' + (c.blocked || 0) + ',跳过 ' + (c.skipped || 0),
|
||
goLabel:'去我的策略', go:'strat' });
|
||
}
|
||
for (const s of (strategies.value || [])) {
|
||
if (!s.buy_paused) continue;
|
||
const t = _at(s.buy_paused, ['at', 'paused_at']) || _at(s, ['updated_at']);
|
||
out.push({ kind:'strat', time:_hm(t), sort:_hms(t), ts_code:s.ts_code,
|
||
cat:'这只票改成只卖不买', strong:true,
|
||
why:'决策系统提示' + (s.buy_paused.reason || '资金异动')
|
||
+ ',已经停掉这个方案的买入。卖出、以及把当天做T买的那部分卖回去,都不受影响。',
|
||
goLabel:'去我的策略', go:'strat' });
|
||
}
|
||
for (const r of (SIG.value.sell || [])) {
|
||
if (r.today === false) continue; // 昨天的风控卖出不再冒充今天的消息 (2026-08-28)
|
||
out.push({ kind:'strat', time:_hm(r.time), sort:_hms(r.time), ts_code:r.ts_code,
|
||
cat:(r.dominant_signal === 'take_profit' ? '决策系统建议止盈' : '决策系统建议卖出')
|
||
+ (confPct(r.confidence) != null ? (',把握 ' + confPct(r.confidence)) : ''),
|
||
dir:'卖出',
|
||
strong:true, level:'DANGER', why:r.reason || '', goLabel:'去处理', go:'strat' });
|
||
}
|
||
for (const o of (opLog.value || [])) {
|
||
if (o.result === 'OK') continue; // 顺利做完的不占消息栏, 在「你做过的操作」里查
|
||
out.push({ kind:'strat', time:_hm(o.at), sort:_hms(o.at), ts_code:o.ts_code || '',
|
||
cat:o.op + ' ' + (o.result === 'BLOCKED' ? '被合规检查挡下' : ('结果 ' + o.result)),
|
||
strong:true, why:o.reason || '', goLabel:'看记录', go:'ins' });
|
||
}
|
||
out.sort((a, b) => String(b.sort).localeCompare(String(a.sort)));
|
||
return out;
|
||
});
|
||
const msgCounts = computed(() => {
|
||
const c = { all:msgFeed.value.length, alert:0, order:0, prop:0, strat:0 };
|
||
for (const m of msgFeed.value) c[m.kind] = (c[m.kind] || 0) + 1;
|
||
return c;
|
||
});
|
||
const msgShown = computed(() =>
|
||
msgKind.value === 'all' ? msgFeed.value : msgFeed.value.filter(m => m.kind === msgKind.value));
|
||
const msgLatest = computed(() => msgShown.value[0] || null);
|
||
// 消息栏左边那道竖线的颜色: 有风控类的用橙, 有重要或警告的用琥珀, 否则中性。
|
||
const msgLevelClass = computed(() => {
|
||
const it = msgShown.value;
|
||
if (it.some(m => m.kind === 'strat')) return 'stop';
|
||
if (it.some(m => ['DANGER', 'IMPORTANT', 'WARNING'].includes(String(m.level || '')))) return 'warn';
|
||
return it.length ? '' : 'none';
|
||
});
|
||
function setMsgKind(k) { msgKind.value = k; msgOpen.value = true; }
|
||
function msgGo(m) {
|
||
if (m.go === 'sig') { sigTab.value = 'brief'; openRailSide('l'); }
|
||
else if (m.go === 'ins') { msgOpen.value = false; scrollTo('t-ins'); }
|
||
else if (m.go === 'prop') { sectClosed.prop = false; openRailSide('r'); }
|
||
else if (m.go === 'strat') { sectClosed.strat = false; openRailSide('r'); }
|
||
}
|
||
|
||
// 二、左栏信号流: 速览分「持仓的票」和「没持仓的票」两段, 持仓的置顶。
|
||
const sigTab = ref('brief');
|
||
const srcOpen = reactive({ metrics:true, mr:false, decision:false, qrs:false,
|
||
capital:false, price:false, rare:false });
|
||
function toggleSrc(k) { srcOpen[k] = !srcOpen[k]; }
|
||
// 默认只看今天 (2026-08-28): 风控卖出流不按日期分键, 清晨没有新信号时最近 N 条全是
|
||
// 昨天以前的, 页面又只显示 HH:MM —— 昨天 14:49 的旧条目会挂在今天 13:20 的页面上冒充
|
||
// 新消息。今天的照常按持仓分两段; 更早的收进底部折叠区, 带日期、置灰 —— 不丢信息也不吵人。
|
||
const sigToday = computed(() => sigBrief.value.filter(x => x.today !== false));
|
||
const sigOld = computed(() => sigBrief.value.filter(x => x.today === false));
|
||
const showOldSig = ref(false);
|
||
const sigBriefHeld = computed(() => sigToday.value.filter(x => x.held));
|
||
const sigBriefOther = computed(() => sigToday.value.filter(x => !x.held));
|
||
|
||
// 三、左右两栏: 宽度够就常驻, 不够就收成窄条; 窄条点开是浮层。
|
||
// 临界值按「主区要放得下持仓表」倒推 —— 表最小 940 加面板内边距 32 等于 972:
|
||
// 两栏都常驻 976+302+334+26+32 = 1670 → 取 1680 (实测 1660 差 10 像素)
|
||
// 只常驻左栏 976+302+ 40+26+32 = 1376 → 取 1380
|
||
const W_DOCK_BOTH = 1680, W_DOCK_LEFT = 1380;
|
||
const RAIL_STORE_KEY = 'pms_rail_fold';
|
||
const _railLoad = () => { try { const v = JSON.parse(sessionStorage.getItem(RAIL_STORE_KEY));
|
||
if (v && typeof v === 'object') return v; } catch (e) {} return { l:false, r:false }; };
|
||
const _rf = _railLoad();
|
||
const lFold = ref(!!_rf.l), rFold = ref(!!_rf.r);
|
||
const railOpen = ref(null); // null / 'l' / 'r'
|
||
const sectClosed = reactive({ prop:false, strat:false, pool:false, ops:false, set:true });
|
||
function toggleSect(k) { sectClosed[k] = !sectClosed[k]; }
|
||
function _railSave() {
|
||
try { sessionStorage.setItem(RAIL_STORE_KEY, JSON.stringify({ l:lFold.value, r:rFold.value })); } catch (e) {}
|
||
}
|
||
function _railClasses() {
|
||
document.body.classList.toggle('l-fold', lFold.value);
|
||
document.body.classList.toggle('r-fold', rFold.value);
|
||
}
|
||
// ?uidbg=1 远程排查 (2026-09-01): iPad 只能靠截图排样式问题, 这块小牌把浏览器
|
||
// 版本与布局关键量直接印在屏幕上, 一张截图就够定位。平时不带参数, 完全不出现。
|
||
const uidbg = new URLSearchParams(location.search).has('uidbg');
|
||
const dbgText = ref('');
|
||
function _dbgUpdate() {
|
||
if (!uidbg) return;
|
||
dbgText.value = navigator.userAgent + ' · 视口 ' + window.innerWidth + '×' + window.innerHeight
|
||
+ ' · dock-l ' + document.body.classList.contains('dock-l')
|
||
+ ' · dock-r ' + document.body.classList.contains('dock-r')
|
||
+ ' · 浮层 ' + (railOpen.value || '关');
|
||
}
|
||
function applyDock() {
|
||
const w = window.innerWidth;
|
||
document.body.classList.toggle('dock-l', w >= W_DOCK_LEFT);
|
||
document.body.classList.toggle('dock-r', w >= W_DOCK_BOTH);
|
||
_dbgUpdate();
|
||
// 这一侧已经能常驻了, 就不该还浮在上面
|
||
if (railOpen.value === 'l' && w >= W_DOCK_LEFT && !lFold.value) railOpen.value = null;
|
||
if (railOpen.value === 'r' && w >= W_DOCK_BOTH && !rFold.value) railOpen.value = null;
|
||
}
|
||
function foldRail(side) {
|
||
if (side === 'l') lFold.value = true; else rFold.value = true;
|
||
_railClasses(); _railSave();
|
||
}
|
||
function closeRail() { railOpen.value = null; _dbgUpdate(); }
|
||
function openRailSide(side, forcePull) {
|
||
const canDock = document.body.classList.contains(side === 'l' ? 'dock-l' : 'dock-r');
|
||
const folded = side === 'l' ? lFold.value : rFold.value;
|
||
// 宽度够、只是手动收起来了: 点一下直接展回常驻, 不用浮层
|
||
if (!forcePull && canDock && folded) {
|
||
if (side === 'l') lFold.value = false; else rFold.value = false;
|
||
_railClasses(); _railSave(); return;
|
||
}
|
||
if (!forcePull && canDock && !folded) return; // 已经常驻着, 不用再浮一层
|
||
railOpen.value = side;
|
||
_dbgUpdate();
|
||
}
|
||
// 触屏直连 (2026-08-31): iPad 的 Safari 对非控件元素的点按并不总是合成 click 事件,
|
||
// 窄条在触屏上点不开就是这么来的。touchend 直接开侧栏, preventDefault 挡掉后续可能
|
||
// 合成的 click 免得双触发; 记录起点加位移阈值, 挡住"滑动页面时扫过窄条"的误触。
|
||
// 鼠标端没有 touch 事件, 走原来的 @click, 行为一个字都不变。
|
||
const _tp = { x: 0, y: 0 };
|
||
function railTS(e) {
|
||
const p = (e.touches && e.touches[0]) || {};
|
||
_tp.x = p.clientX || 0; _tp.y = p.clientY || 0;
|
||
}
|
||
function railTE(e, side) {
|
||
const p = (e.changedTouches && e.changedTouches[0]) || {};
|
||
if (Math.hypot((p.clientX || 0) - _tp.x, (p.clientY || 0) - _tp.y) > 12) return;
|
||
e.preventDefault();
|
||
openRailSide(side);
|
||
}
|
||
function scrimTE(e) { e.preventDefault(); closeRail(); }
|
||
function _onEsc(e) { if (e.key === 'Escape') closeRail(); }
|
||
onMounted(() => {
|
||
_railClasses(); applyDock();
|
||
window.addEventListener('resize', applyDock);
|
||
document.addEventListener('keydown', _onEsc);
|
||
});
|
||
onUnmounted(() => {
|
||
window.removeEventListener('resize', applyDock);
|
||
document.removeEventListener('keydown', _onEsc);
|
||
});
|
||
|
||
return { tab, loading, err, health, ov, params, catalog, commands, plans, plansOf,
|
||
positions, lots, lotsOf, instructions, ledger, ledgerToday, proposals, report, reportDrawer,
|
||
opsDrawer, opsResult, opsLoading, issuing, form, curSpec, dirtyCount, dm,
|
||
money, pct, groupLabel, fieldLabel, stTag, cuTag, canCancel, progPct, insPct,
|
||
scaleGap,
|
||
wsRaw, wsAuto, wsHidePong, wsc, wst, wsMode, wsOrders, wsInbox, wsWarnings, loadWs,
|
||
loadAll, loadParams, saveParams, loadPlans, loadLots, onCmdChange, issue,
|
||
cancelCmd, replan, decide, ops, loadSchema, openReport, cancelIns,
|
||
planDrawer, planLoading, planStatus, planRows, planCand, planResult,
|
||
loadPlan, openPlan, refreshPlan,
|
||
chgRaw, chgSt, chgLoading, chgTab, chg, chgHeld, chgLog, chgRevised, chgWatch,
|
||
loadChanges,
|
||
mode, tx, nm, fullName, nameMap, attention, heldPositions, traderParams,
|
||
propWhy, propReasons, nextStep, scrollTo, actExitStock, actReduceStock, rowMore,
|
||
pHaltBuy, pResumeBuy, pHaltAll, pResumeAll, pReduce, pIncrease, pLiquidate, pSectorExit,
|
||
pctOf, tgtPos, posMoveValid, posMovePreview, doPosMove, heldSectors, secSel, doSectorExit,
|
||
pmap, pval, dcaOn, sumPosition, sumDca, sumAutonomy,
|
||
strategies, stratEnabled, opLog, stratDlg, stratQty, loadStrategies, loadOpLog,
|
||
autoScanRes, autoScanBusy, autoScanPreview, stageTagType, autoInfo,
|
||
openStrategy, validateStrategy, attachStrategy, setStrategyStatus, stratStateText, resumeBuy,
|
||
showLedger, onPosExpand,
|
||
openScan, insTab, todayYmd, insLive, insDone, insEnd, dispOf, loadOpenScan, denyToday,
|
||
commandsShown, strategiesShown, instrLive, showAllCommands, showAllStrategies, showAllInstr,
|
||
cmdArchivable, stratArchivable, archiveRow,
|
||
showOpLog, showSettings, watchCandidates, marketOpen, marketState, lastRefresh, autoRefresh,
|
||
upstream, showUpstream, usrc, loadUpstream,
|
||
macro, macroGateOn, macroUnavailable, mzLabel, mzColor, mzBarStyle,
|
||
loadMacro, macroScan, macroAdopt,
|
||
mzSig, mzAdvicePending, mzChipCls, mzChipText, macroChipClick,
|
||
showAlerts, alertItems, alertLatest, alertLevelClass, alertGroups, alertStripItems,
|
||
alertStripCount, alertTotalCount, catShown, toggleCat, ageText, alertRowClass,
|
||
alertItemsF, levelShown, toggleLevel, sellOpen, toggleSell, levelCount, ALERT_LEVELS, lvLabel, lvClass, alertGridFixed, passLevel, flowText, flowClass,
|
||
sigBrief, sigBriefHeldCount, heldSigOf, openSigCenter, sigToday, sigOld, showOldSig,
|
||
SIG, moneyCn, zBand, zBars, dirUp, dirCls, confPct, eventsQuiet,
|
||
MSG_KINDS, kindLabel, hm, msgOpen, msgKind, msgFeed, msgCounts, msgShown,
|
||
msgLatest, msgLevelClass, setMsgKind, msgGo,
|
||
sigTab, srcOpen, toggleSrc, sigBriefHeld, sigBriefOther,
|
||
lFold, rFold, railOpen, foldRail, closeRail, openRailSide, railTS, railTE, scrimTE,
|
||
clearResync, uidbg, dbgText,
|
||
authed, authReady, me, isAdmin, rolesText, loginForm, loginBusy, loginErr, doLogin, doLogout, doExport,
|
||
sectClosed, toggleSect };
|
||
}
|
||
}).use(ElementPlus).mount('#app');
|
||
</script>
|
||
</body>
|
||
</html>
|