评析报告改页内排版、组合操作与纪律设置移到运维视图、顶栏改成单行标签加菜单

一、个股深度评析报告原来是把上游链接开成新标签, 浏览器直接显示 markdown 源码。现在后端代取
   (/api/research/{code}/report, 链接只认上游给的, 按链接缓存十分钟), 页面用 marked 排版成
   同款样式的抽屉: 持仓表、提议来源详情、个股详情三处入口都改成页内打开, 抽屉里保留「原文」。
二、组合操作 (暂停买入、暂停执行、调整仓位、清仓某行业、一键清仓) 与纪律设置 (资金与仓位、
   浮亏补仓、自主档位) 属管理员操作, 从交易员右栏移到运维视图新页「组合操作」, 两栏布局。
三、顶栏改单行: 七枚状态胶囊改成短标签, 说明进悬停; 角色切换、选股计划、日报、导出、运维操作、
   自动刷新、退出收进右侧一个菜单, 菜单名写当前用户与视图。1180 到 1920 宽度都是一行、高 46。
第七批加两例 (链接只认上游、缓存与失败不缓存), 路由哨兵登记, 现为 904 例。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-18 11:50:45 +08:00
parent c706633ac5
commit f6e94cfcf5
7 changed files with 541 additions and 129 deletions

View File

@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
"""个股深度评析报告的代取 (2026-09-18)。
报告由数据基座每晚生成, 上游计划与逻辑状态里只带一条链接 (company_review.report_url),
链接打开是一份 markdown 纯文本原来页面把链接开成新标签, 浏览器按纯文本显示,
用户看到的是一屏 markdown 源码现在由持仓系统后端代取一次, 页面拿到正文后在自己的
抽屉里排版, 与整页同一套样式
只做三件事:
, 按代码找链接持仓票从逻辑状态映射取, 候选票从当日计划主榜与观察档取,
/api/research 的公司评析来源一致链接只认上游给的, 不接受页面传入的任意地址
, 带超时拉取正文
, 按链接缓存十分钟报告每晚生成一次, 盘中不会变; 点开抽屉不该每次都去打数据基座
取不到就把原因写进 error 返回, 不抛异常 (页面按失败态显示并给重试)
"""
from __future__ import annotations
import logging
import time
import requests
logger = logging.getLogger(__name__)
CACHE_SEC = 600 # 正文缓存秒数
TIMEOUT_SEC = 8 # 拉取超时
MAX_BYTES = 2_000_000 # 超过这个长度当异常处理, 不往页面塞
SRC_HELD, SRC_CAND, SRC_NONE = "held", "candidate", "none"
_cache: dict = {} # url -> {"at": 时刻, "text": 正文}
def _url_of(cr) -> str | None:
if not isinstance(cr, dict):
return None
u = cr.get("report_url")
u = str(u).strip() if u else ""
return u if u.startswith(("http://", "https://")) else None
def report_url_for(code: str, *, state_map=None, plan=None) -> tuple[str | None, str]:
"""返回 (链接, 来源)。来源: held (持仓逻辑状态) / candidate (当日计划) / none。
state_map plan 可注入 (单测用); 缺省时现取, 任一路取失败只记日志不抛"""
if state_map is None:
try:
from app.services import logic_state_service
state_map = logic_state_service.state_map()
except Exception as e: # noqa: BLE001
logger.warning("[评析报告] 取持仓逻辑状态失败 %s: %s", code, e)
state_map = {}
st = (state_map or {}).get(code)
u = _url_of((st or {}).get("company_review")) if isinstance(st, dict) else None
if u:
return u, SRC_HELD
if plan is None:
try:
from app.services import plan_feed
plan = plan_feed.get_plan()
except Exception as e: # noqa: BLE001
logger.warning("[评析报告] 取候选计划失败 %s: %s", code, e)
plan = {}
for r in (plan or {}).get("main") or []:
if r.get("ts_code") == code:
u = _url_of(r.get("company_review"))
if u:
return u, SRC_CAND
for r in (plan or {}).get("observe") or []:
if r.get("ts_code") == code:
u = _url_of(r.get("company_review"))
if u:
return u, SRC_CAND
return None, SRC_NONE
def _http_get_text(url: str, timeout: int) -> str:
r = requests.get(url, timeout=timeout)
r.raise_for_status()
if len(r.content) > MAX_BYTES:
raise ValueError(f"报告过大 ({len(r.content)} 字节)")
r.encoding = r.encoding or "utf-8"
return r.text
def fetch_text(url: str, *, now=None, timeout: int = TIMEOUT_SEC, getter=None) -> tuple[str, bool]:
"""取正文, 返回 (正文, 是否命中缓存)。失败抛异常, 由 get 兜成 error。"""
now = time.time() if now is None else now
hit = _cache.get(url)
if hit and now - hit["at"] < CACHE_SEC:
return hit["text"], True
text = (getter or _http_get_text)(url, timeout)
if not isinstance(text, str) or not text.strip():
raise ValueError("报告内容为空")
_cache[url] = {"at": now, "text": text}
return text, False
def _fail_why(e: Exception, timeout: int) -> str:
if isinstance(e, requests.exceptions.Timeout):
return f"数据基座没有回应,等了 {timeout}"
if isinstance(e, requests.exceptions.ConnectionError):
return "连不上数据基座"
if isinstance(e, requests.exceptions.HTTPError):
code = getattr(getattr(e, "response", None), "status_code", None)
return f"数据基座回了 {code}" if code else f"数据基座回了错误: {e}"
return f"{type(e).__name__}: {e}"
def get(code: str, *, now=None, timeout: int = TIMEOUT_SEC, getter=None, state_map=None, plan=None) -> dict:
"""页面用的一站式取法。返回固定键: ok / ts_code / url / source / markdown / cached / fetched_at / error。"""
url, src = report_url_for(code, state_map=state_map, plan=plan)
out = {"ok": False, "ts_code": code, "url": url, "source": src, "markdown": "", "cached": False,
"fetched_at": None, "error": None}
if not url:
out["error"] = ("这只票没有个股深度评析报告的链接" +
("(不在持仓也不在当日选股计划里)" if src == SRC_NONE else ""))
return out
try:
text, cached = fetch_text(url, now=now, timeout=timeout, getter=getter)
except Exception as e: # noqa: BLE001
out["error"] = f"评析报告读取失败: {_fail_why(e, timeout)}"
logger.warning("[评析报告] %s %s: %s", code, url, out["error"])
return out
at = _cache.get(url, {}).get("at")
out.update({"ok": True, "markdown": text, "cached": cached,
"fetched_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(at)) if at else None})
return out
def invalidate():
_cache.clear()

View File

@ -804,6 +804,17 @@ def _research_st(code):
return None, "none"
@app.get("/api/research/{ts_code}/report")
def api_research_report(ts_code: str):
"""个股深度评析全文 (2026-09-18): 上游给的 markdown 由后端代取, 页面自己排版成同款样式。
点击时调, 不进轮询; 按链接缓存十分钟链接只认上游给的, 不收页面传入的地址"""
from app.services import company_report
try:
return company_report.get(cs.normalize_code(ts_code))
except Exception as e: # noqa: BLE001
return {"ok": False, "error": f"评析报告读取失败: {type(e).__name__}: {e}"}
@app.get("/api/research/{ts_code}")
def api_research(ts_code: str):
"""单票研究面 · 三源合议 (点击时调, 只读, 绝不进轮询)。六块: 公司质地 / 基本面 / 技术面 /

View File

@ -8,6 +8,7 @@
<script src="/static/vendor/vue.global.js"></script>
<script src="/static/vendor/element-plus.js"></script>
<script src="/static/vendor/axios.min.js"></script>
<script src="/static/vendor/marked.min.js"></script>
<style>
:root, html[data-theme=light]{
--plane:#f4f5f2; --surface:#ffffff; --surface-2:#fafaf8; --ink:#141412; --ink-2:#54534f; --muted:#8a8882;
@ -27,10 +28,23 @@ body{margin:0;background:var(--plane);color:var(--ink);
.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;}
/* 2026-09-18 改单行: 不折行; 状态是标签 (.hst, 与综合分析列的 .dim 同款), 动作收进右侧菜单 */
.hd{position:sticky;top:0;z-index:30;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;min-height:46px;box-sizing:border-box;
margin:0 -16px 6px;padding:8px 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;white-space:nowrap;flex:none;}
.hd .ver{color:var(--muted);font-size:12px;font-weight:400;margin-left:6px;}
.hd-tags{display:flex;align-items:center;gap:6px;flex:0 1 auto;min-width:0;overflow:hidden;white-space:nowrap;}
.hd-acts{display:flex;align-items:center;gap:8px;flex:none;margin-left:auto;}
.hst{display:inline-flex;align-items:center;font-size:11.5px;font-weight:560;padding:2px 8px;border-radius:6px;flex:none;line-height:1.5;cursor:default;white-space:nowrap;}
.hst.ok{background:var(--accent-weak);color:var(--accent);}
.hst.warn{background:var(--warn-weak);color:var(--warn);}
.hst.idle{background:rgba(138,136,130,.14);color:var(--ink-2);}
.hst.stop{background:var(--stop-weak);color:var(--stop);}
.hd .tbtn{padding:4px 11px;}
.hd-menu .car{margin-left:5px;font-size:10px;color:var(--muted);}
.hd-menu:focus,.hd-menu:focus-visible{outline:none;}
.lnk{color:var(--accent);cursor:pointer;}
.lnk:hover{text-decoration:underline;}
.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;}
@ -210,15 +224,22 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius:
低于 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;} }
/* 顶栏不许掉成两行: 宽度不够时按重要性从低到高依次收起 (2026-09-18 标签化后阈值下调) */
@media(max-width:1500px){ .hd .ver{display:none;} }
@media(max-width:1340px){ .hd .hd-refresh-at{display:none;} }
@media(max-width:1200px){ .hd .hd-macro{display:none;} }
@media(max-width:1100px){ .hd .hd-exec{display:none;} .hd .hd-tech{display:none;} }
@media(max-width:1000px){ .hd .hd-buy{display:none;} }
/* 运维视图保持原来的居中宽度; 交易员视图铺开用两侧留白 */
.ops-wrap{max-width:1280px;margin:0 auto;}
/* 运维视图「组合操作」页 (2026-09-18): 左边组合级命令, 右边纪律参数, 窄了叠成一列 */
.ops-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start;}
@media(max-width:1000px){ .ops-grid{grid-template-columns:1fr;} }
.ops-grid .oprow2{padding:9px 0;}
.ops-grid .oprow2 .b{width:110px;}
.ops-grid .oprow2 .d{font-size:12.5px;line-height:1.6;}
.ops-grid .set-fields{font-size:12.5px;}
/* ── 融合消息栏: 上游告警 + 系统下单 + 待确认提议 + 策略与风控, 混成一条时间线 ── */
.msgbar{position:sticky;top:46px;z-index:40;display:flex;align-items:center;gap:9px;
@ -388,6 +409,37 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
/* 登录遮罩与登录人胶囊 (2026-08 加权限) */
/* ── 单票信号抽屉 (2026-09-09): 点一只票看它今天的全部信号 ──
层级 3000/2995 是显式写死的, 不能删也不能改小: 见 .banner-float 那条注释。 */
/* 个股深度评析抽屉 (2026-09-18): 压在个股详情抽屉 (3000) 之上; .md 是 markdown 排版, 与整页同一套变量 */
.rp-scrim{position:fixed;inset:0;background:rgba(17,17,16,.32);z-index:3095;}
.rp-drawer{position:fixed;top:0;right:0;bottom:0;width:min(900px,94vw);background:var(--surface);z-index:3100;display:flex;flex-direction:column;
box-shadow:-12px 0 40px rgba(17,17,16,.22);}
.rp-b{flex:1;overflow:auto;padding:12px 24px 36px;}
.md{font-size:13.5px;line-height:1.75;color:var(--ink);word-break:break-word;}
.md>h1:first-child{display:none;}
.md h1{font-size:17px;font-weight:700;margin:14px 0 8px;}
.md h2{font-size:15px;font-weight:700;margin:22px 0 8px;padding-bottom:5px;border-bottom:1px solid var(--hair);}
.md h3{font-size:13.5px;font-weight:680;margin:16px 0 6px;}
.md h4{font-size:13px;font-weight:660;margin:12px 0 4px;}
.md p{margin:6px 0;}
.md ul,.md ol{margin:4px 0 8px;padding-left:22px;}
.md li{margin:2px 0;}
.md li>p{margin:2px 0;}
.md blockquote{margin:8px 0 12px;padding:6px 12px;border-left:3px solid var(--accent);background:var(--accent-weak);color:var(--ink-2);border-radius:0 8px 8px 0;font-size:12.5px;}
.md blockquote p{margin:2px 0;}
.md .tblwrap{overflow-x:auto;margin:8px 0 14px;border:1px solid var(--hair);border-radius:8px;background:var(--surface);}
.md table{border-collapse:collapse;font-size:12.5px;min-width:100%;font-variant-numeric:tabular-nums;}
.md th,.md td{border-bottom:1px solid var(--hair);padding:5px 9px;text-align:left;vertical-align:top;}
.md .tblwrap.num td,.md .tblwrap.num th{white-space:nowrap;}
.md .tblwrap.txt td,.md .tblwrap.txt th{min-width:96px;}
.md th{background:var(--surface-2);color:var(--muted);font-weight:560;white-space:nowrap;}
.md tr:last-child td{border-bottom:none;}
.md td:first-child,.md th:first-child{padding-left:11px;}
.md code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;background:var(--surface-2);border:1px solid var(--hair);border-radius:4px;padding:0 4px;}
.md pre{background:var(--surface-2);border:1px solid var(--hair);border-radius:8px;padding:10px;overflow:auto;font-size:12px;}
.md pre code{border:0;background:transparent;padding:0;}
.md hr{border:0;border-top:1px solid var(--hair);margin:14px 0;}
.md strong{font-weight:680;}
.md a{color:var(--accent);}
.cs-scrim{position:fixed;inset:0;background:rgba(17,17,16,.34);z-index:2995;}
.cs-drawer{position:fixed;top:0;right:0;bottom:0;width:min(620px,94vw);z-index:3000;
background:var(--surface);border-left:1px solid var(--hair);display:flex;flex-direction:column;
@ -485,16 +537,19 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</div>
</div>
<!-- 顶栏 (2026-09-18 改成单行): 原来七枚胶囊各带长文字与说明气泡, 右侧再排六个按钮, 窗口不到 1950 就折成两行。
现在状态只留一个短词做成标签 (与综合分析列同款), 说明进悬停; 按钮收进右侧一个菜单。整行不折行, 放不下时按重要性从低到高收起。 -->
<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>
<div class="hd-tags">
<span class="hst" :class="marketOpen?'ok':'idle'" :title="'交易时段:' + marketState + (health.now ? '。服务器时间 ' + health.now : '')">{{ marketState }}</span>
<span class="hst hd-buy" :class="ov.buy_halt?'warn':'ok'" :title="ov.buy_halt ? '买入已暂停:系统不再发起新买入,已挂出未成交的买单已撤;卖出与止损照常。恢复买入在运维视图的「组合操作」。' : '买入正常:系统按规则自动买入。暂停买入在运维视图的「组合操作」。'">买入{{ ov.buy_halt ? '已暂停' : '正常' }}</span>
<span class="hst hd-exec" :class="ov.exec_halt?'warn':'ok'" :title="ov.exec_halt ? '执行已暂停:系统自动动作全部停下,只保留每日对账与日报。恢复执行在运维视图的「组合操作」。' : '执行正常:系统自动动作照常运行。暂停执行在运维视图的「组合操作」。'">执行{{ ov.exec_halt ? '已暂停' : '正常' }}</span>
<span class="hst" :class="ov.autonomy==='off'?'warn':'ok'" :title="'自主档位:' + tx('autonomy', ov.autonomy) + '。全自动=各项检查通过即直接下单;仅提议=先在「待确认提议」等人工确认;暂停执行=系统不自动动作。改档位在运维视图的「组合操作」。'">自主 {{ tx('autonomy', ov.autonomy) }}</span>
<!-- 计划日期是数据日 (2026-09-16 说明): 上游每个交易日早上 07:10 用上一交易日收盘后的证据出计划, 持仓系统 08:40 拉取,
所以正常显示的是上一交易日; 原来只印日期, 用户以为计划没更新。拉取时刻按持仓系统自己记的时间戳显示。 -->
<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 class="hd-plan-d" v-if="planStatus.ok!==false && planFetchedText"> · {{ planFetchedText }}</span><span class="hd-plan-d" v-if="planStatus.ok!==false && planStatus.plan_source"> · {{ planStatus.plan_source==='snapshot'?'快照':(planStatus.plan_source==='live'?'现算':planStatus.plan_source) }}<span v-if="planStatus.gate_on===true"> · 赛道闸开</span><span v-else-if="planStatus.gate_on===false"> · 赛道闸关</span><span class="help" data-tip="日期是数据日:上游每个交易日早上 07:10 用上一交易日收盘后的证据出计划,持仓系统 08:40 拉取,所以正常显示的是上一交易日。计划来源:快照=盘前落盘那份,毫秒返回、可复现;现算=盘中实时装配,较慢,多半是快照过旧回落。赛道闸=上游是否只在十条赛道内选票。">?</span></span></span>
<span class="chip hd-tech" :class="techChipCls"><span class="dot"></span>技术面<span class="hd-plan-d"> {{ techChipText }}</span><span class="help" data-tip="每晚算的技术面读数布林线、多空布林线、SAR是哪天的。绿今天的映射新鲜读不到或已过期技术面这一层没开。">?</span></span>
所以正常显示的是上一交易日; 拉取时刻按持仓系统自己记的时间戳显示。全文见悬停。 -->
<span class="hst hd-plan" :class="planStatus.ok===false?'warn':'ok'" :title="planTagTip">计划 {{ planTagText }}</span>
<span class="hst hd-tech" :class="techChipCls" :title="'技术面读数日 ' + techChipText + '。每晚算的技术面读数布林线、多空布林线、SAR是哪天的。蓝今天的映射新鲜读不到或已过期技术面这一层没开。'">技术面 {{ techChipText }}</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">
@ -527,24 +582,30 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</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>
<span class="hst hd-macro" :class="mzChipCls" :style="mzAdvicePending ? 'cursor:pointer' : ''" @click="macroChipClick" title="大盘冷热:看整个市场是偏热还是偏冷,用来决定要不要整体加仓或减仓。偏热时系统会自己停掉新买入。悬停看近日读数。">大盘 {{ mzChipText }}</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>
<span class="hst" :class="dm.shadow?'idle':'ok'" v-show="mode==='ops'" title="下发模式。实盘=真的发到 QMT 下单;影子=只记录,不真下单,用来观察。">下发 {{ dm.mode || '-' }}</span>
</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>
<div class="hd-acts">
<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>
<!-- 角色切换、选股计划、日报、导出、运维操作、自动刷新、退出 收进一个菜单; 菜单名写着当前用户与当前视图 -->
<el-dropdown trigger="click" @command="hdMenu">
<button class="tbtn hd-menu" :title="authed ? ('当前用户 ' + (me.username || me.phone || '') + (rolesText ? ' · ' + rolesText : '')) : ''">{{ hdMenuLabel }}<span class="car"></span></button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="isAdmin" command="view">切换到{{ mode==='ops' ? '交易员' : '运维' }}视图</el-dropdown-item>
<el-dropdown-item command="plan" :divided="isAdmin">选股计划</el-dropdown-item>
<el-dropdown-item command="report">日报</el-dropdown-item>
<el-dropdown-item v-if="authed" command="export" title="按公司《量化数据》模板导出当日持仓、平仓与净值(系统接管后的数据)">导出持仓表</el-dropdown-item>
<el-dropdown-item v-if="isAdmin && mode==='ops'" command="ops">运维操作</el-dropdown-item>
<el-dropdown-item command="auto" divided>自动刷新 {{ autoRefresh ? '开 · 点此关闭' : '关 · 点此开启' }}</el-dropdown-item>
<el-dropdown-item v-if="authed" command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<teleport to="body">
@ -1138,7 +1199,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<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>
<span class="ttl">待办与候选</span>
</div>
<div class="rail-body">
@ -1222,7 +1283,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<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>
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>
@ -1325,93 +1386,10 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</div>
<div class="muted" style="margin-top:6px;font-size:11.5px">
受益层级<span class="help" data-tip="这只票离主题多近:直接受益还是间接受益。上游的选股计划给的。">?</span>
和完整明细,在「上游计划」按钮里。</div>
和完整明细,在右上角菜单的「选股计划」里。</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>
@ -1551,6 +1529,88 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<el-tabs v-model="tab" type="border-card">
<!-- ==================== 组合操作 (2026-09-18 从交易员右栏移来: 组合级命令与纪律参数是管理员的事) ==================== -->
<el-tab-pane label="组合操作" name="portfolio">
<div class="ops-grid">
<div class="panel">
<h3>组合操作</h3>
<div class="sub2">对全部持仓生效。数值都按当前实际持仓算好并预览影响金额,不用盲填;危险动作要确认两次。</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 class="panel">
<h3>仓位与自主纪律</h3>
<div class="sub2">每组先给一句「当前状态」,再改其中的项。改完点保存就生效;改的是与「参数设置」页同一份运行参数。</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>
</el-tab-pane>
<!-- ==================== ① 参数设置 ==================== -->
<el-tab-pane label="参数设置" name="params">
<div class="row" style="margin-bottom:10px">
@ -1798,8 +1858,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</template>
<!-- 公司质地 · 失效条件 (2026-09-09 接入方案): 数据基座代码合成的质地档与评议给的失效条件, 只显示。 -->
<div class="muted" v-if="s.row.company && s.row.company.line" :title="s.row.company.text || ''" style="font-size:11px">
{{ s.row.company.line }}<template v-if="s.row.company.invalidation">;失效条件:{{ s.row.company.invalidation }}</template><a
v-if="s.row.company.report_url" :href="s.row.company.report_url" target="_blank" style="margin-left:6px">评析报告 ↗</a></div>
{{ s.row.company.line }}<template v-if="s.row.company.invalidation">;失效条件:{{ s.row.company.invalidation }}</template><span
v-if="s.row.company.report_url" class="lnk" style="margin-left:6px" @click.stop="openCompanyReport(s.row.ts_code)">评析报告</span></div>
<!-- 兜底只在既无逻辑状态又无公司评析时出 (2026-09-11 修): 原来这条是公司质地那行的 v-else,
一旦没有公司评析就把 logic_now.text 再印一遍 —— 而它上面已印过一次, 于是证据印两遍。 -->
<span v-if="!(s.row.logic_now && s.row.logic_now.state) && !(s.row.company && s.row.company.line)"
@ -2086,7 +2146,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<div class="cs-sect" v-if="cr(propDetailP) && cr(propDetailP).invalidation"><div class="h" style="color:var(--warn)">失效条件</div><div class="st">{{ cr(propDetailP).invalidation }}</div></div>
<div class="cs-sect" v-if="cr(propDetailP) && cr(propDetailP).antithesis"><div class="h" style="color:var(--up)">反方</div><div class="st">{{ cr(propDetailP).antithesis }}</div></div>
<div class="cs-sect" v-if="cr(propDetailP) && cr(propDetailP).catalyst"><div class="h">催化</div><div class="st muted">{{ cr(propDetailP).catalyst }}</div></div>
<div class="st" v-if="cr(propDetailP) && cr(propDetailP).report_url" style="margin-top:8px"><a :href="cr(propDetailP).report_url" target="_blank">查看完整报告 ↗</a></div>
<div class="st" v-if="cr(propDetailP) && cr(propDetailP).report_url" style="margin-top:8px"><span class="lnk" @click="openCompanyReport(propDetailP.ts_code)">查看完整评析报告</span></div>
<div class="st muted" v-if="!cr(propDetailP)">这只票没有公司深度评析。</div>
</template>
@ -2133,6 +2193,30 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</div>
</teleport>
<!-- ═══ 个股深度评析抽屉 (2026-09-18): 报告是上游给的 markdown 纯文本, 原来开新标签直接显示源码。
现在后端代取 (/api/research/{code}/report), 这里用 marked 排版成页面同款样式。层级压在个股详情抽屉 (3000) 之上,
因为它可以从个股详情里点开。 -->
<teleport to="body">
<div v-if="rp.open">
<div class="rp-scrim" @click="closeCompanyReport"></div>
<aside class="rp-drawer">
<div class="cs-h">
<span class="ttl">{{ nm(rp.code) }} · 个股深度评析</span>
<span class="muted mono" style="font-size:11.5px">{{ rp.code }}</span>
<span class="sp" style="flex:1"></span>
<span class="muted" style="font-size:11.5px" v-if="rp.meta">{{ rp.meta }}</span>
<a class="tbtn sm" v-if="rp.url" :href="rp.url" target="_blank" rel="noopener" title="在新标签打开上游原文(纯文本)">原文</a>
<button class="iconbtn" @click="closeCompanyReport" title="关闭"></button>
</div>
<div class="rp-b">
<div class="muted" v-if="rp.loading">正在读取评析报告…</div>
<div class="cs-warn" v-else-if="rp.error">{{ rp.error }}<el-button size="small" style="margin-left:6px" @click="openCompanyReport(rp.code)">重试</el-button></div>
<div class="md" v-else ref="rpBody" v-html="rp.html"></div>
</div>
</aside>
</div>
</teleport>
<!-- 运维抽屉 -->
<!-- ═══ 单票信号抽屉 (2026-09-09): 点一只票看它今天的全部信号 ═══
不用 el-drawer 是因为弹层库的层级从 2000 起自增, 信号栏浮层写死 2010、遮罩 2005,
@ -2182,7 +2266,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<div class="cs-detail" v-if="csDetail.fund">
<div v-for="(l,i) in csFundRow.lines" :key="'f'+i">{{ l }}</div>
<div v-if="csFundRow.invalidation" class="slow"><span class="k">失效条件</span>{{ csFundRow.invalidation }}</div>
<div v-if="csFundRow.report_url"><a :href="csFundRow.report_url" target="_blank" rel="noopener">评析报告</a></div>
<div v-if="csFundRow.report_url"><span class="lnk" @click="openCompanyReport(csCode)">评析报告全文</span></div>
<div v-if="!csFundRow.lines.length && !csFundRow.invalidation" class="muted">{{ csFundRow.empty }}</div>
</div>
<div class="prop-src-row" @click="csToggle('tech')">
@ -2535,7 +2619,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</div>
<script>
const { createApp, ref, reactive, computed, onMounted, onUnmounted, watch } = Vue;
const { createApp, ref, reactive, computed, onMounted, onUnmounted, watch, nextTick } = Vue;
const api = axios.create({ timeout: 60000 });
// 收到 401 就弹登录框: setup() 里把 _onUnauth 挂上 (拦截器在 setup 外, 够不到 authed)。
let _onUnauth = null;
@ -4327,6 +4411,74 @@ createApp({
: { changes: { ok: false, hint: (d || {}).error || '取数失败' }, log: {} };
chgLoading.value = false;
}
// ── 顶栏 (2026-09-18 单行化): 计划标签的短文与全文, 右侧菜单
const _ymdShort = d => { const t = String(d || ''); const m = t.match(/^(\d{4})-(\d{2})-(\d{2})/); return m ? m[2] + '-' + m[3] : (t || '-'); };
const planTagText = computed(() => { const p = planStatus.value || {}; return p.ok === false ? '读不到' : _ymdShort(p.date); });
const planTagTip = computed(() => {
const p = planStatus.value || {};
if (p.ok === false) return '选股计划读不到:' + (p.error || p.hint || '上游没有回应') + '。系统按没有候选处理。';
const src = p.plan_source === 'snapshot' ? '快照' : (p.plan_source === 'live' ? '现算' : (p.plan_source || ''));
const gate = p.gate_on === true ? '赛道闸开' : (p.gate_on === false ? '赛道闸关' : '');
const head = ['选股计划数据至 ' + (p.date || '-') + '(数据日)', planFetchedText.value, src, gate].filter(Boolean).join(' · ');
return head + '。日期是数据日:上游每个交易日早上 07:10 用上一交易日收盘后的证据出计划,持仓系统 08:40 拉取,所以正常显示的是上一交易日。计划来源:快照=盘前落盘那份,毫秒返回、可复现;现算=盘中实时装配,较慢,多半是快照过旧回落。赛道闸=上游是否只在十条赛道内选票。';
});
const hdMenuLabel = computed(() => {
const who = authed.value ? (me.value.username || me.value.phone || '') : '';
return (who ? who + ' · ' : '') + (mode.value === 'ops' ? '运维' : '交易员');
});
function hdMenu(c) {
if (c === 'view') mode.value = mode.value === 'ops' ? 'trader' : 'ops';
else if (c === 'plan') openPlan();
else if (c === 'report') openReport();
else if (c === 'export') doExport();
else if (c === 'ops') opsDrawer.value = true;
else if (c === 'auto') autoRefresh.value = !autoRefresh.value;
else if (c === 'logout') doLogout();
}
// ── 个股深度评析抽屉 (2026-09-18): 后端代取 markdown, 这里排版。上游正文里若夹带 HTML 一律按文本显示。
const rp = reactive({ open: false, code: '', url: '', html: '', meta: '', loading: false, error: '' });
const rpBody = ref(null);
const _escHtml = v => String(v == null ? '' : v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const _mdReady = (() => {
if (!window.marked || !window.marked.use) return false;
window.marked.use({ gfm: true, breaks: false,
renderer: { html(t) { return _escHtml(typeof t === 'string' ? t : (t && (t.raw || t.text)) || ''); } } });
return true;
})();
function _renderMd(md) {
if (!_mdReady) return '<pre style="white-space:pre-wrap">' + _escHtml(md) + '</pre>';
return window.marked.parse(md);
}
async function openCompanyReport(code) {
if (!code) return;
Object.assign(rp, { open: true, code, url: '', html: '', meta: '', loading: true, error: '' });
// 不走 call(): 取不到报告只在抽屉里说, 不再往顶部横幅重复报一次
let d;
try { d = (await api.get('/api/research/' + encodeURIComponent(code) + '/report')).data; }
catch (e) { d = { ok: false, error: '评析报告读取失败: ' + ((e && e.message) || '请求失败') }; }
if (!rp.open || rp.code !== code) return;
rp.loading = false;
if (!d || !d.ok) { rp.error = (d && d.error) || '评析报告读取失败'; return; }
rp.url = d.url || '';
const md = String(d.markdown || '');
const g = md.match(/生成\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})/), c = md.match(/数据截至\s*(\d{4}-\d{2}-\d{2})/);
rp.meta = [g ? '生成 ' + g[1] : '', c ? '数据截至 ' + c[1] : '', d.cached ? '缓存' : ''].filter(Boolean).join(' · ');
try { rp.html = _renderMd(md); } catch (e) { rp.error = '报告排版失败: ' + (e && e.message || e); return; }
await nextTick();
const el = rpBody.value; if (!el) return;
el.querySelectorAll('a').forEach(a => { a.target = '_blank'; a.rel = 'noopener'; });
// 表格: 数字表整行不折 (横向滚动), 文字表允许折行
el.querySelectorAll('table').forEach(t => {
if (t.parentElement && t.parentElement.classList.contains('tblwrap')) return;
const cells = Array.from(t.querySelectorAll('td'));
const longest = cells.reduce((m, td) => Math.max(m, (td.textContent || '').trim().length), 0);
const w = document.createElement('div'); w.className = 'tblwrap ' + (longest > 14 ? 'txt' : 'num');
t.parentNode.insertBefore(w, t); w.appendChild(t);
});
}
function closeCompanyReport() { rp.open = false; }
async function openPlan() { planDrawer.value = true; await Promise.all([loadPlan(), loadChanges()]); }
async function refreshPlan() {
planLoading.value = true;
@ -4503,7 +4655,7 @@ createApp({
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 });
const sectClosed = reactive({ prop:false, strat:false, pool:false });
// 提议卡上「展开证据」的状态, 按提议号记。用整体替换而不是原地增删,
// 因为 Vue 对 Set 的方法调用不作响应, 替换引用才会重画。
const propMoreOpen = ref(new Set());
@ -4646,7 +4798,10 @@ createApp({
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 };
sectClosed, toggleSect,
hdMenu, hdMenuLabel, planTagText, planTagTip, // 2026-09-18 顶栏单行化
// 2026-09-18 评析报告抽屉
rp, rpBody, openCompanyReport, closeCompanyReport };
}
}).use(ElementPlus).mount('#app');
</script>

69
app/web/static/vendor/marked.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -17,7 +17,8 @@
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫/
候选卡五键透传与截断/仅展示在候选阶段剔除/
研究理由两键透传与不进送研判名单 +
来源字段 plan_source/snapshot_generated_at/gate_on (§5.3) (38 )
来源字段 plan_source/snapshot_generated_at/gate_on (§5.3) +
评析报告代取: 链接只认上游/缓存十分钟/失败不缓存 (40 )
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 )
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (49 )
test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 +
@ -119,14 +120,14 @@
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) +
目标价到价自决关时入队自决开时自动清仓按可卖量夹紧挂着方案且有在途买单时先撤再清 +
用户设的止损价与目标价单独成列显示 (72 )
902
904
(总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条
减持与研究理由两键各一例, 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 654; 又加了页面文案守卫一例, 655; 09-07 审查修复加了跨轮减持等五例, 660; 第二件低把握驳回交人一例, 661; 第三件逻辑状态接入第二十二批十五例, 676; 第四件安全边际整句透传一例, 677; 参考目标价一例, 678; 催化事件与定价状态透传一例, 679; 两个期限的头一例, 现为 680; 09-10 建议档位对齐第二十六批十七例到 697; 09-11 技术面接入工作包一第二十七批二十七例到 724; 三源合议工作包二纯逻辑第二十八批三十七例到 763; 接入下单链路第二十九批二十四例到 787; 工作包三离场纪律第三十批十八例到 805; part3 盘中 SAR 止损线补九例到 814; part4 弱基本面紧止盈补六例到 820;
09-14 观察读数包第三十一批二十例到 840; 加固包给第二十八批加三例第三十批加三例到 846; 评审修订第三十一批加一例到 847; 09-14 盘中确认包第三十二批十九例到 866; 页面收尾包第三十三批五例到 871;
09-14 系统自决包第三十四批二十二例test_wiring 目标价自动清仓加一例到 894;
09-14 选股打分包 §5.3 来源字段给第七批加两例到 896; 页面专业化包加文案守卫一例到 897;
09-14 评审补第三十四批两例test_wiring 撤方案与在途买单一例到 900;
09-16 提议失效不跨自然日给第二十批加两例, 现为 902)
09-16 提议失效不跨自然日给第二十批加两例902; 09-18 评析报告代取给第七批加两例, 现为 904)
任一子集失败即整体失败 (退出码 1)
哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了):

View File

@ -15,6 +15,7 @@ import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services import plan_feed as pf # noqa: E402
from app.services import company_report as crp # noqa: E402
RESULTS = []
@ -671,6 +672,49 @@ def _():
assert pf.FAIL_CACHE_SEC > 0, "失败也要缓存一会儿, 否则每分钟的调度位会把超时叠成雪崩"
@case("评析报告·链接只认上游: 持仓票取逻辑状态, 候选票取当日计划, 都没有回 none 且 ok False 带原因")
def _():
sm = {"300627.SZ": {"company_review": {"overall": "", "report_url": "http://base/company/300627.SZ/review"}},
"000001.SZ": {"company_review": {"overall": "", "report_url": "javascript:alert(1)"}}}
plan = {"main": [{"ts_code": "300750.SZ", "company_review": {"report_url": "http://base/company/300750.SZ/review"}}],
"observe": [{"ts_code": "688981.SH", "company_review": {"report_url": "https://base/company/688981.SH/review"}}]}
assert crp.report_url_for("300627.SZ", state_map=sm, plan=plan) == ("http://base/company/300627.SZ/review", crp.SRC_HELD)
assert crp.report_url_for("300750.SZ", state_map=sm, plan=plan) == ("http://base/company/300750.SZ/review", crp.SRC_CAND)
assert crp.report_url_for("688981.SH", state_map=sm, plan=plan) == ("https://base/company/688981.SH/review", crp.SRC_CAND)
# 非 http 链接一律不认; 完全不在的票回 none
assert crp.report_url_for("000001.SZ", state_map=sm, plan=plan) == (None, crp.SRC_NONE)
assert crp.report_url_for("600000.SH", state_map=sm, plan=plan) == (None, crp.SRC_NONE)
d = crp.get("600000.SH", state_map=sm, plan=plan)
assert d["ok"] is False and d["url"] is None and "没有个股深度评析报告的链接" in d["error"]
assert set(d) == {"ok", "ts_code", "url", "source", "markdown", "cached", "fetched_at", "error"}
@case("评析报告·缓存: 同一链接十分钟内不再拉取, 过期后重拉; 拉取失败不抛、不缓存、原因进 error")
def _():
crp.invalidate()
sm = {"300627.SZ": {"company_review": {"overall": "", "report_url": "http://base/company/300627.SZ/review"}}}
calls = []
def ok_getter(url, timeout):
calls.append(url)
return "# 华测导航300627.SZ个股深度评析\n\n> 生成 2026-09-18 00:05\n"
d1 = crp.get("300627.SZ", now=1000.0, getter=ok_getter, state_map=sm, plan={})
assert d1["ok"] and d1["cached"] is False and d1["markdown"].startswith("# 华测导航") and d1["source"] == "held"
d2 = crp.get("300627.SZ", now=1000.0 + crp.CACHE_SEC - 1, getter=ok_getter, state_map=sm, plan={})
assert d2["ok"] and d2["cached"] is True and len(calls) == 1, "十分钟内该命中缓存"
d3 = crp.get("300627.SZ", now=1000.0 + crp.CACHE_SEC + 1, getter=ok_getter, state_map=sm, plan={})
assert d3["ok"] and d3["cached"] is False and len(calls) == 2, "过期后要重拉"
crp.invalidate()
def bad_getter(url, timeout):
raise ValueError("boom")
d4 = crp.get("300627.SZ", now=5000.0, getter=bad_getter, state_map=sm, plan={})
assert d4["ok"] is False and "boom" in d4["error"] and d4["url"] and d4["markdown"] == ""
assert not crp._cache, "失败不缓存"
def empty_getter(url, timeout):
return " "
d5 = crp.get("300627.SZ", now=5000.0, getter=empty_getter, state_map=sm, plan={})
assert d5["ok"] is False and "为空" in d5["error"]
def main():
import logging
logging.disable(logging.CRITICAL)

View File

@ -663,6 +663,7 @@ def _():
"/api/upstream/signals/{ts_code}", # 2026-09-09 点股票看它今天全部信号
"/api/tech/status", "/api/ops/tech-pull", # 2026-09-11 技术面状态与手动拉取
"/api/research/{ts_code}", # 2026-09-11 单票研究面 (工作包一给技术面块)
"/api/research/{ts_code}/report", # 2026-09-18 个股深度评析全文代取, 页面排版
"/api/ops/strategy-attach-scan"] # 2026-08-25 策略自动挂载试算位
missing = [p for p in need if p not in paths]
assert not missing, missing