Compare commits

..
77 changed files with 7315 additions and 3803 deletions
+6 -2
View File
@@ -95,8 +95,12 @@ background scheduler
six-line input validation and safety gates belong to `manual.py`; trend setup, market mode,
source disclosure, and quality checks belong to `trend.py`; stock, index, and sector context
collection belongs to `market_context.py`; personal fields, hexagrams, saved readings, and
model interpretation belong to `readings.py`. These owners cooperate through the composed
service object and do not duplicate or delegate method bodies through the facade.
interpretation orchestration belong to `readings.py`; deterministic Jing Fang Na Jia, eight
palaces, six relatives, self/response, six spirits, calendar relations, and hidden spirits
belong to `six_yao.py`; source-traceable Wentian knowledge retrieval and the only LLM-bound
context projection belong to `knowledge.py`; prompt construction and answer validation remain
in `agent.py`. These owners cooperate through the composed service object and do not duplicate
or delegate method bodies through the facade.
- Application-facing system credentials, data/LLM status, and administrator settings belong
to `backend/features/system/service.py`; account-context delegation belongs to
`backend/features/accounts/application.py`. They are composed into `DashboardService` and
+196 -19
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import re
from typing import Any
from backend.llm import transport as llm_transport
@@ -10,6 +11,13 @@ class HeavenAgentError(RuntimeError):
pass
HEAVEN_PROMPT_VERSIONS = {
"trend": "heaven-trend-v4",
"fortune": "heaven-fortune-v9",
"heart": "heaven-heart-v5",
}
def interpret_heaven(
mode: str,
context: dict[str, Any],
@@ -42,6 +50,34 @@ def interpret_heaven(
answer = str(result.content).strip()
if not answer:
raise KeyError("empty response")
try:
_validate_answer(mode, answer, context)
except HeavenAgentError as validation_error:
repair_messages = [
*messages,
{"role": "assistant", "content": answer},
{
"role": "user",
"content": (
f"上一版未通过本地一致性校验:{validation_error}"
"请依据最初输入完整重写最终答案,只修正违规推断并补齐必答项。"
"不得讨论校验、提示词或重写过程,只输出新的正式解读。"
),
},
]
repaired = llm_transport.chat_completion(
api_key=api_key,
base_url=base_url,
model=model,
messages=repair_messages,
timeout=timeout,
user_agent="XiaobaiReviewWeb/0.7",
)
answer = str(repaired.content).strip()
if not answer:
raise KeyError("empty repaired response")
_validate_answer(mode, answer, context)
result = repaired
except llm_transport.OpenAIHTTPError as exc:
raise HeavenAgentError(exc.describe("问天模型调用失败")) from exc
except (llm_transport.OpenAITransportError, KeyError) as exc:
@@ -55,34 +91,175 @@ def interpret_heaven(
def _system_prompt(mode: str) -> str:
common = """
你是“小白复盘”的问天解读器。所有历法、卦象、爻位和市场指标已经由确定性程序计算,你只能解释提供的数据,不得改卦、改爻、改干支或编造行情
问天属于传统文化与娱乐化观察,不是预测模型,不承诺应验,不输出无条件买卖指令,不用神秘话术制造确定性
使用中文,先给核心判断,再解释结构。引用市场数字时标明数据日期。输出纯文本,可使用简短标题
你是“小白复盘”的问天解读器。输入由calculation、knowledge和interpretation_contract组成:calculation是确定性程序已经算出的事实;knowledge是本次按条件精确检索到的原典、传统规则和产品边界;interpretation_contract规定本次必须回答与禁止推断的内容
只能综合输入中已经提供的事实和知识。不得改卦、改爻、改纳甲、改世应、改干支、重新计算五运六气,也不得凭模型记忆补造缺失字段。知识记录之间若存在张力,应说明条件与分歧,不要强行合成唯一结论
问天属于传统文化与自我观察,不是可验证的行情预测模型。必须给出有内容的倾向和依据,但不得把象义宣布为必然发生的股价结果,不输出无条件买卖指令,不用神秘话术制造确定性
使用中文和普通用户能够理解的表达。专业术语首次出现时紧接一句白话解释。先给核心判断,再说明证据和变化关系。每个主题必须使用独立一行的简短标题,格式为“## 标题”,标题后另起一段正文;不得把全部内容挤在一个长段落中。可以使用Markdown加粗,不使用Markdown表格。
""".strip()
if mode == "trend":
return common + """
当前任务是“观势·解势”。六爻从初爻到上爻依次是个股内核、个股外显、板块内核、板块外显、指数内核、指数外显;初二为地、三四为人、五上为天
行情数据只负责生成六爻,本次解势必须以卦象本身为主,不得根据指数涨跌、板块强弱、涨停家数、成交量或个股表现直接推演方向。context中不会提供这些数字,也不会提供爻位对应的市场角色
先解释本卦卦名的核心义、上下卦组合及大象;再只解释实际动爻所代表的转折,并说明本卦如何走向之卦;最后可把这一组卦势翻译成克制的市场语言
重点是“本卦为当下之势,动爻为变化关节,之卦为所趋之势”。不要说明某一动爻对应指数、板块或个股,也不要输出“一看指数、二看涨停家数”一类行情观察条件
全文控制在300至450个中文字符,最多四小段。卦理约占九成,市场翻译最多一句,只能落到节制、等待、守信、辨伪等行为态度,不得据此预测市场下一阶段、涨跌方向或动能变化。不直接荐股,不使用Markdown表格
不要使用“必然、确定、必涨、必跌、后续将、进入某阶段”等断语;天机只点出势的性质与变化关系,不替用户宣布结果。
当前任务是“观势·解势”。行情只负责在进入模型之前生成卦象,本次回答不得引用或反推指数涨跌、成交额、涨跌停、板块强弱或个股表现,也不得说明某一爻原先对应哪类市场指标
calculation中有意不提供股票、行业和板块身份。不得猜测或讨论观察对象所属行业、政策、消费环境、基本面、资金面或任何现实市场变量;只解释已经生成的卦象
必须明确给出卦义上的当下倾向、主要矛盾、实际动爻所的转折,以及本卦走向之卦后的变化方向。允许使用偏进、偏守、先难后易、由盛转收、转机有限、内外相违或结论有条件等相对判断;不得只罗列卦辞,也不得用“谨慎、等待、守信、辨伪”一类泛化劝诫代替解势
以knowledge中本卦、上下卦、卦辞、彖义、大象、实际动爻和之卦记录为依据。无动爻、一动爻和多动爻分别服从本次检索到的方法规则;多动爻有冲突时必须指出冲突,不得压成单一套话
按“## 核心判断、## 卦势依据、## 动爻转折、## 之卦趋向、## 决策映射”组织答案;无动爻时仍保留“动爻转折”,明确说明本次无动爻并解释结构的延续条件。结尾可以把卦势翻译成克制的交易决策语言,但只能表达条件、节奏和需要验证的矛盾,不得预测具体涨跌、价格、日期或给出直接荐股结论。篇幅随动爻数量自然展开,不设置固定字数
""".strip()
if mode == "fortune":
return common + """
当前任务是“观气·解运”。严格区分五运、六气、节气、月令和日干,不把丙午简单解释为火年
严格服从five_phase_field.framework提供的确定性结构,不自行重新计算五行:年纲由中运司天在泉构成;岁半以前司天为主、在泉为辅,岁半以后在泉为主、司天为辅;当前六气层以客气加临主气为核心;日辰只负责触发。节气只用于定位当前六气阶段,不得再次叠加为独立力量
重点解释framework.relations中的客主同气、客生主、主生客、客克主或主克客,以及客胜为从、主胜为逆、司天在泉同位、天符岁会等已经判定的关系。不得把司天、在泉、主气、客气视为彼此独立的证据重复计权,也不得自行增删传统格局
首要解释当日气场容易放大参与者的哪些情绪、判断偏差和操作冲动,例如急躁、恐惧、迟疑、追涨、过早止损或路径依赖;再给出一至两个调节动作
如有personal_profile,结合其日主、十神、五行平衡倾向说明当日对该用户主观状态的影响,但不得把简化平衡倾向说成唯一喜用神,也不得复述或猜测出生日期
不得引用市场上涨下跌家数、涨跌停数量、成交额、板块强度或个股表现来证明气场。industry_affinity只是五行行业取象示例,不是行情旁证;行业契合度最多在末尾用一句话说明,不得写“当日共振”或暗示相关行业必然涨跌
全文控制在420至600个中文字符,按“三层气机、人的状态、操作偏向、个人影响(如有)、制衡动作”组织,标题必须写“三层气机”。明确这些是传统历法框架下的观察语言,不宣称气候或五行直接导致股价
当前任务是“观气·解运”。页面用于直观展示的五行权重、主导元素和预制复合断语已明确排除,不得自行恢复这些结果,也不得按百分比重新生成单一五行结论
严格区分中运司天在泉、当前主气客气、节气定位和日辰触发。先解释中运与司天在泉构成的年纲,再解释客气加临主气的当前关系,最后说明日辰如何触发;不得把同一项拆成多份证据重复计权。相生不直接等于吉,相克不直接等于凶
必须使用knowledge中与本日中运、六气和客主关系精确匹配的记录。可形成“湿热交蒸、燥中夹滞”一类复合表达,但要从输入关系逐层说明,不能从页面权重结论倒推
如calculation.personal存在,只结合日主、十神和当日派生关系说明用户容易出现的主观感受与判断偏差;不得使用简化强弱、喜用神、出生日期或权重平衡结论
personal.natal_day_master才是用户本命日主;today_relative_to_natal_day_master.pillars是当日历法,不是用户出生四柱。stem_relations只是当日年、月、日三柱天干相对本命日主的程序结果,只能使用knowledge中本次命中的关系释义,不得自行重算十神或扩展五行生克过程。不得使用藏干、支中藏某干、某支为某库或燥湿属性等输入未提供的信息,也不得把当日日柱写成用户命局,或推断用户命局中某个十神“较重”、身强身弱或喜用神
日辰只按calculation.day_trigger.summary与knowledge中的日辰边界解释,不得从干支另行推导藏气、库气或五行生克链。个人合参不得宣称本命日主被当日某气生扶、泄耗或克制,只能说明已给关系标签可能对应的主观注意点
day_trigger.summary中的日干运势、地支五行和六气对应是三个并列的确定性事实。不得把日柱整体改写成某一种五行,也不得把地支与六气的“对应”改写成地支自身具有某种六气属性
calculation.industry_symbols只提供五行与行业的传统取象归类及其本次出现依据。必须说明这些气机对相关行业可能形成的象征性关注、节奏或约束,但不得引入行业实时行情,不得预测行业涨跌或把取象写成投资推荐;未列入industry_symbols的行业不得自行补造。
按“## 年纲、## 客主加临、## 日辰触发、## 行业影响、## 个人合参、## 制衡动作”组织答案;没有个人资料时可以省略“个人合参”。不得用行情上涨下跌、行业表现或个股结果证明运气关系。篇幅按实际关系自然展开,不设置固定字数。
""".strip()
return common + """
当前任务是“观心·解卦”。用户的问题始终只在心中,没有输入给你,因此你不能猜测问题内容,也不能替用户作具体决定
全文控制在180至350个中文字符。只写一句卦意;一小段动爻与之卦;最后三句极短的问心句
不要重述六条爻辞,不猜用户未说出口的问题,不以吉凶二字替代思考,不给出股票涨跌预测。语气安静、克制,越短越有余味
当前任务是“观心·解卦”。用户在起卦前确定的问题位于calculation.questionquestion_preset只说明问题来源。必须针对实际问题作答;无题观心时不得猜测用户没有说出的事项
question_scope是本次问题的产品边界。trade预设专指股票交易中的参与条件、机会、阻碍和风险,不是商业合作、融资、借贷或寻找资金方;除非用户问题明确写出这些背景,否则不得擅自补入
纳甲、卦宫、世应、六亲、六神、月建日辰、旬空、伏神、动变和冲合关系已经由确定性程序给出。只能解释这些结果,不得自行改排盘、补用神或用模型记忆重算。六神只作辅助,任何单项都不能独立决定结论
六亲是关系类别,不是现实人物或资金来源的一一映射。必须使用knowledge中的六亲、旬空、动变和六神边界;不得把妻财直接写成现金或融资,把子孙写成资金提供方,把兄弟写成合作方,也不得由某一六神直接推出紧迫、欺骗或吉凶。
除非calculation.question明确说明用户已经持仓、买入、卖出或正在管理仓位,否则不得假定用户已经入场,不得使用“持仓、仓位、建仓、入场、持有、买入、卖出、止损、止盈”等措辞描述用户现状。可以只写尚待核对的参与条件、风险边界和决策倾向。
除非问题明确涉及融资、借贷、合作或资源安排,否则不得制造外围资金、外围资源、资金进入、资源进入,也不得虚构资金或资源的来源、提供、注入、安排和路径。
按“## 所问之答、## 卦象依据、## 动变与之卦、## 可验证之处”组织答案。先直接回应所问,再用白话解释本卦所示处境、世应与相关六亲、关键动爻和变爻,最后说明之卦趋向及一项可以由用户验证的动作。若证据相互冲突,应明确说明结论成立的条件,不以“吉、凶”二字替代推理。
交易问题可以判断参与条件、内外阻碍、风险和决策倾向,但不得宣告具体股价、涨跌日期或替用户作无条件买卖决定。不得用旬空、填实、出空或干支日推算“未来几日”或某日应验;可验证动作必须是用户当下能核对的交易条件或自身判断,不能制造现实中不存在的合作方、承诺、资金或资源安排。心境问题聚焦念头、压力和盲点;无题观心只作一般卦象观照。篇幅随问题和动爻复杂度自然展开,不使用固定三句模板,也不得输出使用竖线分栏的Markdown表格。
""".strip()
def _validate_answer(
mode: str, answer: str, context: dict[str, Any] | None = None
) -> None:
compact = "".join(answer.split())
if len(compact) < 60:
raise HeavenAgentError("问天模型返回内容过短,未形成有效解读。")
forbidden = ("必涨", "必跌", "保证上涨", "保证下跌", "无条件买入", "无条件卖出")
if any(term in answer for term in forbidden):
raise HeavenAgentError("问天模型返回了禁止的确定性行情断语。")
if mode == "fortune" and "%" in answer:
raise HeavenAgentError("解运结果错误引用了已排除的权重百分比。")
if mode == "trend":
market_narratives = (
"行业", "板块", "个股", "指数", "成交额", "涨停", "跌停",
"政策", "消费环境", "基本面", "资金面",
)
if any(term in answer for term in market_narratives):
raise HeavenAgentError("解势结果错误引入了卦象之外的现实市场叙事。")
if mode == "fortune":
if re.search(
r"(?:命局|个人本身).{0,16}(?:偏重|较重|过旺|过弱|身强|身弱|喜用神)",
answer,
):
raise HeavenAgentError("解运结果错误推断了输入中不存在的命局强弱。")
if re.search(
r"藏干|[子丑寅卯辰巳午未申酉戌亥](?:中|内)|[子丑寅卯辰巳午未申酉戌亥].{0,4}(?:含|藏)|(?:中|内)藏|余气|[辰戌丑未].{0,4}(?:火库|水库|金库|木库|土库|燥土|湿土)",
answer,
):
raise HeavenAgentError("解运结果使用了输入中未提供的藏干推断。")
if re.search(
r"木生火|火生土|土生金|金生水|水生木|木克土|土克水|水克火|火克金|金克木",
answer,
):
raise HeavenAgentError("解运结果自行扩展了输入中未提供的五行生克链。")
if re.search(
r"(?:本命)?日主.{0,32}(?:生扶|泄耗|受克|被克|得生|被生|偏强|偏弱)",
answer,
):
raise HeavenAgentError("解运结果把当日关系错误扩展成了本命强弱推断。")
if re.search(
r"(?:日柱)?[甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥]"
r".{0,8}(?:本身|自身)(?:就)?是[木火土金水]",
answer,
):
raise HeavenAgentError("解运结果错误地把整个日柱归成了单一五行。")
if re.search(
r"[子丑寅卯辰巳午未申酉戌亥](?:的|具有|带有).{0,8}"
r"(?:风木|君火|湿土|相火|燥金|寒水)(?:之)?(?:属性|性质)",
answer,
):
raise HeavenAgentError("解运结果把六气对应误写成了地支自身属性。")
calculation = (context or {}).get("calculation") or {}
if calculation.get("industry_symbols") and "行业影响" not in answer:
raise HeavenAgentError("解运结果遗漏了本次必答的行业影响。")
if re.search(
r"行业.{0,16}(?:必涨|必跌|必然上涨|必然下跌|确定领涨|确定领跌|投资推荐)",
answer,
):
raise HeavenAgentError("解运结果把行业取象错误写成了行情预测或投资推荐。")
personal = calculation.get("personal") or {}
personal_today = personal.get("today_relative_to_natal_day_master") or {}
pillar_values = {
str(value)
for group in (calculation.get("pillars") or {}, personal_today.get("pillars") or {})
for value in group.values()
if value
}
mentioned_pillars = set(
re.findall(r"[甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥]", answer)
)
if mentioned_pillars - pillar_values:
raise HeavenAgentError("解运结果补入了确定性输入中不存在的干支。")
month_pillar = str((calculation.get("pillars") or {}).get("month") or "")
month_branch = month_pillar[1:2]
mentioned_month_branches = set(
re.findall(r"([子丑寅卯辰巳午未申酉戌亥])月", answer)
)
if mentioned_month_branches - ({month_branch} if month_branch else set()):
raise HeavenAgentError("解运结果补入了当前月份之外的地支月。")
if personal:
relations = {
str(value)
for value in (personal_today.get("stem_relations") or {}).values()
if value
}
if "个人合参" not in answer and "本命日主" not in answer:
raise HeavenAgentError("解运结果遗漏了本次必答的个人合参。")
if relations and not any(relation in answer for relation in relations):
raise HeavenAgentError("解运结果未使用程序提供的当日关系标签。")
if mode != "heart":
return
calculation = (context or {}).get("calculation") or {}
question = str(calculation.get("question") or "")
if _contains_markdown_table(answer):
raise HeavenAgentError("解卦结果错误输出了Markdown表格。")
position_terms = (
"持仓", "仓位", "建仓", "入场", "持有", "买入", "卖出", "止损", "止盈",
)
if not any(term in question for term in position_terms) and any(
term in answer for term in position_terms
):
raise HeavenAgentError("解卦结果擅自假定了用户的持仓或买卖状态。")
financing_terms = (
"融资", "借贷", "合作", "出资", "资金来源", "资金方", "投资人", "投资方",
"外部资金", "外围资金", "外部资源", "外围资源",
)
invented_scenarios = (
"融资", "借贷", "合作方", "资金提供方", "资金意向", "资金注入", "自有资金",
"外围资金", "外围资源", "资金进入", "资源进入",
)
invented_resource_path = re.search(
r"(?:资金|资源).{0,8}(?:来源|提供|注入|安排|路径)", answer
)
if not any(term in question for term in financing_terms) and (
any(term in answer for term in invented_scenarios) or invented_resource_path
):
raise HeavenAgentError("解卦结果擅自补入了用户没有提出的融资或合作场景。")
timing_patterns = (
r"未来\s*[一二三四五六七八九十\d]+\s*(?:个)?(?:交易)?日",
r"[子丑寅卯辰巳午未申酉戌亥]{1,2}日(?:到来|来临|之前|之后|前后)",
r"(?:等待|等到|待).{0,16}(?:旬空|空亡).{0,16}(?:填实|出空)",
)
if any(re.search(pattern, answer) for pattern in timing_patterns):
raise HeavenAgentError("解卦结果错误使用旬空或干支推算了具体应期。")
def _contains_markdown_table(answer: str) -> bool:
return bool(
re.search(r"(?m)^\s*\|", answer)
or re.search(r"(?m)^\s*:?-{3,}:?\s*\|", answer)
or re.search(r"(?m)\|\s*:?-{3,}:?\s*(?:\||$)", answer)
)
+2
View File
@@ -733,6 +733,7 @@ def hexagram_from_lines(values: list[int]) -> dict[str, Any]:
return {
"name": primary["name"],
"text": primary["text"],
"tuan": primary.get("tuan") or "",
"image": primary.get("image") or "",
"inner_trigram": inner,
"outer_trigram": outer,
@@ -741,6 +742,7 @@ def hexagram_from_lines(values: list[int]) -> dict[str, Any]:
"transformed": {
"name": transformed["name"],
"text": transformed["text"],
"tuan": transformed.get("tuan") or "",
"image": transformed.get("image") or "",
"inner_trigram": transformed_inner,
"outer_trigram": transformed_outer,
+387
View File
@@ -0,0 +1,387 @@
from __future__ import annotations
import json
from functools import lru_cache
from typing import Any
from backend.bootstrap.config import APP_DIR
KNOWLEDGE_FILE = APP_DIR / "data" / "heaven_knowledge.json"
def prepare_heaven_context(mode: str, calculation: dict[str, Any]) -> dict[str, Any]:
"""Build the only context shape that may cross the LLM boundary."""
if mode == "trend":
prepared = _prepare_trend(calculation)
elif mode == "fortune":
prepared = _prepare_fortune(calculation)
elif mode == "heart":
prepared = _prepare_heart(calculation)
else:
raise ValueError("不支持的问天知识模式。")
prepared["knowledge"] = retrieve_heaven_knowledge(mode, prepared)
return prepared
def retrieve_heaven_knowledge(mode: str, context: dict[str, Any]) -> dict[str, Any]:
catalog = _knowledge_catalog()
source_ids: list[str]
records: list[dict[str, Any]]
if mode == "trend":
source_ids = ["zhouyi"]
records = _trend_records(catalog, context)
elif mode == "fortune":
source_ids = ["neijing"]
records = _fortune_records(catalog, context)
elif mode == "heart":
source_ids = ["zhouyi", "jingfang", "huozhulin", "zengshan"]
records = _heart_records(catalog, context)
else:
raise ValueError("不支持的问天知识模式。")
return {
"version": str(catalog.get("version") or ""),
"retrieval": "deterministic-keyed",
"sources": [
{"id": source_id, **dict(catalog["sources"][source_id])}
for source_id in source_ids
],
"records": records,
}
def _prepare_trend(context: dict[str, Any]) -> dict[str, Any]:
return {
"mode": "trend",
"calculation": {
"data_trade_date": context.get("data_trade_date") or "",
"hexagram": context.get("hexagram") or {},
"movement": context.get("movement") or {},
},
"interpretation_contract": {
"required": ["明确卦势倾向", "主要矛盾", "实际动爻转折", "本卦到之卦的变化关系"],
"allowed": ["偏进或偏守", "先难后易或由盛转收", "结论有条件或存在分歧"],
"forbidden": ["原始行情旁证", "具体涨跌预测", "时间点预测", "无条件买卖指令", "泛化劝诫代替解卦"],
},
}
def _prepare_fortune(context: dict[str, Any]) -> dict[str, Any]:
field = context.get("five_phase_field") or {}
framework = field.get("framework") or {}
relations = framework.get("relations") or {}
layers = {
str(item.get("id") or ""): item
for item in framework.get("layers") or []
if isinstance(item, dict)
}
six_qi = field.get("six_qi") or {}
movement = field.get("movement") or {}
pillars = field.get("pillars") or {}
personal = context.get("personal_profile") or {}
sector_catalog = {
str(group.get("element") or ""): [
str(item.get("name") or "").strip()
for item in group.get("industries") or []
if str(item.get("name") or "").strip()
]
for group in field.get("sector_catalog") or []
if isinstance(group, dict)
}
industry_bases: dict[str, list[str]] = {}
def add_industry_basis(element: str, basis: str) -> None:
if element not in sector_catalog or not sector_catalog[element]:
return
industry_bases.setdefault(element, [])
if basis not in industry_bases[element]:
industry_bases[element].append(basis)
add_industry_basis(str(movement.get("phase") or ""), "中运")
for label, qi in (
("司天", six_qi.get("sitian")),
("在泉", six_qi.get("zaiquan")),
("主气", six_qi.get("host_qi")),
("客气", six_qi.get("guest_qi")),
):
qi_text = str(qi or "")
add_industry_basis(qi_text[-1:] if qi_text else "", label)
day_master = personal.get("day_master") or {}
current = personal.get("current") or {}
personal_context = {}
if day_master:
current_ten_gods = current.get("ten_gods") or {}
personal_context = {
"natal_day_master": {
"stem": day_master.get("stem") or "",
"element": day_master.get("element") or "",
},
"today_relative_to_natal_day_master": {
"pillars": current.get("pillars") or {},
"stem_relations": {
key: str((current_ten_gods.get(key) or {}).get("stem") or "")
for key in ("year", "month", "day")
},
},
}
return {
"mode": "fortune",
"calculation": {
"calendar_date": context.get("calendar_date") or field.get("date") or "",
"lunar_date": field.get("lunar_date") or "",
"pillars": {
"year": pillars.get("year") or "",
"month": pillars.get("month") or "",
"day": pillars.get("day") or "",
},
"solar_terms": field.get("solar_terms") or {},
"year_movement": {
"phase": movement.get("phase") or "",
"tendency": movement.get("tendency") or "",
"label": movement.get("label") or "",
},
"annual_qi": {
"sitian": six_qi.get("sitian") or "",
"zaiquan": six_qi.get("zaiquan") or "",
"ruling": six_qi.get("ruling") or "",
"ruling_qi": six_qi.get("ruling_qi") or "",
"annual_pattern": relations.get("annual_pattern") or {},
},
"current_qi": {
"step": six_qi.get("step"),
"step_name": six_qi.get("step_name") or "",
"host_qi": six_qi.get("host_qi") or "",
"guest_qi": six_qi.get("guest_qi") or "",
"guest_host_relation": relations.get("guest_host") or {},
"alignment": relations.get("alignment") or six_qi.get("alignment") or "",
},
"day_trigger": {
"day_pillar": pillars.get("day") or "",
"summary": (layers.get("day") or {}).get("summary") or "",
},
"industry_symbols": [
{
"element": element,
"basis": bases,
"industries": sector_catalog[element],
}
for element, bases in industry_bases.items()
],
"personal": personal_context,
},
"excluded_from_interpretation": [
"五行权重与百分比",
"主导元素排序",
"权重生成的复合断语",
"预制情绪与交易行为结论",
"行业实时行情旁证",
"简化喜用神与强弱结论",
],
"interpretation_contract": {
"required": ["年纲", "当前客主加临", "日辰触发", "行业影响", "个人合参(如有)", "制衡动作"],
"forbidden": [
"重新计算五行权重",
"把相生直接判吉",
"把相克直接判凶",
"用市场涨跌证明气场",
"把行业取象写成行业涨跌预测或投资推荐",
"把当日日柱误称为用户命局",
"推断未提供的命局强弱或喜用神",
],
},
}
def _prepare_heart(context: dict[str, Any]) -> dict[str, Any]:
preset = str(context.get("question_preset") or "custom")
if preset not in {"trade", "mind", "unthemed", "custom"}:
preset = "custom"
return {
"mode": "heart",
"calculation": {
"question": str(context.get("question") or "").strip(),
"question_preset": preset,
"question_scope": {
"trade": "股票交易中的参与条件、机会、阻碍与风险,不是融资或商业合作问题。",
"mind": "影响股票交易判断的情绪、执念或盲点。",
"unthemed": "不指定事项的一般观照。",
"custom": "只按用户实际写出的事项理解,不补写背景。",
}[preset],
"ritual": context.get("ritual") or {},
"hexagram": context.get("hexagram") or {},
"six_yao": context.get("six_yao") or {},
},
"interpretation_contract": {
"required": ["回应所问", "本卦处境", "世应与相关六亲", "关键动变", "之卦趋向", "可验证动作"],
"plain_language": "专业术语首次出现时立即用白话解释。",
"forbidden": [
"修改纳甲排盘",
"猜测未输入的问题",
"把股票交易改写成融资或合作问题",
"把六亲直接等同于现实人物或资金来源",
"单凭六神或空亡断吉凶",
"根据旬空填实或干支日期预测应期",
"具体股价和时间点预测",
"无条件买卖指令",
],
},
}
def _trend_records(catalog: dict[str, Any], context: dict[str, Any]) -> list[dict[str, Any]]:
hexagram = (context.get("calculation") or {}).get("hexagram") or {}
moving = [line for line in hexagram.get("lines") or [] if line.get("moving")]
method_key = "stable" if not moving else "single" if len(moving) == 1 else "multiple"
rules = catalog["trend"]["rules"]
records = [
{"id": "trend-method", "source": "product_method", "text": catalog["trend"]["method"]},
{"id": f"trend-moving-{method_key}", "source": "product_method", "text": rules[method_key]},
_hexagram_record("primary", hexagram),
]
records.extend(_line_record(line) for line in moving)
transformed = hexagram.get("transformed") or {}
if transformed:
records.append(_hexagram_record("transformed", transformed))
return records
def _fortune_records(catalog: dict[str, Any], context: dict[str, Any]) -> list[dict[str, Any]]:
calculation = context.get("calculation") or {}
movement = calculation.get("year_movement") or {}
annual_qi = calculation.get("annual_qi") or {}
current_qi = calculation.get("current_qi") or {}
knowledge = catalog["fortune"]
records = [
{"id": "fortune-principle", "source": "neijing", "text": knowledge["principle"]},
]
if calculation.get("industry_symbols"):
records.append(
{
"id": "fortune-industry-boundary",
"source": "product_method",
"text": knowledge["industry_boundary"],
}
)
personal = calculation.get("personal") or {}
if personal:
records.append(
{
"id": "fortune-personal-boundary",
"source": "product_method",
"text": knowledge["personal_boundary"],
}
)
today = personal.get("today_relative_to_natal_day_master") or {}
relation_semantics = knowledge.get("personal_relations") or {}
for relation in dict.fromkeys((today.get("stem_relations") or {}).values()):
if relation in relation_semantics:
records.append(
{
"id": f"fortune-personal-{relation}",
"source": "product_method",
"subject": relation,
"text": relation_semantics[relation],
}
)
tendency = str(movement.get("tendency") or "")
if tendency in knowledge["movement"]:
records.append({"id": f"movement-{tendency}", "source": "neijing", "text": knowledge["movement"][tendency]})
for key in ("sitian", "zaiquan"):
qi = str(annual_qi.get(key) or "")
if qi in knowledge["qi"]:
records.append({"id": f"annual-{key}", "source": "neijing", "subject": qi, "text": knowledge["qi"][qi]})
for key in ("host_qi", "guest_qi"):
qi = str(current_qi.get(key) or "")
if qi in knowledge["qi"]:
records.append({"id": f"current-{key}", "source": "neijing", "subject": qi, "text": knowledge["qi"][qi]})
relation = current_qi.get("guest_host_relation") or {}
relation_type = str(relation.get("type") or "")
if relation_type in knowledge["relations"]:
records.append({"id": f"relation-{relation_type}", "source": "neijing", "subject": relation.get("label") or "", "text": knowledge["relations"][relation_type]})
records.append({"id": "day-trigger", "source": "neijing", "text": knowledge["day_trigger"]})
return records
def _heart_records(catalog: dict[str, Any], context: dict[str, Any]) -> list[dict[str, Any]]:
calculation = context.get("calculation") or {}
hexagram = calculation.get("hexagram") or {}
six_yao = calculation.get("six_yao") or {}
preset = str(calculation.get("question_preset") or "custom")
heart = catalog["heart"]
records = [
{"id": "heart-focus", "source": "product_method", "text": heart["focus"].get(preset, heart["focus"]["custom"])},
{"id": "heart-evidence-order", "source": "product_method", "items": heart["evidence_order"]},
{"id": "heart-limits", "source": "product_method", "text": heart["limits"]},
{"id": "heart-self-response", "source": "jingfang", "text": heart["semantics"]["self_response"]},
{"id": "heart-calendar", "source": "zengshan", "text": heart["semantics"]["calendar"]},
{"id": "heart-movement", "source": "huozhulin", "text": heart["semantics"]["movement"]},
{"id": "heart-six-spirits", "source": "zengshan", "text": heart["semantics"]["six_spirits"]},
{"id": "heart-timing-boundary", "source": "product_method", "text": heart["semantics"]["timing_boundary"]},
_hexagram_record("primary", hexagram),
]
relatives = {
str(line.get("relative") or "")
for line in six_yao.get("lines") or []
if line.get("relative")
}
for relative in sorted(relatives):
text = (heart["semantics"].get("relatives") or {}).get(relative)
if text:
records.append(
{
"id": f"heart-relative-{relative}",
"source": "huozhulin",
"subject": relative,
"text": text,
}
)
records.extend(_line_record(line) for line in hexagram.get("lines") or [] if line.get("moving"))
transformed = hexagram.get("transformed") or {}
if transformed:
records.append(_hexagram_record("transformed", transformed))
palace = six_yao.get("palace") or {}
records.append(
{
"id": "heart-palace",
"source": "jingfang",
"text": (
f"本卦归{palace.get('name') or '--'}{palace.get('stage') or '--'}"
f"世在{palace.get('self_position') or '--'}爻,应在{palace.get('response_position') or '--'}爻。"
),
}
)
return records
def _hexagram_record(kind: str, hexagram: dict[str, Any]) -> dict[str, Any]:
return {
"id": f"zhouyi-{kind}",
"source": "zhouyi",
"kind": kind,
"name": hexagram.get("name") or "",
"inner_trigram": hexagram.get("inner_trigram") or "",
"outer_trigram": hexagram.get("outer_trigram") or "",
"text": hexagram.get("text") or "",
"tuan": hexagram.get("tuan") or "",
"image": hexagram.get("image") or "",
}
def _line_record(line: dict[str, Any]) -> dict[str, Any]:
return {
"id": f"zhouyi-line-{line.get('position') or ''}",
"source": "zhouyi",
"position": line.get("position"),
"position_name": line.get("position_name") or "",
"line_name": line.get("line_name") or "",
"text": line.get("text") or "",
"image": line.get("image") or "",
}
@lru_cache(maxsize=1)
def _knowledge_catalog() -> dict[str, Any]:
payload = json.loads(KNOWLEDGE_FILE.read_text(encoding="utf-8"))
if not payload.get("version") or not isinstance(payload.get("sources"), dict):
raise ValueError("问天知识库格式不完整。")
return payload
+51 -27
View File
@@ -6,11 +6,17 @@ from datetime import date
from typing import Any
from backend.bootstrap.config import normalize_date
from backend.features.heaven.agent import HeavenAgentError, interpret_heaven
from backend.features.heaven.agent import (
HEAVEN_PROMPT_VERSIONS,
HeavenAgentError,
interpret_heaven,
)
from backend.features.heaven.engine import (
build_five_phase_field,
hexagram_from_lines,
)
from backend.features.heaven.knowledge import prepare_heaven_context
from backend.features.heaven.six_yao import build_six_yao_chart
from backend.features.market import MarketServiceMixin
@@ -75,9 +81,11 @@ class HeavenReadingMixin:
return subject, detail
hexagram = context.get("hexagram") or {}
transformed = hexagram.get("transformed") or {}
question = str(context.get("question") or "").strip()
question_detail = f" · {question[:48]}" if question else ""
return (
f"{display_date} 观心",
f"{hexagram.get('name') or '--'}{transformed.get('name') or '--'}",
f"{hexagram.get('name') or '--'}{transformed.get('name') or '--'}{question_detail}",
)
def heaven_interpret(self, payload: dict[str, Any]) -> dict[str, Any]:
@@ -85,6 +93,8 @@ class HeavenReadingMixin:
if mode not in {"trend", "fortune", "heart"}:
raise ValueError("问天解读模式不正确。")
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
prompt_version = HEAVEN_PROMPT_VERSIONS[mode]
stale_fortune: dict[str, Any] | None = None
if mode == "fortune":
existing = self.database.latest_heaven_reading(
self.current_user_id, "fortune", trade_date
@@ -94,7 +104,9 @@ class HeavenReadingMixin:
self.current_user_id, int(existing["id"])
)
existing = None
if existing:
if existing and self.database.heaven_reading_interpretation_version(
self.current_user_id, int(existing["id"])
) == prompt_version:
return {
"answer": existing["answer"],
"mode": mode,
@@ -103,6 +115,7 @@ class HeavenReadingMixin:
"reading": existing,
"reused": True,
}
stale_fortune = existing
if mode in {"trend", "fortune"}:
setup = self.heaven_setup(
trade_date,
@@ -141,38 +154,44 @@ class HeavenReadingMixin:
setup["field"],
public=False,
)
fortune_field = json.loads(json.dumps(setup["field"], ensure_ascii=False))
catalog = fortune_field.pop("sector_catalog", [])
dominant_elements = {
item.get("element") for item in fortune_field.get("balance", [])[:2]
}
fortune_field["industry_affinity"] = [
{
"element": group.get("element"),
"examples": [
item.get("name")
for item in group.get("industries", [])[:8]
if item.get("name")
],
}
for group in catalog
if group.get("element") in dominant_elements
]
context = {
"calendar_date": setup["calendar_date"],
"five_phase_field": fortune_field,
"five_phase_field": setup["field"],
"personal_profile": personal_profile,
}
context_date = setup["calendar_date"]
if mode == "trend":
context_date = setup["trade_date"]
else:
question = str(payload.get("question") or "").strip()
if len(question) > 300:
raise ValueError("观心问题不能超过300个字符。")
question_preset = str(payload.get("question_preset") or "unthemed").strip()
if question_preset not in {"trade", "mind", "unthemed", "custom"}:
question_preset = "custom"
if not question:
question = "不设具体问题,只观此刻一念。"
question_preset = "unthemed"
raw_lines = payload.get("lines")
hexagram = self.heaven_hexagram(raw_lines)
context = {
"hexagram": self.heaven_hexagram(payload.get("lines")),
"ritual": "用户已完成30秒静心、六次三枚铜钱起卦,并在心中察看第一念。问题未输入。",
"question": question,
"question_preset": question_preset,
"hexagram": hexagram,
"six_yao": build_six_yao_chart(
[int(value) for value in raw_lines],
str(payload.get("cast_at") or ""),
),
"ritual": {
"breathing": "用户已完成1秒准备与五轮吸3秒、顿2秒、呼4秒的静心呼吸。",
"casting": "用户以三枚铜钱自初爻至上爻投掷六次。",
"reflection": "用户已在看见卦象后察看第一念。",
},
}
context_date = trade_date
result, compiler = self._call_heaven_agent(mode, context)
agent_context = prepare_heaven_context(mode, context)
agent_context["interpretation_version"] = prompt_version
result, compiler = self._call_heaven_agent(mode, agent_context)
subject, subject_detail = self._heaven_reading_identity(
mode, context_date, context
)
@@ -181,6 +200,10 @@ class HeavenReadingMixin:
if mode == "fortune"
else f"{mode}:{context_date}:{secrets.token_urlsafe(12)}"
)
if stale_fortune:
self.database.delete_heaven_reading(
self.current_user_id, int(stale_fortune["id"])
)
reading = self.database.save_heaven_reading(
self.current_user_id,
mode,
@@ -188,14 +211,14 @@ class HeavenReadingMixin:
subject,
subject_detail,
str(result.get("answer") or ""),
context,
agent_context,
dedupe_key,
)
return {
**result,
"mode": mode,
"compiler": compiler,
"notice": "智能解读已自动切换用服务。" if compiler == "fallback" else "",
"notice": "当前智能服务繁忙,已自动切换用服务。" if compiler == "fallback" else "",
"reading": reading,
"reused": False,
}
@@ -205,9 +228,10 @@ class HeavenReadingMixin:
return bool(reading and str(reading.get("answer") or "").rstrip().endswith("……"))
def _call_heaven_agent(self, mode: str, context: dict[str, Any]) -> tuple[dict[str, Any], str]:
prompt_version = HEAVEN_PROMPT_VERSIONS[mode]
result = self.llm_gateway.call(
f"heaven_{mode}",
f"heaven-{mode}-v1",
prompt_version,
lambda profile: interpret_heaven(
mode,
context,
+19
View File
@@ -131,6 +131,25 @@ class HeavenRepositoryMixin:
items = self.list_heaven_readings(user_id, mode, context_date, 1)
return items[0] if items else None
def heaven_reading_interpretation_version(
self, user_id: int, reading_id: int
) -> str:
with self.connect() as connection:
row = connection.execute(
"""
SELECT context_snapshot FROM heaven_readings
WHERE id = ? AND user_id = ?
""",
(int(reading_id), int(user_id)),
).fetchone()
if not row:
return ""
try:
snapshot = json.loads(str(row["context_snapshot"] or "{}"))
except (TypeError, json.JSONDecodeError):
return ""
return str(snapshot.get("interpretation_version") or "")
def delete_heaven_reading(self, user_id: int, reading_id: int) -> bool:
with self.connect() as connection:
cursor = connection.execute(
+432
View File
@@ -0,0 +1,432 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from typing import Any
from backend.features.heaven.engine import (
BRANCH_ELEMENT,
ELEMENT_CONTROLS,
ELEMENT_GENERATES,
LINE_POSITIONS,
Solar,
)
SHANGHAI = timezone(timedelta(hours=8), "Asia/Shanghai")
TRIGRAM_BITS = {
"": (1, 1, 1),
"": (1, 1, 0),
"": (1, 0, 1),
"": (1, 0, 0),
"": (0, 1, 1),
"": (0, 1, 0),
"": (0, 0, 1),
"": (0, 0, 0),
}
BITS_TRIGRAM = {bits: name for name, bits in TRIGRAM_BITS.items()}
PALACE_ELEMENT = {
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
}
# 京房纳甲通行表。每组均按初爻至三爻、四爻至上爻排列。
NAJIA = {
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
}
PALACE_STAGES = (
("本宫", (), 6),
("一世", (0,), 1),
("二世", (0, 1), 2),
("三世", (0, 1, 2), 3),
("四世", (0, 1, 2, 3), 4),
("五世", (0, 1, 2, 3, 4), 5),
("游魂", (0, 1, 2, 4), 4),
("归魂", (4,), 3),
)
SIX_SPIRITS = ("青龙", "朱雀", "勾陈", "螣蛇", "白虎", "玄武")
SPIRIT_START = {
"": 0,
"": 0,
"": 1,
"": 1,
"": 2,
"": 3,
"": 4,
"": 4,
"": 5,
"": 5,
}
BRANCH_CLASH = {
"": "", "": "", "": "", "": "",
"": "", "": "", "": "", "": "",
"": "", "": "", "": "", "": "",
}
BRANCH_COMBINE = {
"": "", "": "", "": "", "": "",
"": "", "": "", "": "", "": "",
"": "", "": "", "": "", "": "",
}
BRANCH_HARM = {
"": "", "": "", "": "", "": "",
"": "", "": "", "": "", "": "",
"": "", "": "", "": "", "": "",
}
THREE_PUNISHMENTS = (frozenset("寅巳申"), frozenset("丑未戌"), frozenset("子卯"))
SELF_PUNISHMENT = set("辰午酉亥")
ADVANCE_PAIRS = {
("", ""), ("", ""), ("", ""), ("", ""),
("", ""), ("", ""), ("", ""), ("", ""),
}
RETREAT_PAIRS = {(target, source) for source, target in ADVANCE_PAIRS}
def build_six_yao_chart(values: list[int], cast_at: str = "") -> dict[str, Any]:
"""Return a deterministic Jing Fang Na Jia chart for a six-coin result."""
if len(values) != 6 or any(value not in {6, 7, 8, 9} for value in values):
raise ValueError("六爻必须由六、七、八、九组成,且从初爻到上爻排列。")
observed_at = _parse_cast_at(cast_at)
solar = Solar.fromYmdHms(
observed_at.year,
observed_at.month,
observed_at.day,
observed_at.hour,
observed_at.minute,
observed_at.second,
)
lunar = solar.getLunar()
month_gz = lunar.getMonthInGanZhiExact()
day_gz = lunar.getDayInGanZhiExact2()
time_gz = lunar.getTimeInGanZhi()
void_branches = tuple(lunar.getDayXunKongExact2())
month_branch = month_gz[1]
day_stem, day_branch = day_gz[0], day_gz[1]
bits = tuple(1 if value % 2 else 0 for value in values)
transformed_values = tuple(7 if value == 6 else 8 if value == 9 else value for value in values)
transformed_bits = tuple(1 if value % 2 else 0 for value in transformed_values)
palace = _palace_map()[bits]
palace_element = PALACE_ELEMENT[palace["trigram"]]
self_position = int(palace["self_position"])
response_position = self_position + 3 if self_position <= 3 else self_position - 3
najia = _najia_for_bits(bits)
transformed_najia = _najia_for_bits(transformed_bits)
spirits = tuple(
SIX_SPIRITS[(SPIRIT_START[day_stem] + index) % 6] for index in range(6)
)
lines: list[dict[str, Any]] = []
for index, ((stem, branch), value) in enumerate(zip(najia, values)):
position = index + 1
element = BRANCH_ELEMENT[branch]
transformed_stem, transformed_branch = transformed_najia[index]
transformed_element = BRANCH_ELEMENT[transformed_branch]
line = {
"position": position,
"position_name": LINE_POSITIONS[index],
"value": value,
"yin_yang": "" if value % 2 else "",
"moving": value in {6, 9},
"stem": stem,
"branch": branch,
"element": element,
"relative": _six_relative(palace_element, element),
"spirit": spirits[index],
"role": "" if position == self_position else "" if position == response_position else "",
"void": branch in void_branches,
"month": _calendar_relation("", month_branch, branch),
"day": _calendar_relation("", day_branch, branch),
}
if line["moving"]:
line["transformation"] = {
"value": transformed_values[index],
"yin_yang": "" if transformed_values[index] % 2 else "",
"stem": transformed_stem,
"branch": transformed_branch,
"element": transformed_element,
"relative": _six_relative(palace_element, transformed_element),
"relation_to_origin": _transformation_relation(
branch,
element,
transformed_branch,
transformed_element,
),
}
lines.append(line)
hidden = _hidden_spirits(palace["trigram"], palace_element, lines)
for item in hidden:
lines[item["position"] - 1].setdefault("hidden_spirits", []).append(item)
return {
"method": "京房纳甲·八宫世应",
"method_version": "xiaobai-six-yao-v1",
"sources": ["jingfang", "huozhulin", "zengshan"],
"cast_at": observed_at.isoformat(timespec="seconds"),
"timezone": "Asia/Shanghai",
"day_boundary": "晚子时仍按民用当日排日柱",
"calendar": {
"month": month_gz,
"month_branch": month_branch,
"day": day_gz,
"day_branch": day_branch,
"time": time_gz,
"day_void": "".join(void_branches),
},
"palace": {
"name": f"{palace['trigram']}",
"trigram": palace["trigram"],
"element": palace_element,
"stage": palace["stage"],
"self_position": self_position,
"response_position": response_position,
},
"lines": lines,
"hidden_spirits": hidden,
"branch_pattern": _hexagram_branch_pattern(lines),
"relationships": _significant_line_relationships(lines),
}
def _parse_cast_at(raw: str) -> datetime:
value = str(raw or "").strip()
if not value:
return datetime.now(SHANGHAI)
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError("起卦时间格式不正确。") from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=SHANGHAI)
return parsed.astimezone(SHANGHAI)
@lru_cache(maxsize=1)
def _palace_map() -> dict[tuple[int, ...], dict[str, Any]]:
result: dict[tuple[int, ...], dict[str, Any]] = {}
for trigram, trigram_bits in TRIGRAM_BITS.items():
pure = trigram_bits + trigram_bits
for stage, flipped, self_position in PALACE_STAGES:
bits = list(pure)
for index in flipped:
bits[index] = 1 - bits[index]
key = tuple(bits)
if key in result:
raise RuntimeError("八宫映射出现重复卦象。")
result[key] = {
"trigram": trigram,
"stage": stage,
"self_position": self_position,
}
if len(result) != 64:
raise RuntimeError("八宫映射未覆盖六十四卦。")
return result
def _najia_for_bits(bits: tuple[int, ...]) -> tuple[tuple[str, str], ...]:
inner = BITS_TRIGRAM[bits[:3]]
outer = BITS_TRIGRAM[bits[3:]]
return tuple(NAJIA[inner]["inner"] + NAJIA[outer]["outer"])
def _six_relative(palace_element: str, line_element: str) -> str:
if line_element == palace_element:
return "兄弟"
if ELEMENT_GENERATES[line_element] == palace_element:
return "父母"
if ELEMENT_GENERATES[palace_element] == line_element:
return "子孙"
if ELEMENT_CONTROLS[palace_element] == line_element:
return "妻财"
return "官鬼"
def _calendar_relation(prefix: str, actor_branch: str, line_branch: str) -> dict[str, Any]:
actor_element = BRANCH_ELEMENT[actor_branch]
line_element = BRANCH_ELEMENT[line_branch]
labels = []
if actor_branch == line_branch:
labels.append(f"{prefix}{'' if prefix == '' else ''}")
if BRANCH_CLASH[actor_branch] == line_branch:
labels.append("月破" if prefix == "" else "日冲")
if BRANCH_COMBINE[actor_branch] == line_branch:
labels.append(f"{prefix}")
if BRANCH_HARM[actor_branch] == line_branch:
labels.append(f"{prefix}")
element_relation = _actor_element_relation(actor_element, line_element, prefix)
return {
"branch": actor_branch,
"element": actor_element,
"branch_relations": labels,
"element_relation": element_relation,
}
def _actor_element_relation(actor: str, target: str, prefix: str) -> str:
if actor == target:
return f"{prefix}与爻同气"
if ELEMENT_GENERATES[actor] == target:
return f"{prefix}生爻"
if ELEMENT_CONTROLS[actor] == target:
return f"{prefix}克爻"
if ELEMENT_GENERATES[target] == actor:
return f"爻生{prefix}"
return f"爻克{prefix}"
def _transformation_relation(
origin_branch: str,
origin_element: str,
target_branch: str,
target_element: str,
) -> list[str]:
labels = []
if (origin_branch, target_branch) in ADVANCE_PAIRS:
labels.append("化进神")
elif (origin_branch, target_branch) in RETREAT_PAIRS:
labels.append("化退神")
if BRANCH_COMBINE[origin_branch] == target_branch:
labels.append("化合")
if BRANCH_CLASH[origin_branch] == target_branch:
labels.append("化冲")
if target_element == origin_element:
labels.append("变爻同气")
elif ELEMENT_GENERATES[target_element] == origin_element:
labels.append("回头生")
elif ELEMENT_CONTROLS[target_element] == origin_element:
labels.append("回头克")
elif ELEMENT_GENERATES[origin_element] == target_element:
labels.append("原爻生变")
else:
labels.append("原爻克变")
return labels
def _hidden_spirits(
palace_trigram: str,
palace_element: str,
lines: list[dict[str, Any]],
) -> list[dict[str, Any]]:
present = {str(line["relative"]) for line in lines}
missing = {"父母", "兄弟", "子孙", "妻财", "官鬼"} - present
if not missing:
return []
pure_bits = TRIGRAM_BITS[palace_trigram] + TRIGRAM_BITS[palace_trigram]
result = []
for index, (stem, branch) in enumerate(_najia_for_bits(pure_bits)):
element = BRANCH_ELEMENT[branch]
relative = _six_relative(palace_element, element)
if relative not in missing:
continue
result.append(
{
"position": index + 1,
"position_name": LINE_POSITIONS[index],
"stem": stem,
"branch": branch,
"element": element,
"relative": relative,
"flying_relative": lines[index]["relative"],
}
)
return result
def _hexagram_branch_pattern(lines: list[dict[str, Any]]) -> str:
pairs = ((0, 3), (1, 4), (2, 5))
if all(BRANCH_CLASH[lines[left]["branch"]] == lines[right]["branch"] for left, right in pairs):
return "六冲"
if all(BRANCH_COMBINE[lines[left]["branch"]] == lines[right]["branch"] for left, right in pairs):
return "六合"
return ""
def _significant_line_relationships(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
result = []
for left_index in range(6):
for right_index in range(left_index + 1, 6):
left = lines[left_index]
right = lines[right_index]
if not (left["moving"] or right["moving"] or left["role"] or right["role"]):
continue
labels = _branch_pair_relations(left["branch"], right["branch"])
element_relation = _pair_element_relation(left["element"], right["element"])
if not labels and element_relation == "同气":
continue
result.append(
{
"positions": [left["position"], right["position"]],
"lines": [left["position_name"], right["position_name"]],
"branch_relations": labels,
"element_relation": element_relation,
}
)
return result
def _branch_pair_relations(left: str, right: str) -> list[str]:
labels = []
if BRANCH_COMBINE[left] == right:
labels.append("六合")
if BRANCH_CLASH[left] == right:
labels.append("六冲")
if BRANCH_HARM[left] == right:
labels.append("六害")
pair = frozenset((left, right))
if pair in THREE_PUNISHMENTS or (left == right and left in SELF_PUNISHMENT):
labels.append("相刑")
return labels
def _pair_element_relation(left: str, right: str) -> str:
if left == right:
return "同气"
if ELEMENT_GENERATES[left] == right:
return "前者生后者"
if ELEMENT_GENERATES[right] == left:
return "后者生前者"
if ELEMENT_CONTROLS[left] == right:
return "前者克后者"
return "后者克前者"
+17 -5
View File
@@ -5,6 +5,7 @@ from typing import Any
from backend.bootstrap.config import normalize_date
from backend.data.providers.tushare_client import _sector_coverage_issue
from backend.features.heaven.agent import HEAVEN_PROMPT_VERSIONS
from backend.features.heaven.engine import build_five_phase_field, build_market_hexagram
@@ -157,11 +158,7 @@ class HeavenTrendMixin:
field,
public=True,
)
daily_fortune_reading = self.database.latest_heaven_reading(
self.current_user_id, "fortune", normalized_date
)
if self._legacy_truncated_heaven_reading(daily_fortune_reading):
daily_fortune_reading = None
daily_fortune_reading = self._reusable_daily_fortune_reading(normalized_date)
return {
"trade_date": data_date,
"calendar_date": normalized_date,
@@ -182,6 +179,21 @@ class HeavenTrendMixin:
},
}
def _reusable_daily_fortune_reading(
self, context_date: str
) -> dict[str, Any] | None:
reading = self.database.latest_heaven_reading(
self.current_user_id, "fortune", context_date
)
if not reading or self._legacy_truncated_heaven_reading(reading):
return None
version = self.database.heaven_reading_interpretation_version(
self.current_user_id, int(reading["id"])
)
if version != HEAVEN_PROMPT_VERSIONS["fortune"]:
return None
return reading
@staticmethod
def _heaven_market_mode(
trade_date: str,
+76 -1
View File
@@ -15,6 +15,11 @@ class MentorAgentError(RuntimeError):
pass
FOLLOW_UP_START = "<XIAOBAI_FOLLOW_UPS>"
FOLLOW_UP_END = "</XIAOBAI_FOLLOW_UPS>"
MAX_FOLLOW_UP_LENGTH = 80
@dataclass(frozen=True)
class MentorSkill:
skill_id: str
@@ -185,6 +190,8 @@ def stream_with_mentor(
base_url: str,
model: str,
timeout: int = 90,
*,
follow_ups: list[str] | None = None,
) -> Iterator[str]:
if not api_key or not model:
raise MentorAgentError("LLM API Key 或模型尚未配置。")
@@ -193,8 +200,10 @@ def stream_with_mentor(
messages = [{"role": "system", "content": system_prompt}]
messages.extend(history[-10:])
messages.append({"role": "user", "content": question})
if follow_ups is not None:
follow_ups.clear()
try:
yield from llm_transport.stream_chat_completion(
upstream = llm_transport.stream_chat_completion(
api_key=api_key,
base_url=base_url,
model=model,
@@ -202,6 +211,7 @@ def stream_with_mentor(
timeout=timeout,
user_agent="XiaobaiReviewWeb/0.6",
)
yield from _stream_answer_and_collect_follow_ups(upstream, follow_ups)
except llm_transport.OpenAIEmptyResponseError as exc:
raise MentorAgentError("问师模型未返回有效内容。") from exc
except llm_transport.OpenAIHTTPError as exc:
@@ -223,6 +233,10 @@ def _build_system_prompt(skill: MentorSkill, market_context: dict[str, Any]) ->
5. 优先回答用户真正的问题。市场分析通常按“判断、数据依据、思维模型下的应对、失效条件”组织;纯交易心理或方法问题可以自然回答,不强制套模板。
6. 保留该 Skill 的核心心智模型和表达节奏,但不要复述身份履历,不要宣称自己就是真人,不攻击或贬低用户。
7. 使用中文,信息密度高,避免空泛口号。引用数字时标明数据日期。
8. 正文结束后必须输出2至3条与本轮问题和正文直接相关的追问。追问用于帮助用户继续核实条件、风险或失效边界,不得引入正文没有依据的新事实,不得给出无条件买卖指令。严格使用以下机器结构,不要放进Markdown代码块,结束标签后不要再输出文字:
<XIAOBAI_FOLLOW_UPS>
["追问一?","追问二?","追问三?"]
</XIAOBAI_FOLLOW_UPS>
网页市场数据:
{context_json}
@@ -233,6 +247,67 @@ def _build_system_prompt(skill: MentorSkill, market_context: dict[str, Any]) ->
""".strip()
def _stream_answer_and_collect_follow_ups(
chunks: Iterator[str], follow_ups: list[str] | None
) -> Iterator[str]:
buffer = ""
collecting = False
for raw_chunk in chunks:
chunk = str(raw_chunk or "")
if not chunk:
continue
buffer += chunk
if collecting:
continue
marker_index = buffer.find(FOLLOW_UP_START)
if marker_index >= 0:
if marker_index:
yield buffer[:marker_index]
buffer = buffer[marker_index + len(FOLLOW_UP_START):]
collecting = True
continue
overlap = _marker_prefix_overlap(buffer, FOLLOW_UP_START)
emit_length = len(buffer) - overlap
if emit_length:
yield buffer[:emit_length]
buffer = buffer[emit_length:]
if not collecting:
if buffer:
yield buffer
return
raw_follow_ups = buffer.split(FOLLOW_UP_END, 1)[0].strip()
parsed = _parse_follow_ups(raw_follow_ups)
if follow_ups is not None and len(parsed) >= 2:
follow_ups.extend(parsed)
def _marker_prefix_overlap(value: str, marker: str) -> int:
max_length = min(len(value), len(marker) - 1)
for length in range(max_length, 0, -1):
if value.endswith(marker[:length]):
return length
return 0
def _parse_follow_ups(payload: str) -> list[str]:
try:
values = json.loads(payload)
except (TypeError, json.JSONDecodeError):
return []
if not isinstance(values, list):
return []
result: list[str] = []
for value in values:
question = re.sub(r"\s+", " ", str(value or "")).strip()
if not question or len(question) > MAX_FOLLOW_UP_LENGTH or question in result:
continue
result.append(question)
if len(result) == 3:
break
return result
def _parse_frontmatter(content: str) -> dict[str, str]:
if not content.startswith("---"):
return {}
+25 -1
View File
@@ -129,9 +129,10 @@ class MentorServiceMixin:
def generate():
answer_parts: list[str] = []
follow_ups: list[str] = []
events = self.llm_gateway.stream(
"mentor",
f"mentor-skill-v1:{skill.skill_id}",
f"mentor-skill-v2:{skill.skill_id}",
lambda profile: stream_with_mentor(
skill,
context,
@@ -140,6 +141,7 @@ class MentorServiceMixin:
profile.api_key,
profile.base_url,
profile.model,
follow_ups=follow_ups,
),
(MentorAgentError,),
)
@@ -160,6 +162,7 @@ class MentorServiceMixin:
yield {
"type": "meta",
"data_trade_date": context["data_trade_date"],
"follow_ups": follow_ups or self._mentor_follow_up_fallback(question),
"notice": "智能解读已自动切换可用服务。"
if event.role == "fallback"
else "",
@@ -167,6 +170,27 @@ class MentorServiceMixin:
return generate()
@staticmethod
def _mentor_follow_up_fallback(question: str) -> list[str]:
normalized = question.strip()
if any(keyword in normalized for keyword in ("风险", "亏损", "回撤", "止损")):
return [
"这些风险最早会从哪些信号中暴露?",
"哪些变化会让当前风险判断失效?",
"如果风险继续扩大,仓位预案应如何调整?",
]
if any(keyword in normalized for keyword in ("股票", "个股", "代码", "怎么看")):
return [
"这个判断最关键的确认信号是什么?",
"哪些变化会让当前结论失效?",
"明日盘中应该优先观察哪些数据?",
]
return [
"这个判断最关键的确认依据是什么?",
"哪些变化会让当前结论失效?",
"下一步应该优先观察什么?",
]
def mentor_messages(self, mentor_id: str, trade_date: str) -> list[dict[str, Any]]:
mentor_id = validate_text(mentor_id, "问师角色", 100, required=True)
trade_date = normalize_date(trade_date)
+4
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from backend.features.screener.strategies import ADVANCED_CURATED_STRATEGIES
from backend.features.screener.signals import attach_strategy_validity
REGIMES = {
@@ -705,3 +706,6 @@ for strategy in CURATED_STRATEGIES:
)
BUILTIN_STRATEGIES.extend(CURATED_STRATEGIES)
for strategy in BUILTIN_STRATEGIES:
attach_strategy_validity(strategy)
@@ -0,0 +1,70 @@
from __future__ import annotations
from typing import Any
def resolve_published_batch(
markers: list[dict[str, Any]],
requested_date: str,
legacy_date: str = "",
) -> tuple[dict[str, Any] | None, dict[str, Any], dict[str, Any] | None]:
requested_marker = next(
(
item for item in markers
if str(item.get("trade_date") or "") == requested_date
),
None,
)
published = next(
(item for item in markers if item.get("status") == "complete"),
None,
)
if published is None and legacy_date:
published = {
"trade_date": legacy_date,
"status": "complete",
"legacy_inferred": True,
"completed": [],
"skipped": [],
"failed": [],
}
request_status = dict(requested_marker or {})
request_status.setdefault("trade_date", requested_date)
request_status.setdefault("status", "pending")
descriptor = _published_descriptor(published, requested_date, request_status)
if descriptor and descriptor["is_fallback"]:
request_status["retaining_trade_date"] = descriptor["trade_date"]
return published, request_status, descriptor
def _published_descriptor(
marker: dict[str, Any] | None,
requested_date: str,
request_status: dict[str, Any],
) -> dict[str, Any] | None:
if marker is None:
return None
trade_date = str(marker.get("trade_date") or "")
is_fallback = trade_date != requested_date
status = str(request_status.get("status") or "pending")
notice = ""
if is_fallback:
if status == "running":
notice = "所选日期候选正在生成,当前保留上一成功批次"
elif status in {"failed", "partial"}:
notice = "所选日期候选未完整发布,当前保留上一成功批次"
else:
notice = "所选日期候选尚未发布,当前展示最近成功批次"
return {
"trade_date": trade_date,
"status": "complete",
"started_at": marker.get("started_at") or "",
"finished_at": marker.get("finished_at") or marker.get("updated_at") or "",
"library_version": int(marker.get("library_version") or 0),
"completed_count": len(marker.get("completed") or []),
"skipped_count": len(marker.get("skipped") or []),
"legacy_inferred": bool(marker.get("legacy_inferred")),
"is_fallback": is_fallback,
"notice": notice,
}
@@ -686,6 +686,102 @@ class ScreenerRepositoryMixin:
result.append(payload)
return result
def screener_runs_for_dates(
self, user_id: int, trade_dates: list[str], limit: int = 1200,
) -> list[dict[str, Any]]:
normalized_dates = list(dict.fromkeys(str(item) for item in trade_dates if item))
if not normalized_dates:
return []
safe_limit = max(1, min(2400, int(limit)))
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
parameters: list[Any] = [] if int(user_id) == 0 else [int(user_id)]
placeholders = ",".join("?" for _ in normalized_dates)
parameters.extend(normalized_dates)
parameters.append(safe_limit)
with self.connect() as connection:
rows = connection.execute(
f"""
WITH ranked AS (
SELECT id, trade_date, regime, mode, strategy_name, result, created_at,
ROW_NUMBER() OVER (
PARTITION BY trade_date, mode, regime, strategy_name
ORDER BY id DESC
) AS context_rank
FROM screener_runs
WHERE {owner_clause} AND trade_date IN ({placeholders})
)
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
FROM ranked
WHERE context_rank = 1
ORDER BY trade_date DESC, id DESC
LIMIT ?
""",
parameters,
).fetchall()
return [
payload
for row in rows
if (payload := self._screener_run_payload(row)) is not None
]
def recent_screener_runs(
self, user_id: int, trade_date: str, mode: str, limit: int = 40,
) -> list[dict[str, Any]]:
if int(user_id) == 0 or mode not in {"smart", "curated", "quant"}:
return []
safe_limit = max(1, min(160, int(limit)))
with self.connect() as connection:
rows = connection.execute(
"""
WITH ranked AS (
SELECT id, trade_date, regime, mode, strategy_name, result, created_at,
ROW_NUMBER() OVER (
PARTITION BY trade_date, mode, regime, strategy_name
ORDER BY id DESC
) AS context_rank
FROM screener_runs
WHERE user_id = ? AND trade_date <= ? AND mode = ?
)
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
FROM ranked
WHERE context_rank = 1
ORDER BY trade_date DESC, id DESC
LIMIT ?
""",
(int(user_id), trade_date, mode, safe_limit),
).fetchall()
return [
payload
for row in rows
if (payload := self._screener_run_payload(row)) is not None
]
def list_screener_batch_markers(
self, end_date: str, limit: int = 30,
) -> list[dict[str, Any]]:
safe_limit = max(1, min(120, int(limit)))
with self.connect() as connection:
rows = connection.execute(
"""
SELECT cache_key, payload, updated_at
FROM data_snapshots
WHERE kind = 'screener_auto_v1' AND cache_key <= ?
ORDER BY cache_key DESC
LIMIT ?
""",
(end_date, safe_limit),
).fetchall()
result = []
for row in rows:
try:
payload = json.loads(row["payload"])
except json.JSONDecodeError:
continue
payload.setdefault("trade_date", str(row["cache_key"] or ""))
payload.setdefault("updated_at", str(row["updated_at"] or ""))
result.append(payload)
return result
def get_screener_run(self, user_id: int, run_id: int) -> dict[str, Any] | None:
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
parameters: tuple[Any, ...] = (int(run_id),)
+96 -7
View File
@@ -15,6 +15,11 @@ from backend.features.screener.compiler import (
from backend.features.screener.catalog import FACTOR_FIELDS, FACTOR_GROUPS, REGIMES
from backend.features.screener.data_sync import FactorDataService
from backend.features.screener.formula import compile_local_strategy
from backend.features.screener.publication import resolve_published_batch
from backend.features.screener.signals import (
attach_strategy_validity,
build_candidate_archive,
)
SCREENER_LIBRARY_VERSION = 8
@@ -91,26 +96,71 @@ class ScreenerServiceMixin:
factor_health = self.screener.factor_health(normalized_date)
strategies = self.database.list_screener_strategies(self.current_user_id)
for strategy in strategies:
attach_strategy_validity(strategy)
missing = self._strategy_missing_data(strategy, factor_dates, factor_health)
strategy["data_ready"] = not missing
strategy["missing_data"] = missing
automatic_results = self.database.screener_runs_for_date(0, normalized_date)
personal_results = self.database.screener_runs_for_date(
self.current_user_id, normalized_date
batch_markers = self.database.list_screener_batch_markers(normalized_date, 120)
complete_markers = [
item for item in batch_markers if item.get("status") == "complete"
][:30]
legacy_results = []
legacy_date = ""
if not batch_markers:
legacy_results = self.database.screener_runs_for_date(0, normalized_date)
if legacy_results:
legacy_date = normalized_date
published_marker, automatic_status, published_batch = resolve_published_batch(
batch_markers, normalized_date, legacy_date
)
published_date = str((published_batch or {}).get("trade_date") or "")
automatic_results = (
legacy_results
if legacy_results and published_date == normalized_date
else self.database.screener_runs_for_date(0, published_date)
if published_date
else []
)
personal_results = self.database.recent_screener_runs(
self.current_user_id, normalized_date, "quant", 40
)
recent_results = [
*[item for item in automatic_results if item.get("meta", {}).get("mode") in {"smart", "curated"}],
*[item for item in personal_results if item.get("meta", {}).get("mode") == "quant"],
*personal_results,
]
latest_results: dict[str, dict[str, Any]] = {}
for result in reversed(recent_results):
mode = str(result.get("meta", {}).get("mode") or "smart")
latest_results[mode] = result
automatic_status = self.database.get_data_snapshot(
"screener_auto_v1", normalized_date
) or {}
published_dates = [
str(item.get("trade_date") or "") for item in complete_markers
if item.get("trade_date")
]
if legacy_date and legacy_date not in published_dates:
published_dates.append(legacy_date)
archive_runs = self.database.screener_runs_for_dates(0, published_dates, 1800)
archive_runs.extend(personal_results)
archive_as_of_date = published_date or (factor_dates[-1] if factor_dates else "")
marker_regime = (published_marker or {}).get("regime") or {}
archive_regime = str(
(marker_regime.get("id") if isinstance(marker_regime, dict) else marker_regime)
or regime.get("id") or "repair"
)
active_signals, candidate_history = build_candidate_archive(
archive_runs,
strategies,
factor_dates,
archive_as_of_date,
archive_regime,
)
self._attach_published_strategy_status(
strategies, automatic_results, published_marker, published_date
)
return {
"trade_date": normalized_date,
"requested_trade_date": normalized_date,
"regime": regime,
"regimes": [{"id": key, "label": value} for key, value in REGIMES.items()],
"strategies": strategies,
@@ -141,10 +191,49 @@ class ScreenerServiceMixin:
"latest_results": latest_results,
"recent_results": recent_results,
"automatic_status": automatic_status,
"published_batch": published_batch,
"published_status": published_marker or {},
"active_signals": active_signals,
"candidate_history": candidate_history,
# Kept during the client transition for compatibility with older frontends.
"latest_result": latest_results.get("smart"),
}
@staticmethod
def _attach_published_strategy_status(
strategies: list[dict[str, Any]],
automatic_results: list[dict[str, Any]],
marker: dict[str, Any] | None,
published_date: str,
) -> None:
results_by_name = {
str((item.get("meta") or {}).get("strategy_name") or ""): item
for item in automatic_results
}
skipped_by_name = {
str(item.get("name") or ""): item
for item in (marker or {}).get("skipped") or []
}
for strategy in strategies:
name = str(strategy.get("name") or "")
result = results_by_name.get(name)
skipped = skipped_by_name.get(name)
if result is not None:
candidates = result.get("candidates") or []
status = "ready" if candidates else "no_signal"
detail = f"{len(candidates)} 只候选" if candidates else "数据完整,暂无符合条件个股"
elif skipped is not None:
status = "missing_data"
detail = str(skipped.get("reason") or "缺少策略必需数据")
else:
status = "not_run"
detail = "该成功批次未运行此策略"
strategy["published_run"] = {
"trade_date": published_date,
"status": status,
"detail": detail,
}
def screener_tracking(self, limit: int = 12) -> dict[str, Any]:
return self.strategy_tracking.list_tracking(self.current_user_id, limit)
+198
View File
@@ -0,0 +1,198 @@
from __future__ import annotations
from typing import Any
FREQUENCY_VALIDITY_DAYS = {
"每日": 1,
"每日9:25": 1,
"每周": 5,
"双周": 10,
"月度": 20,
"事件驱动": 5,
}
def signal_validity(mode: str, formula: dict[str, Any] | None) -> dict[str, Any]:
if mode == "smart":
return {
"type": "until_regime_change",
"label": "当前阶段不变时有效",
}
meta = (formula or {}).get("meta") or {}
frequency = str(meta.get("frequency") or "每日")
days = FREQUENCY_VALIDITY_DAYS.get(frequency, 1)
return {
"type": "trading_days",
"days": days,
"label": f"{days}个交易日",
}
def attach_strategy_validity(strategy: dict[str, Any]) -> None:
formula = strategy.get("formula") or {}
meta = formula.setdefault("meta", {})
mode = "curated" if meta.get("library") == "curated" else "smart"
meta["signal_validity"] = signal_validity(mode, formula)
def build_candidate_archive(
runs: list[dict[str, Any]],
strategies: list[dict[str, Any]],
trading_dates: list[str],
as_of_date: str,
as_of_regime: str,
history_limit: int = 1200,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
strategy_formulas = {
str(item.get("name") or ""): item.get("formula") or {}
for item in strategies
}
date_positions = {trade_date: index for index, trade_date in enumerate(trading_dates)}
as_of_position = date_positions.get(as_of_date, len(trading_dates) - 1)
history: dict[tuple[str, str, str], dict[str, Any]] = {}
active: dict[tuple[str, str], dict[str, Any]] = {}
ordered_runs = sorted(
runs,
key=lambda item: (
str((item.get("meta") or {}).get("trade_date") or ""),
int((item.get("meta") or {}).get("run_id") or 0),
),
reverse=True,
)
for result in ordered_runs:
meta = result.get("meta") or {}
mode = str(meta.get("mode") or "smart")
if mode not in {"smart", "curated", "quant"}:
continue
selection_date = str(meta.get("trade_date") or "").replace("-", "")
strategy_name = str(meta.get("strategy_name") or "未命名策略")
regime = str(meta.get("regime") or "")
formula = result.get("formula") or strategy_formulas.get(strategy_name) or {}
validity = signal_validity(mode, formula)
valid, valid_until, remaining = _signal_state(
validity,
selection_date,
regime,
trading_dates,
date_positions,
as_of_position,
as_of_regime,
)
hit = {
"selection_date": selection_date,
"strategy_name": strategy_name,
"regime": regime,
"run_id": int(meta.get("run_id") or 0),
"validity": validity,
"valid_until": valid_until,
"remaining_trading_days": remaining,
"active": valid,
}
for candidate in result.get("candidates") or []:
code = str(candidate.get("code") or "")
if not code:
continue
history_key = (mode, selection_date, code)
history_row = history.setdefault(
history_key,
_archive_row(candidate, mode, selection_date),
)
candidate_hit = {**hit, "score_display": candidate.get("score_display")}
_append_hit(history_row, candidate_hit)
if valid:
active_key = (mode, code)
active_row = active.get(active_key)
if active_row is None:
active_row = _archive_row(candidate, mode, selection_date)
active[active_key] = active_row
_append_hit(active_row, candidate_hit)
history_rows = sorted(
history.values(),
key=lambda item: (item["selection_date"], _numeric_score(item["score_display"])),
reverse=True,
)[: max(1, int(history_limit))]
active_rows = sorted(
active.values(),
key=lambda item: (item["selection_date"], _numeric_score(item["score_display"])),
reverse=True,
)
for row in [*history_rows, *active_rows]:
_finalize_archive_row(row)
return active_rows, history_rows
def _signal_state(
validity: dict[str, Any],
selection_date: str,
regime: str,
trading_dates: list[str],
date_positions: dict[str, int],
as_of_position: int,
as_of_regime: str,
) -> tuple[bool, str, int | None]:
if validity.get("type") == "until_regime_change":
return regime == as_of_regime, "", None
days = max(1, int(validity.get("days") or 1))
selected_position = date_positions.get(selection_date)
if selected_position is None or as_of_position < selected_position:
return False, "", 0
elapsed = as_of_position - selected_position
valid = elapsed < days
valid_position = selected_position + days - 1
valid_until = (
trading_dates[valid_position]
if 0 <= valid_position < len(trading_dates)
else ""
)
return valid, valid_until, max(0, days - elapsed) if valid else 0
def _archive_row(
candidate: dict[str, Any], mode: str, selection_date: str
) -> dict[str, Any]:
return {
"mode": mode,
"selection_date": selection_date,
"code": str(candidate.get("code") or ""),
"name": str(candidate.get("name") or ""),
"sector": str(candidate.get("sector") or ""),
"score_display": candidate.get("score_display"),
"pct_chg": candidate.get("pct_chg"),
"return_5d": candidate.get("return_5d"),
"hits": [],
}
def _append_hit(row: dict[str, Any], hit: dict[str, Any]) -> None:
identity = (hit["strategy_name"], hit["regime"], hit["run_id"])
existing = {
(item["strategy_name"], item["regime"], item["run_id"])
for item in row["hits"]
}
if identity not in existing:
row["hits"].append(dict(hit))
def _finalize_archive_row(row: dict[str, Any]) -> None:
hits = row.get("hits") or []
active_hits = [item for item in hits if item.get("active")]
row["matched_strategies"] = list(
dict.fromkeys(item["strategy_name"] for item in hits)
)
row["regimes"] = list(dict.fromkeys(item["regime"] for item in hits if item["regime"]))
row["active"] = bool(active_hits)
row["status"] = "持续有效" if active_hits else "已到期"
labels = list(
dict.fromkeys(item["validity"]["label"] for item in (active_hits or hits))
)
row["validity_label"] = " / ".join(labels)
def _numeric_score(value: Any) -> float:
try:
return float(value)
except (TypeError, ValueError):
return -1.0
+71 -71
View File
@@ -370,29 +370,29 @@
}
],
"css_layers": [
"/shared/tokens.css?v=20260729-1",
"/shared/base.css?v=20260802-5",
"/shared/shell.css?v=20260802-5",
"/shared/auth.css?v=20260802-5",
"/shared/components/controls.css?v=20260802-5",
"/shared/components/navigation.css?v=20260802-5",
"/shared/components/cards.css?v=20260802-5",
"/shared/components/tables.css?v=20260802-5",
"/shared/components/dialogs.css?v=20260802-5",
"/shared/components/feedback.css?v=20260802-5",
"/pages/market/foundation.css?v=20260802-5",
"/pages/sentiment/foundation.css?v=20260802-5",
"/pages/pools/foundation.css?v=20260802-5",
"/pages/ladder/foundation.css?v=20260802-5",
"/pages/rotation/foundation.css?v=20260802-5",
"/pages/auction/foundation.css?v=20260802-5",
"/pages/themes/foundation.css?v=20260802-5",
"/pages/popularity/foundation.css?v=20260802-5",
"/pages/dragon-tiger/foundation.css?v=20260802-5",
"/pages/screener/foundation.css?v=20260802-5",
"/pages/mentor/foundation.css?v=20260802-5",
"/pages/heaven/foundation.css?v=20260802-5",
"/pages/review/foundation.css?v=20260802-5"
"/shared/tokens.css?v=20260806-2",
"/shared/base.css?v=20260806-1",
"/shared/shell.css?v=20260806-2",
"/shared/auth.css?v=20260806-1",
"/shared/components/controls.css?v=20260806-1",
"/shared/components/navigation.css?v=20260806-1",
"/shared/components/cards.css?v=20260806-1",
"/shared/components/tables.css?v=20260806-1",
"/shared/components/dialogs.css?v=20260806-1",
"/shared/components/feedback.css?v=20260806-1",
"/pages/market/foundation.css?v=20260806-1",
"/pages/sentiment/foundation.css?v=20260806-2",
"/pages/pools/foundation.css?v=20260806-2",
"/pages/ladder/foundation.css?v=20260806-1",
"/pages/rotation/foundation.css?v=20260806-1",
"/pages/auction/foundation.css?v=20260806-2",
"/pages/themes/foundation.css?v=20260806-1",
"/pages/popularity/foundation.css?v=20260806-1",
"/pages/dragon-tiger/foundation.css?v=20260806-1",
"/pages/screener/foundation.css?v=20260806-2",
"/pages/mentor/foundation.css?v=20260807-1",
"/pages/heaven/foundation.css?v=20260806-2",
"/pages/review/foundation.css?v=20260806-1"
],
"frontend_composition": {
"shell": "frontend/index.html",
@@ -436,43 +436,43 @@
"code_hotspots": [
{
"path": "frontend/pages/heaven/foundation.css",
"bytes": 183624,
"lines": 11655
"bytes": 185936,
"lines": 11734
},
{
"path": "frontend/pages/screener/foundation.css",
"bytes": 100200,
"lines": 6433
"bytes": 102987,
"lines": 6565
},
{
"path": "frontend/pages/heaven/page.js",
"bytes": 92669,
"lines": 1965
},
{
"path": "backend/features/heaven/engine.py",
"bytes": 51670,
"lines": 1181
},
{
"path": "frontend/index.html",
"bytes": 44688,
"lines": 632
"bytes": 97189,
"lines": 2069
},
{
"path": "frontend/shared/shell.css",
"bytes": 40260,
"lines": 2800
"bytes": 51978,
"lines": 3224
},
{
"path": "backend/features/heaven/engine.py",
"bytes": 51764,
"lines": 1183
},
{
"path": "frontend/index.html",
"bytes": 45846,
"lines": 643
},
{
"path": "backend/features/screener/catalog.py",
"bytes": 35424,
"lines": 707
"bytes": 35571,
"lines": 711
},
{
"path": "frontend/pages/auction/foundation.css",
"bytes": 32818,
"lines": 2305
"bytes": 34990,
"lines": 2409
},
{
"path": "database.py",
@@ -501,29 +501,29 @@
},
{
"path": "frontend/pages/heaven/page.html",
"bytes": 19103,
"lines": 255
"bytes": 19747,
"lines": 262
},
{
"path": "frontend/pages/screener/page.html",
"bytes": 18513,
"lines": 219
"bytes": 19579,
"lines": 229
},
{
"path": "frontend/pages/market/preview.js",
"bytes": 18178,
"lines": 446
},
{
"path": "backend/features/heaven/trend.py",
"bytes": 16772,
"lines": 370
},
{
"path": "backend/features/market/insights_auction_scoring.py",
"bytes": 16689,
"lines": 355
},
{
"path": "backend/features/heaven/trend.py",
"bytes": 16310,
"lines": 358
},
{
"path": "frontend/pages/market/charts.js",
"bytes": 15311,
@@ -551,8 +551,8 @@
},
{
"path": "frontend/shared/session.js",
"bytes": 12842,
"lines": 285
"bytes": 13176,
"lines": 293
},
{
"path": "backend/features/market/insights_auction_data.py",
@@ -569,6 +569,11 @@
"bytes": 10717,
"lines": 221
},
{
"path": "backend/features/heaven/readings.py",
"bytes": 10539,
"lines": 244
},
{
"path": "backend/data/providers/tushare_sectors.py",
"bytes": 9876,
@@ -579,11 +584,6 @@
"bytes": 9348,
"lines": 222
},
{
"path": "backend/features/heaven/readings.py",
"bytes": 9313,
"lines": 220
},
{
"path": "frontend/pages/market/entity-detail.js",
"bytes": 9119,
@@ -624,16 +624,16 @@
"bytes": 6739,
"lines": 156
},
{
"path": "frontend/shared/context.js",
"bytes": 6547,
"lines": 220
},
{
"path": "frontend/pages/sentiment/page.html",
"bytes": 6488,
"lines": 81
},
{
"path": "frontend/shared/context.js",
"bytes": 6433,
"lines": 216
},
{
"path": "backend/data/providers/tushare_stocks.py",
"bytes": 6244,
@@ -644,6 +644,11 @@
"bytes": 6202,
"lines": 141
},
{
"path": "frontend/pages/mentor/page.html",
"bytes": 6190,
"lines": 89
},
{
"path": "backend/features/screener/selection.py",
"bytes": 6092,
@@ -659,11 +664,6 @@
"bytes": 5690,
"lines": 124
},
{
"path": "frontend/pages/mentor/page.html",
"bytes": 5501,
"lines": 72
},
{
"path": "backend/data/providers/tushare_indices.py",
"bytes": 5451,
+117
View File
@@ -0,0 +1,117 @@
{
"version": "2026.08.05-5",
"sources": {
"zhouyi": {
"title": "周易经文与十翼",
"scope": "卦辞、爻辞、彖传、象传",
"kind": "public_domain_primary",
"note": "观势与观心只引用本项目已校录的卦爻原文,不把现代网络释文当作原典。"
},
"jingfang": {
"title": "京氏易传",
"scope": "八宫与纳甲体系来源",
"kind": "public_domain_traditional",
"note": "确定性程序采用京房纳甲、八宫世应的通行排法。"
},
"huozhulin": {
"title": "火珠林",
"scope": "纳甲筮法、六亲与日月关系",
"kind": "public_domain_traditional",
"note": "用于观心规则脉络,不直接复制后世简化断语。"
},
"zengshan": {
"title": "增删卜易",
"scope": "用神、世应、动变、日月旺衰",
"kind": "public_domain_traditional",
"note": "只采用可明确编码且有一致输入条件的规则;争议规则单独标记。"
},
"neijing": {
"title": "黄帝内经·素问运气七篇",
"scope": "五运、司天在泉、主客气与运气关系",
"kind": "public_domain_primary",
"note": "观气将原典关系转成当日自我观察语言,不宣称对股价存在因果作用。"
}
},
"trend": {
"method": "本卦说明当下结构,实际动爻说明变化关节,之卦说明所趋结构;多动爻全部保留,不以固定口诀删去用户实际得到的爻。",
"rules": {
"stable": "无动爻时以本卦整体、上下卦关系和大象为主,说明结构的延续条件,不把静止等同于永远不变。",
"single": "一爻动时以该爻的时位、爻辞和象辞为变化核心,并用之卦检查变化后的结构。",
"multiple": "多爻动时逐一保留相关爻义,先找共同方向与冲突,再结合之卦给出有条件的倾向;不得用固定套话把不同动爻压成同一结论。"
}
},
"fortune": {
"principle": "先立中运与司天在泉的年纲,再察当前客气加临主气,最后以日辰说明当日触发;不使用产品权重推导传统结论。",
"movement": {
"太过": "太过表示该运之气偏于有余,解释时同时观察其本气表现与对所胜、所生关系的牵动,不直接等同于吉或凶。",
"不及": "不及表示该运之气偏于不足,解释时同时观察其所不胜来乘与所生受累的可能,不直接等同于弱势结论。"
},
"qi": {
"厥阴风木": "厥阴取风木之动,侧重疏泄、升发、变化与不定;偏盛时可表现为动摇、急变或升散不收。",
"少阴君火": "少阴取君火之明与热,侧重显化、温煦和内在驱动;偏盛时容易躁热,受制时则显而不畅。",
"太阴湿土": "太阴取湿土之濡与承载,侧重黏滞、蓄积和转化;偏盛时容易困重迟缓,得化时则能承接。",
"少阳相火": "少阳取相火之行与枢转,侧重外达、加速和往来;偏盛时容易浮越躁动,受阻时表现为枢机不利。",
"阳明燥金": "阳明取燥金之收与清肃,侧重收敛、裁决和边界;偏盛时容易干急严峻,得润时则清明有序。",
"太阳寒水": "太阳取寒水之藏与凝,侧重潜藏、收引和下行;偏盛时容易凝滞退缩,得温时则蓄势有根。"
},
"relations": {
"same": "客主同气表示同类气相并,重点看是否相得而彰,还是同气偏盛而亢;不能机械判为有利。",
"guest_generates_host": "客生主表示来气生助时令本气,气机较易衔接;仍需观察生助是否过度及年纲是否承接。",
"host_generates_guest": "主生客表示时令本气向来气流转,有相生也有外泄;不能只取相生而忽略主气受耗。",
"guest_controls_host": "客克主表示来气制约主气,传统称客胜为从;重点解释外来变化居上及原有节律受制。",
"host_controls_guest": "主克客表示主气制约来气,传统称主胜为逆;重点解释时令与来气相持而不把相克直接断凶。"
},
"day_trigger": "日辰只说明当日关系如何被触发,不与中运、司天在泉或主客气并列重复计权。",
"industry_boundary": "五行对应行业只作传统取象:可以说明本次已经出现的五行之气对相应行业形成的象征性关注、节奏或约束,但不得读取或猜测行业实时行情,不得预测涨跌,也不得把取象写成投资推荐。",
"personal_boundary": "personal.natal_day_master才是用户本命日主;today_relative_to_natal_day_master中的pillars是当日历法,stem_relations只是当日年、月、日三柱天干相对本命日主的确定性关系标签。只能使用本次检索到的关系释义,不得自行重算十神、扩展五行生克、使用藏干、库气或支的燥湿属性,也不得把当日日柱称为用户命局,或由这些字段推断命局中某一十神偏重、身强身弱或喜用神。",
"personal_relations": {
"比肩": "比肩作为当日天干关系标签,只提示用户可能更在意自主判断、同类比较或坚持原有立场;不能据此判断命局强弱或现实事件。",
"劫财": "劫财作为当日天干关系标签,只提示用户留意精力、注意力或可支配资源在同类事项间的分流与竞争感;不等同于破财或他人争夺。",
"食神": "食神作为当日天干关系标签,只提示用户留意表达、输出、舒缓与完成感;不等同于收益或确定的轻松结果。",
"伤官": "伤官作为当日天干关系标签,只提示用户留意质疑规则、急于表达或追求自主空间的倾向;不等同于冲突或违规。",
"偏财": "偏财作为当日天干关系标签,只提示用户留意机会分配、灵活取舍与非固定资源的吸引力;不等同于意外获利。",
"正财": "正财作为当日天干关系标签,只提示用户更关注可核对的结果、资源边界和务实落地;不等同于必得收益或现金变化。",
"七杀": "七杀作为当日天干关系标签,只提示用户留意紧迫感、外部压力和快速决断冲动;不等同于危险必然发生。",
"正官": "正官作为当日天干关系标签,只提示用户更在意规则、责任、秩序和可交付标准;不等同于结果必然受控。",
"偏印": "偏印作为当日天干关系标签,只提示用户留意内省、非惯常信息和反复推敲的倾向;不等同于退缩、失眠或方向错误。",
"正印": "正印作为当日天干关系标签,只提示用户更在意依据、支持、学习和安全边界;不等同于必然获得帮助。"
}
},
"heart": {
"presets": {
"trade": "关于我心中的这笔交易,此刻最需要看清的机会、阻碍与风险是什么?",
"mind": "此刻影响我交易判断的情绪、执念或盲点是什么?",
"unthemed": "不设具体问题,只观此刻一念。"
},
"focus": {
"trade": "以世爻、应爻、妻财爻及实际动变为主要检索对象,同时检查兄弟、官鬼和子孙的生克,不把任何单一六亲固定判吉凶。",
"mind": "以世爻和实际动爻为主,观察官鬼所示压力、子孙所示舒解及内外生克;不把心境问题强行翻译成价格方向。",
"unthemed": "不强选事项用神,以本卦、世爻、实际动爻和之卦作一般观照,不猜测用户没有提出的问题。",
"custom": "先依据用户明确写出的股票交易问题选择相关六亲;无法明确归类时退回世爻、动爻和卦变的一般解释,不擅自补全问题。"
},
"evidence_order": [
"用户问题与预设来源",
"本卦及卦宫",
"世应与所问相关六亲",
"月建日辰、旬空及冲合生克",
"实际动爻与变爻",
"之卦与整体卦义",
"六神辅助象义"
],
"limits": "六神只作辅助象义;空亡、月破、日冲、合冲刑害均需结合用神、世应和动变,不得单项宣布结果。",
"semantics": {
"self_response": "世爻表示求测者当前立场与承受状态,应爻表示所问事项的外部一端或对照面。应爻不是固定的合作方、庄家或资金方;只有用户问题明确给出该角色时,才可作对应解释。",
"calendar": "月建与日辰用于判断爻在起卦时刻的承受、生扶和制约。旬空表示该爻所象征的条件当下可能未落实、难发挥或有名无实,但不能单凭旬空判失败,也不能用填实日期预测何时涨跌或行动。月破、日冲、六合、六冲、六害和相刑同样必须与世应、相关六亲及动变合看。",
"movement": "动爻说明关系正在变化;变爻说明变化后的承接方向。回头生、回头克和原变爻生克只描述力量关系,不自动对应现实中的借贷、融资、合作或某个具体人物。进神退神只说明同类地支变化的进退趋势,不直接宣布价格方向。",
"six_spirits": "六神只补充表达色彩,不单独定成败。青龙不必然有利,白虎不必然紧急或凶险,朱雀不必然等同口舌,玄武不必然等同欺骗,勾陈与螣蛇也不得脱离爻位、六亲和动变独断。",
"timing_boundary": "观心不作应期预测。可以说明某项条件在起卦时刻尚未落实或受制,但不得给出未来若干日、某干支日、出空或填实后必然发生什么。",
"relatives": {
"兄弟": "兄弟是与卦宫五行同类的关系。在股票交易问题中可作为竞争、同类力量或资源分流的候选象义,但不直接等同合作方、亏损或他人拿走资金。",
"子孙": "子孙是卦宫所生的关系,可作为舒缓、产出、执行后的释放或对压力的制衡候选象义,但不直接等同收益、资金提供方或确定的利好。",
"妻财": "妻财是卦宫所克的关系,在股票交易问题中可作为价值、收益预期、持仓利益或可支配资源的候选象义,但不直接等同现金、融资、自有资金或必得之财。",
"官鬼": "官鬼是克制卦宫的关系,可作为压力、风险、规则约束或担忧的候选象义,但不直接等同借贷、坏消息、疾病或必然损失。",
"父母": "父母是生助卦宫的关系,可作为信息、依据、计划、规则、凭据或保护条件的候选象义,但不直接等同政策、合同或某一条消息。"
}
}
}
}
+320
View File
@@ -0,0 +1,320 @@
# 小白复盘项目交接说明
> 核实日期:2026-08-06Asia/Shanghai
> 正式源码边界:`webapp/app/`
> 产品行为基准:`docs/product/小白复盘-完整产品规格说明书.md`
本文件不是聊天摘要。内容以当前仓库、配置注册表、测试、Git 状态和产品规格交叉核实为准。后续维护者应先阅读根目录 `AGENTS.md``ARCHITECTURE.md`、本文件和产品规格,再修改代码。
## 0. 状态口径与证据
本文使用四种状态,不能混用:
- **已实现**:当前正式源码中存在对应实现。
- **自动验证通过**:有测试或注册表检查证明,不等同于人工视觉验收。
- **人工已验收**:用户已经确认迁移后的正式 `app/` 在功能和视觉上与迁移前等价;该结论只覆盖当时基线。
- **待验收/待实现**:代码尚未完成,或虽已写入工作区但尚未取得本轮人工确认和 Git 回档点。
### 0.1 Git 与运行快照
- 分支:`main`
- 当前提交:`bd97ba1 feat: unify trading workspace visual system`
- `HEAD``origin/main` 一致;远端为内部 Gitea 仓库。
- 生成本文前工作区已有 37 个修改文件,约 `2490` 行新增、`2958` 行删除,主要是全站视觉调整和最新问师改造;这些改动不是本文创建的,禁止丢弃。
- 生成本文时 `8797` 端口没有监听进程,因此实时数据源和 LLM 的运行可用性没有通过在线健康检查确认。
- 当前正式数据库为 `data/review.db`,使用 SQLite WAL;数据库、`.env`、Token、私有 Skill、日志和运行产物不进入 Git。
- 本轮文档生成后的自动验证结果见本文末尾“验证记录”。
## 1. 项目目标和当前状态
### 1.1 项目目标
小白复盘是面向 A 股盘后复盘和盘前观察的本地/局域网 Web 工作台。目标不是自动交易,而是把真实行情、市场情绪、涨跌停结构、集合竞价、板块题材、选股、思维模型问答、传统文化观察和个人复盘放在一套可追溯、可复现、账号隔离的系统中。
产品必须坚持以下底线:
1. 不使用演示行情冒充真实数据,不静默混用日期、单位、复权或数据源。
2. 计算型数据缺失时失败关闭;公开网页源只允许作为已登记的展示兜底。
3. 阶段、策略筛选、情绪、观势取象和六爻排盘由确定性程序完成;LLM 只编译自然语言条件或解释确定性结果。
4. 用户自选、复盘、交易日志、问师/问天历史等私有数据必须按账号隔离。
5. PC 端优先达到稳定、精致、可长期维护;移动端必须独立设计,不能把 PC 页面简单压缩。
### 1.2 当前状态
正式版本已经从历史混乱目录保真迁入 `webapp/app/`,用户已人工确认迁移本身在功能和视觉上成功。项目已经完成模块化单体边界、页面碎片化、数据网关、LLM 网关、后台任务、数据库迁移、注册表和统一验收工具等结构治理。
当前不是“从零重写”状态,也不应再次从旧根目录或失败的 `next/` 复制实现。现阶段属于:
- 核心 PC 产品可用,16 个主工作区均有正式实现。
- 当前工作区正在进行全站 PC 视觉一致性调整,以及问师经典 QQ 式三栏界面和动态追问能力;自动化测试已覆盖,尚待本轮人工视觉验收和提交。
- 移动端明确暂停,当前存在样式但不能据此宣称可用。
- 完整 IC 动态加权、稳定宏观/政策/隔夜消息、分析师一致预期、Level-2 等依赖数据与算法的能力尚未完成。
- 局域网单实例是当前部署边界;公网多实例能力不属于当前完成范围。
## 2. 技术架构与主要目录
### 2.1 总体架构
项目采用**模块化单体**:一个 Python 进程、一个 SQLite WAL 数据库、无构建工具的 HTML/CSS/JavaScript 前端。
```text
Browser
-> frontend/shared/api.js
-> backend/http + backend/features/<feature>/routes.py
-> feature service
-> Repository / DataGateway / LLMGateway
-> SQLite / Tushare / iFinD / display-only providers / LLM provider
Scheduler
-> backend/jobs
-> 同一套 feature service / repository / gateway
```
该结构适合当前局域网单实例产品:部署简单、数据本地、回档直接,同时通过领域边界避免再次退化成单文件应用。除非进入公网多实例阶段,不要提前引入微服务、消息队列或前端构建框架。
### 2.2 主要目录
| 路径 | 唯一职责 |
|---|---|
| `server.py` | 稳定启动/导入门面 |
| `backend/bootstrap/` | 配置、依赖组装、启动与组合根 |
| `backend/http/` | 鉴权、请求 ID、JSON/NDJSON、静态文件、流式连接和统一异常 |
| `backend/features/` | 按账户、市场、选股、问师、问天、复盘等领域组织业务、路由和 Repository |
| `backend/data/` | `DataGateway`、数据源策略、来源/日期/单位/新鲜度/覆盖率质量门 |
| `backend/data/providers/` | Tushare、iFinD 等供应商适配;不得由业务模块直接调用 |
| `backend/database/` | SQLite 连接、顺序迁移和 Repository 组合 |
| `backend/jobs/` | 行情刷新、盘后选股、事件补充的锁、状态、幂等和重试 |
| `backend/llm/` | 模型选择、会员/额度、主辅回退、流式协议、取消和审计 |
| `frontend/index.html` | 登录层、全站 Shell、摘要条、状态栏、全局弹窗和唯一页面挂载点 |
| `frontend/shared/` | 唯一 API 出口、状态、Shell、会话、主题和公共组件 |
| `frontend/pages/` | 页面局部 `page.html``page.js``foundation.css` |
| `config/` | 页面、功能、API、数据字段、质量和任务注册表 |
| `data/` | 正式数据库与私有数据,不入 Git |
| `runtime/` | 日志、PID、缓存、测试结果,不入 Git |
| `tests/` | Python 单元/边界/契约测试与 Playwright 浏览器回归 |
| `tools/` | 启动、注册表生成、架构清单和统一验收工具 |
| `docs/` | 产品规格、维护、治理、历史迁移和当前交接/Issue |
### 2.3 注册表和运行事实
- `config/pages.config.json`16 个主页面,默认页为情绪周期。
- `config/features.config.json`20 个功能及 `public/authenticated/member/admin` 权限。
- `config/api.config.json`:当前 53 个精确 API 路径和 11 个正则路径,由工具生成并校验。
- `config/jobs.config.json`:行情刷新、15:10 后盘后选股、iFinD 事件补充三类任务。
- `config/data-fields.config.json`:数据源与字段用途;Tushare/iFinD 可进入已登记计算,东方财富/腾讯只允许展示,未解决数据集显式阻塞。
- `config/data-quality.config.json`:单位、覆盖率、新鲜度和失败关闭规则。
- `config/architecture-inventory.json`:生成的架构清单和代码热点,不应手工编造。
### 2.4 数据源边界
| 数据源 | 当前角色 | 约束 |
|---|---|---|
| Tushare | 交易日、股票主数据、日线、估值、财务、资金、申万行业、涨跌停、最终竞价、热榜、龙虎榜等主要计算数据 | 按接口权限和质量门使用 |
| iFinD | 动态竞价、展示型日 K/分时和盘后事件补充 | 凭据/授权到期时必须显式不可用,不得伪造 |
| 东方财富/腾讯 | 分时或实时指数的展示观察兜底 | 不得静默进入情绪、选股或问天计算 |
| Local | 情绪等确定性派生结果 | 保存算法/输入版本,保证可复现 |
| unresolved | 分析师一致预期、Level-2 | 当前阻塞,不能用名称或空字段冒充实现 |
## 3. 已完成功能
以下表示当前正式源码存在实现;人工视觉结论仅继承用户对迁移基线的确认,不覆盖本轮未提交视觉改动。
### 3.1 全局与账户
- 注册、登录、退出、首账号管理员、普通/会员/管理员权限。
- 个人资料、生辰资料、修改密码、会员状态、系统管理与公共凭据配置。
- 顶栏日期、默认最近真实交易日、情绪摘要条、日间/夜间、全局搜索、提醒中心。
- 股票、题材、板块、指数详情;日 K/分时与代码/题材悬浮预览。
- 统一 Toast、弹窗、空态、加载、错误转换和页面生命周期基础设施。
### 3.2 市场复盘页面
- 情绪周期:温度、阶段、方向、置信度、构成、趋势和交易日明细。
- 涨停池、炸板池、跌停池、昨日涨停、涨停表现。
- 市场天梯、板块轮动与成分股联动。
- 集合竞价:盘前状态、9:25 最终筛选、普通异动/一字板、成交额对比和自选。
- 题材库、人气热榜、龙虎榜和游资名录/详情基础能力。
### 3.3 智能选股
- 六阶段盘后候选、29 套精选策略、策略适用说明和确定性候选结果。
- 自定义公式 DSL、自然语言编译公式、因子与权重手动配置。
- 候选按策略/日期隔离,盘后自动发布最近完整交易日结果。
- 用户手动加入五交易日策略跟踪,T+1/T+3/T+5 反馈和幂等提醒。
- 数据缺失、无符合条件、任务失败等状态区分。
- 当前多因子为基础动态版;完整 IC 版不在“已完成”范围内。
### 3.4 问师与 LLM
- 公共/管理员私有思维模型 Skill 注册、证据等级、关注维度和排序偏好。
- 按账号、模型、交易日隔离对话;最多带入最近 10 条历史。
- 按模型类型提供不同市场上下文,识别个股时追加有限标的数据。
- 统一 LLM 会员/额度、主辅回退、流式去重、停止生成、审计和安全错误。
- 当前工作区已经实现经典 QQ 式联系人/会话/资料三栏和同次调用动态追问;状态为“自动验证通过、待人工验收和提交”,详见 Issue 001。
### 3.5 问天
- 观势:真实行情安全门、三才六爻、势值、本卦/之卦、客观数据补录与恢复自动数据。
- 观气:历法、节气、中运/司天在泉/主客气、个人合参、五行行业取象和每日解运持久化。
- 观心:交易/心境/无题预设、呼吸流程、六次铜钱起卦、第一念、京房纳甲/八宫世应/六亲/六神/旬空等确定性排盘。
- 本地知识检索、答案一致性校验和 LLM 解释;LLM 不起卦、不修改程序结果。
### 3.6 个人复盘
- 账号私有自选追踪、个股笔记、三个独立输入框的每日复盘及历史。
- 结构化交易日志、编辑删除、胜率/盈亏/仓位统计。
- 复盘助手流式对话,读取共享市场和当前用户记录,不执行交易。
- 手工提醒、已读状态、策略跟踪 T+1/T+5 自动提醒和幂等去重。
### 3.7 工程治理
- 正式源码独立于父目录旧程序和失败 `next/`
- 页面结构、行为和样式已按领域拆分;浏览器请求统一经过 `frontend/shared/api.js`
- Tushare 大客户端、智能选股、问天、市场洞察和 HTTP 层已拆成职责明确的模块门面。
- 有正式数据库 migration、数据/LLM/job 网关、API/功能/页面/数据注册表。
- 统一验收工具覆盖 Python、注册表、JS 语法、Git 空白、SQLite 完整性和可选 Playwright。
## 4. 尚未完成的功能
每项均有独立 Issue,Issue 状态优先于历史聊天中的阶段编号。
| Issue | 状态 | 优先级 | 未完成内容 |
|---|---|---:|---|
| [ISSUE-001](issues/ISSUE-001-finalize-mentor-redesign.md) | 待人工验收/提交 | P0 | 问师三栏界面、停止生成和动态追问收口 |
| [ISSUE-002](issues/ISSUE-002-checkpoint-current-pc-visual-work.md) | 待审查/提交 | P0 | 当前全站 PC 视觉改动的逐页验收、拆分和回档点 |
| [ISSUE-003](issues/ISSUE-003-mobile-redesign.md) | 明确延期 | P2 | 独立移动 Shell、逐页信息架构和触控交互 |
| [ISSUE-004](issues/ISSUE-004-full-ic-multifactor.md) | 未实现 | P1 | 12 个月 Rank IC、季度重算、中性化和前 5% 输出 |
| [ISSUE-005](issues/ISSUE-005-policy-macro-overnight-data.md) | 数据源未定 | P1 | 稳定政策/宏观/隔夜消息序列与竞价量化 |
| [ISSUE-006](issues/ISSUE-006-analyst-consensus-data.md) | 数据阻塞 | P2 | 一致预期、预测修正、评级/目标价等字段 |
| [ISSUE-007](issues/ISSUE-007-level2-auction.md) | 授权阻塞 | P2 | Level-2 委托队列、逐笔和动态竞价深度 |
| [ISSUE-008](issues/ISSUE-008-hot-money-profile-history.md) | 低优先级 | P3 | 游资档案的更完整历史画像和归类质量 |
| [ISSUE-009](issues/ISSUE-009-documentation-status-drift.md) | 待整理 | P1 | 活跃文档/注册表中移动端、端口和验收状态漂移 |
| [ISSUE-010](issues/ISSUE-010-live-provider-llm-readiness.md) | 待运行核验 | P0 | 启动正式服务并验证数据源、iFinD、LLM 与任务健康 |
| [ISSUE-011](issues/ISSUE-011-public-deployment-hardening.md) | 未来范围 | P3 | 公网多实例、TLS、PostgreSQL、队列、缓存和集中监控 |
明确不是待办:问师自主联网取数当前已因风险高于收益而延期;全能金融爬虫 Skill 已放弃;旧 `next/` 已冻结失败;不要把这些内容重新加入实现。
## 5. 已知问题与风险
### 5.1 用户可见问题
1. **移动端整体不可用或交互较差。** 当前存在大量媒体查询和 `mobile_layout: dedicated` 注册值,但这只证明代码存在,不证明通过人工可用性验收。
2. **当前问师与全站视觉改动未完成交付闭环。** 自动化已通过,但工作区未提交,且用户尚未对本轮 QQ 式问师界面进行视觉确认。
3. **实时数据和 LLM 当前在线状态未知。** 生成本文时 8797 未启动;外部服务还受本机网络、系统凭据、接口权限和 iFinD 授权有效期影响。
4. **缺失数据不能被误显示为无信号。** 分析师一致预期、Level-2 和部分宏观/新闻数据目前无正式来源;相关策略或页面必须显示数据缺失/阻塞。
### 5.2 维护风险
- `frontend/pages/heaven/foundation.css` 约 11,734 行、`frontend/pages/screener/foundation.css` 约 6,565 行、`frontend/shared/shell.css` 约 3,224 行;它们是当前最大 CSS 热点。没有具体回归证据时不得为了“减行数”盲拆。
- `frontend/pages/heaven/page.js` 约 2,069 行、`backend/features/heaven/engine.py` 约 1,183 行,问天仍是高复杂度领域。
-`database.py` 仍是历史 schema/Repository 组合锚点,不是新增业务查询的位置;继续向其中加功能会破坏治理结果。
- 自动化测试不能替代产品规格第 25 至 27 节的全矩阵人工验收,尤其是外部真实数据、LLM、日夜主题、1080P/4K 和移动端。
- 当前脏工作区横跨 37 个文件。提交前必须按功能拆分或至少留下清晰回档说明,不能把无关改动混成无法审计的大提交。
## 6. 已作出的重要技术决策及原因
| 决策 | 原因 |
|---|---|
| `webapp/app/` 是唯一正式源码 | 已完成保真迁移并人工确认;避免继续依赖父目录旧代码或失败 `next/` |
| 保持模块化单体 | 当前局域网单实例用一个进程和 SQLite 最简单;领域边界已经足以控制复杂度 |
| 不更换技术栈,前端保持无构建 HTML/CSS/JS | 迁移目标是整理和减法,不是重拍功能;减少部署与人工维护成本 |
| 页面、功能、API、数据和任务采用注册表 | 防止入口散落、权限漂移和“代码有但系统不知道” |
| 浏览器 API、外部数据和 LLM 各自只有一个网关出口 | 统一鉴权、错误、质量、额度、降级和审计 |
| 计算数据失败关闭,展示兜底隔离 | 防止公开网页源或旧快照静默污染情绪、选股、竞价和问天结果 |
| 智能选股由条件和数据确定执行,LLM 只编译公式 | 保证同日期同策略可复现,避免刷新结果漂移 |
| 问天确定性引擎负责历法/卦象,LLM 只解释 | 结果可复现、可测试,避免模型改卦或编造事实 |
| 问师外部工具自主取数暂缓 | 当前缺少成熟权限、来源和失败边界,风险高于收益 |
| 放弃通用金融爬虫 Skill | 网页规则不稳定、版权/安全/口径不可控,不适合进入正式计算链 |
| 移动端暂停并要求独立设计 | 密集 PC 表格不能靠压缩获得可用手机体验;先保证 PC 功能与视觉 |
| 不确定代码默认保留,删除需扫描、差异测试和人工验收 | 防止“减法”误删隐含功能;历史迁移日志用于回档证据 |
| 公网能力不提前实现 | 当前用户场景是本地/局域网;多实例、PostgreSQL 和队列应由真实部署需求驱动 |
## 7. 当前正在处理的事项
### 7.1 问师改造
当前未提交代码已经完成:
- 经典 QQ 式 PC 三栏结构:联系人、对话、当前模型资料/证据。
- 动态追问:模型在同一次输出末尾返回 `<XIAOBAI_FOLLOW_UPS>` 机器块;服务端剥离机器块,并在最终 NDJSON `meta.follow_ups` 返回 2 至 3 条建议。
- 动态追问不额外调用 LLM、不重复扣额度;点击只预填输入框。
- 停止生成控制、Enter 发送、流式占位与回答状态。
- 问师 CSS 从历史约 2,800 行收敛到约 988 行。
相关文件:
- `backend/features/mentor/agent.py`
- `backend/features/mentor/service.py`
- `frontend/pages/mentor/page.html`
- `frontend/pages/mentor/page.js`
- `frontend/pages/mentor/foundation.css`
- `tests/test_mentor_stream.py`
- `tests/e2e/app-shell.spec.js`
尚缺:启动正式服务、接入真实 LLM 做一次端到端验证、用户人工确认日间/夜间及 1080P/4K 视觉、建立提交并推送回档点。
### 7.2 当前全站视觉改动
工作区还包含 Shell、设计令牌、公共组件以及市场、情绪、股池、天梯、轮动、竞价、题材、热榜、龙虎榜、选股、问天、复盘等页面样式改动。它们已进入自动化回归,但尚未形成独立验收结论。移动端已被产品决策暂停,因此不能因为这些 CSS 中存在移动规则就标记移动端完成。
## 8. 推荐的后续执行顺序
1. **先恢复运行环境并核验外部能力。** 启动 8797,检查健康、登录、最近真实交易日、Tushare/iFinD、LLM 主辅模型和后台任务;不通过时先解决 Issue 010。
2. **人工验收问师。** 完成 Issue 001 的真实 LLM、流式、停止、动态追问、日夜和分辨率检查。
3. **审查当前全站视觉差异。** 按 16 页逐页检查 Issue 002,确认哪些是 PC 正式改动、哪些是已暂停移动尝试,保持功能等价。
4. **建立回档点。** 将问师和全站视觉按可审计边界提交并推送,不夹带密钥、数据库或运行产物。
5. **清理活跃文档状态漂移。** 完成 Issue 009,使 README、注册表和交接状态不再暗示移动端已验收。
6. **先补可获得的高价值数据,再升级算法。** 先确定 Issue 005/006 的合法稳定来源,再实施 Issue 004;没有完整历史覆盖时不能伪造 IC。
7. **有正式授权后再做 Level-2。** Issue 007 不能用普通快照模拟。
8. **低优先级完善游资档案。** Issue 008 不应阻塞市场、选股、问师和问天稳定性。
9. **PC 稳定后才重启移动端设计。** Issue 003 必须单独打样和逐页人工验收。
10. **确定公网商业化再做部署升级。** Issue 011 需要单独架构决策和迁移方案。
## 9. 每项任务的验收标准
本节是交接总表;独立 Issue 内给出更具体的范围和命令。产品规格第 25 至 27 节固定案例仍是最终依据。
| 任务 | 必须满足的验收标准 |
|---|---|
| Issue 001 问师收口 | 真实 LLM 只输出一份正文;同次调用出现 2 至 3 条有效追问;点击只预填;停止后不上演回退重放;无额外额度;日夜、1080P/4K 人工通过 |
| Issue 002 PC 视觉回档 | 16 页日间/夜间、1920×1080、4K 无白块、遮挡、双滚动和功能回归;当前差异可解释;提交可独立回退 |
| Issue 003 移动端 | 320/375/390/430/768 及横屏无页面横溢;底部五入口、市场子导航、弹窗/抽屉、宽表和键盘交互可用;用户逐页验收 |
| Issue 004 完整 IC | 行业内去极值、z-score、行业/市值中性化、过去 12 月下期收益 Rank IC、季度重算、前 5% 均有版本化确定性测试;无未来函数;UI 明确基础/IC 模式 |
| Issue 005 政策宏观隔夜 | 合法稳定来源、字段/单位/时间/版权/新鲜度登记完整;历史归档可复现;缺失显式失败;消息只按确认规则进入竞价量化 |
| Issue 006 一致预期 | 五类字段有 point-in-time 历史、公告时点和覆盖率;策略缺数据与无命中可区分;回测无未来函数 |
| Issue 007 Level-2 | 有正式授权;委托队列/逐笔/快照时间可追溯;盘中断线不伪造;与 9:25 最终归档区分;回放测试通过 |
| Issue 008 游资画像 | 名录、别名、席位归类和历史操作可追溯;未知席位保留;同名误合并有回归测试;左名录右详情无超长弹窗 |
| Issue 009 文档漂移 | 活跃文档、端口、移动端状态、完成状态与注册表一致;历史迁移文档明确只作审计,不被当运行说明;文档链接有效 |
| Issue 010 在线就绪 | `/api/health` 可达;登录和最近真实快照正常;数据源与 LLM 分别可诊断;失效凭据不泄露;重启后任务与结果不重复 |
| Issue 011 公网部署 | 完成 ADR;TLS、可信 Host、限流、集中密钥、审计、备份恢复、多实例数据库和任务互斥全部通过;不破坏局域网数据边界 |
### 9.1 通用自动验收
```powershell
cd C:\Users\MoBai\Documents\gupiaofupan\webapp\app
python tools/verify_baseline.py
python tools/verify_baseline.py --e2e
git diff --check
```
### 9.2 通用人工验收
- 普通、会员、管理员三种权限。
- 正常、有数据为空、数据缺失、上游失败、请求超时、最近快照九类状态。
- 日间、夜间、1920×1080、3840×2160;移动 Issue 开始后再加入完整移动视口矩阵。
- 真实行情日期与图表一致;开盘前不制造当天空 K 线。
- 用户甲乙的自选、复盘、日志、对话、问天历史互不可见。
- 所有保存/删除/添加只出现可关闭的规范反馈,不出现超长空弹窗。
- 密钥、数据库、日志和私有 Skill 不进入 Git diff。
## 10. 验证记录
2026-08-06 本轮结果;后续代码变化后不能沿用:
- Python326 个测试全部通过(约 11.8 秒)。
- Playwright49 个测试全部通过(约 2.2 分钟)。
- API 注册表与架构清单:均为 current。
- JavaScript:统一工具枚举的全部 `.js/.mjs` 均通过 `node --check`
- SQLite`data/review.db``PRAGMA integrity_check``ok`,验证时大小为 455,434,240 字节。
- Git`git diff --check` 通过。
- 说明:组合命令在本代理的 120 秒命令上限处被终止于 Playwright 阶段;Playwright 随后以同一配置单独运行并完整通过,因此上述各子项均有本轮实际结果。
+3 -1
View File
@@ -1,9 +1,11 @@
# 文档索引
- `product/小白复盘-完整产品规格说明书.md`:从零恢复产品时的完整功能与行为资产。
- `HANDOFF.md`:当前仓库、架构、完成度、风险和后续验收的交接基线。
- `issues/README.md`:尚未完成事项的独立 Issue 索引;Issue 不是已创建的 Gitea 工单。
- `maintenance/人工维护指南.md`:当前正式源码的启动、修改、验收、数据和回退流程。
- `governance/`:架构决策、注册表治理和历次结构治理记录。
- `migration/`:从旧根目录保真迁入`app/`的历史账本、证据和失败版本记录。
日常维护优先阅读根目录`AGENTS.md``ARCHITECTURE.md`和维护指南。`migration/`只用于审计与
日常维护优先阅读根目录`AGENTS.md``ARCHITECTURE.md``HANDOFF.md`和维护指南。`migration/`只用于审计与
追溯,不参与应用启动、测试选择或运行时路径解析。
@@ -0,0 +1,42 @@
# ISSUE-001:问师界面与动态追问收口
- 状态:待人工验收/提交
- 优先级:P0
- 来源:当前工作区问师改造、产品规格 L06、14.3
## 目标
收口当前未提交的经典 QQ 式 PC 三栏问师界面和同次 LLM 调用动态追问,保持既有权限、流式、额度、对话隔离和错误回退行为。
## 已有实现
- 联系人、会话、资料/证据三栏。
- `<XIAOBAI_FOLLOW_UPS>` 机器块剥离,最终 NDJSON `meta.follow_ups` 返回 2 至 3 条。
- 点击追问只预填输入框;停止按钮不重新播放答案;没有第二次业务 LLM 调用。
- 相关 Python 和 Playwright 测试已加入工作区。
## 范围外
不在本 Issue 内加入问师自动联网取数、金融爬虫、真人身份暗示或新的交易建议能力。
## 依赖
本地 LLM 主/辅助凭据、会员账号、可用的真实市场快照,以及 Issue 002 的公共 Shell 视觉状态。
## 验收标准
1. 会员可以切换模型、加载历史、发送问题、停止生成、清空当前会话。
2. 流式正文只出现一次;主模型首字前失败最多辅助回退一次;已输出后失败不重放。
3. 成功回答显示 2 至 3 条相关追问;点击不自动发送、不增加额度;下一轮、换模型、清空后旧追问消失。
4. 追问结构无效、回答失败或用户停止时不显示追问。
5. 日间/夜间及 1920×1080、3840×2160 无白边、遮挡、超长弹窗和输入区不可达。
6. 普通用户不能发起 LLM 调用;账号甲乙对话互不可见。
## 验证
```powershell
python -m unittest tests.test_mentor_stream tests.test_llm_stream tests.test_llm_gateway
python tools/verify_baseline.py --e2e
```
通过人工验收后单独提交并推送,记录回档提交号。
@@ -0,0 +1,35 @@
# ISSUE-002:当前 PC 全站视觉改动验收与回档
- 状态:待审查/提交
- 优先级:P0
- 来源:工作区现有 37 个修改文件和 `bd97ba1` 之后的视觉调整
## 目标
逐页确认当前公共 Shell、令牌、夜间模式和 16 个页面的视觉修改,保留用户认可的 PC 变化,拆出或回退无关变化,并建立可以人工回档的提交。
## 范围
检查 `frontend/shared/`、所有 `frontend/pages/*/foundation.css`、页面 HTML/JS、架构清单和对应 E2E 测试。重点是夜间白块、边距/滚动、表格对齐、弹窗层级、图表背景和问师三栏。
## 范围外
不以本 Issue 重写业务算法,不删除未确认的旧代码,不开始移动端独立设计;移动端另见 Issue 003。
## 验收标准
1. 16 个页面均可从默认情绪周期进入,功能和权限与提交前一致。
2. 日间/夜间主题一次切换完成,不出现白闪、白色表头/搜索框/弹窗或 Hover 反色不可读。
3. 1920×1080 和 3840×2160 下页面内容、固定状态栏、全页滚动和局部滚动符合规格,无双滚动冲突。
4. 交易日明细、股池、天梯、轮动、竞价、题材、龙虎榜和选股的列宽/对齐/空态可读。
5. 每个修改文件都有明确原因;提交不包含 `.env`、数据库、日志、缓存、截图或私有 Skill。
6. 自动化、人工验收和 Git diff 检查均通过,提交可独立回退。
## 验证
```powershell
python tools/verify_baseline.py --e2e
git diff --check
```
人工截图至少覆盖日间/夜间、1920×1080、3840×2160;验收结论写回 `docs/HANDOFF.md`
@@ -0,0 +1,26 @@
# ISSUE-003:移动端独立重设
- 状态:明确延期,PC 稳定前不启动
- 优先级:P2
- 来源:产品规格第 20 节;当前移动端人工验收未通过
## 目标
在不改变 PC DOM、API、权限、数据口径和业务结果的前提下,单独设计移动 Shell、底部五入口、行情子导航、对话输入、抽屉和宽表摘要视图。
## 范围外
不能把桌面页面缩小、不能用 Hover 作为唯一入口、不能通过隐藏页面解决不可达问题,也不修改 PC 视觉作为“移动适配”。
## 验收标准
1. 320、375、390、430、768px 及手机横屏无页面横向溢出和内容遮挡。
2. 底部行情/选股/问师/问天/复盘五入口可触控,目标不小于 44×44px。
3. 行情子页面通过选择器或抽屉切换;宽表在 320px 使用摘要+详情或局部横滚,页面本身不横滚。
4. 弹窗改为底部抽屉或全屏页后仍有关闭/返回路径;键盘弹出不遮挡问师/复盘输入区。
5. 问天动画、表格状态、日夜主题和权限锁定在窄屏可读。
6. 用户人工逐页验收后,才能把 `pages.config` 的移动意图标为完成。
## 依赖与验证
依赖 Issue 002。需要 Playwright 视口回归、真实手机人工操作和产品规格第 25.8、27.3 验收案例。
@@ -0,0 +1,34 @@
# ISSUE-004:完整 IC 动态多因子
- 状态:未实现,当前仅有基础动态多因子
- 优先级:P1
- 来源:产品规格 17.6、24.2;当前实现基线明确不得夸大
## 目标
把五类因子(估值、成长、质量、动量、情绪)从基础合成升级为可复现的 IC 动态加权模式,同时保留用户手动权重的专业模式。
## 必须实现
- 行业内去极值。
- 因子 z-score 标准化。
- 行业和市值中性化。
- 使用过去 12 个月“因子值与下一期收益”的 Rank IC 均值定权。
- 每季度重算、记录算法版本和样本覆盖。
- 综合得分前 5% 输出;数据不足时逐因子说明缺失。
## 范围外
不使用 LLM 计算分数,不把缺失当 0,不将基础动态权重 UI 改名为 IC 完整版。
## 验收标准与验证
1. 固定样本可复现每个因子预处理、IC、权重和排名。
2. 严格按公告时点和下一期收益计算,无未来函数。
3. 季度边界、行业小样本、缺因子、负 IC 和极端值均有测试。
4. UI 明确“基础动态权重/IC 自动权重”差异及数据日期。
5. 回测结果保存输入快照、算法版本和输出版本。
```powershell
python -m unittest discover -s tests
```
@@ -0,0 +1,25 @@
# ISSUE-005:政策、宏观与隔夜消息数据
- 状态:数据源未定
- 优先级:P1
- 来源:产品规格 14.4、17.6、23.3;当前字段注册表尚无稳定计算来源
## 目标
为宏观思维模型、集合竞价和问师补充可授权、可归档、带时间戳的政策、公告、指数、ETF、汇率、利率、商品和隔夜资讯数据。
## 验收标准
1. 每个字段登记来源、授权、发布时间、交易日、单位、新鲜度、覆盖率和显示/计算用途。
2. 历史结果能按快照和版本复现,公告发布时间晚于目标时点的数据不能进入过去结果。
3. 消息去重、来源冲突、撤回/修订和上游失败有明确规则。
4. 竞价量化只使用已经登记且在目标时点可见的消息证据;无数据显示“数据缺失”,不显示“暂无信号”。
5. 问师宏观模型只追加适用上下文,不把政策新闻强塞给其他流派。
## 范围外
不接入未经授权的网页爬虫,不把金融爬虫 Skill 作为正式 Provider。
## 依赖
供应商授权和 Issue 010 的数据质量/凭据核验。
@@ -0,0 +1,20 @@
# ISSUE-006:分析师一致预期数据
- 状态:数据阻塞(`research.consensus``unresolved`
- 优先级:P2
## 目标
提供按公告时间可追溯的盈利预测、一致预期、预测修正、评级变化、目标价和研报数量,供策略因子和问师宏观/基本面上下文按需使用。
## 验收标准
1. 字段包含来源、分析师/机构(如授权允许)、公告时间、报告期、单位、币种和版本。
2. 目标日期只能读取当时已发布的数据;修正保留历史,不覆盖旧快照。
3. 缺失、过期、覆盖不足和无命中状态可区分。
4. 策略候选与回测固定输入下可复现;不能把缺失预测当 0 或中性。
5. 权限和授权边界经过审计,私有/授权数据不进入浏览器或普通日志。
## 范围外
没有稳定授权来源前,不在 UI 中显示伪造的“分析师共识”指标。
@@ -0,0 +1,20 @@
# ISSUE-007Level-2 与动态竞价深度
- 状态:授权阻塞(`market.level2``unresolved`
- 优先级:P2
## 目标
在取得正式授权后,接入逐笔成交、逐笔委托、委托队列、未匹配量和开板深度,增强集合竞价与盘中观察。
## 验收标准
1. 供应商、授权、字段、时间精度、单位和保留期限登记在数据配置。
2. 动态快照标出采集时间;断线、延迟和部分覆盖不会伪装成实时完整数据。
3. 9:159:25 动态观察与 9:25 最终竞价归档分开保存;不能用最终快照冒充动态过程。
4. Level-2 数据不进入未授权的历史回测;重连和重复消息幂等。
5. UI 在数据缺失时保留已有成功快照并给出可执行提示。
## 范围外
未获得授权前不使用公开网页抓取或模拟队列填充。
@@ -0,0 +1,20 @@
# ISSUE-008:游资档案历史画像
- 状态:低优先级,基础名录和详情已存在
- 优先级:P3
## 目标
完善游资名录、席位别名、历史上榜、买卖倾向、常见题材和证据来源,支持龙虎榜页面左名录右详情,不再依赖超长弹窗。
## 验收标准
1. Tushare 已收录席位完整展示;未知或无法识别席位保留原名并标记待归类。
2. 别名合并有来源、人工修订记录和可回退历史。
3. 历史画像按日期、股票、方向和金额可追溯;不把单日行为直接概括为稳定风格。
4. 点击名录进入详情,桌面/夜间/低分辨率可读,无异常空弹窗。
5. 龙虎榜出现“有股票上榜但席位不可识别”时,展示该状态而不是“无数据”。
## 依赖
依赖稳定龙虎榜席位数据和人工别名维护;不阻塞其他市场页面。
@@ -0,0 +1,26 @@
# ISSUE-009:活跃文档和状态漂移
- 状态:待整理
- 优先级:P1
## 目标
让活跃文档、配置注册表、启动端口和完成状态与正式 `app/` 实际一致,历史迁移材料继续保留但明确仅用于审计。
## 检查范围
- `README.md``docs/README.md``docs/maintenance/人工维护指南.md`
- `config/pages.config.json` 的移动端意图与实际验收状态。
- 默认端口 8765、局域网验收端口 8797 及 Docker 文档的区分。
- 迁移文档中的“待人工验收”等历史表述是否被误读为当前状态。
## 验收标准
1. 活跃文档只描述当前正式源码和真实启动方式,过期内容链接到历史说明并标注日期。
2. 文档声明移动端仍延期,不能把媒体查询或 `dedicated` 注册值当作已完成。
3. 端口、健康检查、数据目录、备份和回退命令在干净环境可执行。
4. `docs/HANDOFF.md` 与 Issue 索引同步更新,所有相对链接有效。
```powershell
python tools/verify_baseline.py
```
@@ -0,0 +1,26 @@
# ISSUE-010:数据源与 LLM 在线就绪核验
- 状态:待运行核验
- 优先级:P0
## 目标
在不把任何密钥写入仓库的前提下,恢复本地/局域网服务并分别证明进程、数据库、Tushare、iFinD、LLM 主/辅助模型和后台任务的健康状态。
## 验收标准
1. `0.0.0.0:8797`(或部署指定端口)可访问 `/api/health`,健康响应区分进程、数据库、数据源、任务和模型。
2. 登录后能读取最近真实交易日;无快照时显示等待同步,不显示演示数字。
3. 管理员诊断页可看到脱敏的来源、错误类型、关联 ID 和最后成功时间;普通用户看不到 Token、URL、模型名或堆栈。
4. Tushare/iFinD/LLM 单独测试成功或给出明确缺失/授权/网络原因;失败不清空已有成功数据。
5. 重启后账号、行情、问师/问天历史、选股和复盘记录不丢,盘后任务不重复产出。
## 验证
```powershell
powershell -ExecutionPolicy Bypass -File tools/start_local.ps1 -Port 8797
Invoke-RestMethod http://127.0.0.1:8797/api/health
python tools/verify_baseline.py --e2e
```
凭据只从管理员系统设置或受保护环境注入,不能写入 Issue、截图或日志。
@@ -0,0 +1,24 @@
# ISSUE-011:公网部署加固
- 状态:未来范围,局域网版不阻塞
- 优先级:P3
## 目标
在真正转向外网和商业授权前,将当前单实例局域网部署升级为可审计的公网部署方案。
## 必须完成
- 反向代理、TLS、可信 Host、Secure Cookie、CSRF、限流和审计。
- PostgreSQL 或等价正式数据库迁移,连接池和并发写入策略。
- 后台任务队列、分布式锁、缓存、健康检查、集中日志和告警。
- 多实例数据一致性、密钥托管、备份恢复和升级回退演练。
- 会员/激活码/授权模型的服务端鉴权和额度审计。
## 范围外
在没有部署决策和容量指标前,不为局域网版本预先拆微服务或引入云依赖。
## 验收标准
公网威胁模型、ADR、压测、故障注入、备份恢复、跨实例重复任务和安全扫描全部有记录;局域网数据边界和产品行为不变。
+19
View File
@@ -0,0 +1,19 @@
# 未完成事项 Issue 索引
这些 Issue 是仓库内的可审计任务说明,供人工维护或后续智能体执行。它们不是通过 Gitea API 创建的远端工单;需要远端协作时,应在确认范围后逐项复制到 Gitea,并保留本地文件作为产品交接记录。
| Issue | 标题 | 状态 | 优先级 |
|---|---|---|---:|
| [001](ISSUE-001-finalize-mentor-redesign.md) | 问师界面与动态追问收口 | 待人工验收/提交 | P0 |
| [002](ISSUE-002-checkpoint-current-pc-visual-work.md) | 当前 PC 全站视觉改动验收与回档 | 待审查/提交 | P0 |
| [003](ISSUE-003-mobile-redesign.md) | 移动端独立重设 | 明确延期 | P2 |
| [004](ISSUE-004-full-ic-multifactor.md) | 完整 IC 动态多因子 | 未实现 | P1 |
| [005](ISSUE-005-policy-macro-overnight-data.md) | 政策、宏观与隔夜消息数据 | 数据源未定 | P1 |
| [006](ISSUE-006-analyst-consensus-data.md) | 分析师一致预期数据 | 数据阻塞 | P2 |
| [007](ISSUE-007-level2-auction.md) | Level-2 与动态竞价深度 | 授权阻塞 | P2 |
| [008](ISSUE-008-hot-money-profile-history.md) | 游资档案历史画像 | 低优先级 | P3 |
| [009](ISSUE-009-documentation-status-drift.md) | 活跃文档和状态漂移 | 待整理 | P1 |
| [010](ISSUE-010-live-provider-llm-readiness.md) | 数据源与 LLM 在线就绪核验 | 待运行核验 | P0 |
| [011](ISSUE-011-public-deployment-hardening.md) | 公网部署加固 | 未来范围 | P3 |
关闭任一 Issue 前,必须更新 `docs/HANDOFF.md` 的状态、验证日期和回档提交;不能只改 Issue 标题。
@@ -1065,6 +1065,9 @@ iFinD动态竞价不可用时,不能把Tushare最终竞价伪装成动态监
- 回答必须是真流式:每个文本片段只追加一次;流结束后不能再次追加完整答案,避免双份结果。
- 主模型在尚未输出任何内容前失败时可切换辅助模型。
- 一旦已经向用户输出文本,中途失败只能提示连接中断,不能切换模型后重复整个回答。
- 每次成功回答可在同一次模型输出末尾生成2至3条动态追问;追问必须结合本轮问题、回答和当前思维模型,不得额外发起一次LLM业务调用或重复扣减额度。
- 动态追问是当前回答的临时操作建议,不写入对话正文。点击追问只预填输入框,由用户确认或编辑后发送;切换模型、清空对话或开始下一次提问时,旧追问立即失效。
- 回答失败、被用户停止或追问结构无效时不显示动态追问;追问不得包含无条件买卖指令、收益承诺或正文未支持的新事实。
- 回答必须声明这是基于公开资料蒸馏的思维模型,不是真人本人,不构成投资建议。
### 14.4 按模型类型提供数据
@@ -1102,6 +1105,8 @@ iFinD动态竞价不可用时,不能把Tushare最终竞价伪装成动态监
- 解读加载动画循环直到结果出现;动画、标题和底部文字不得重叠。
- 观势/解卦使用六爻推演动画;观气使用五运六气主题动画。
- LLM只解释程序已经算出的结构,不能起卦、改卦、修改干支、行情或五运六气。
- LLM解读必须经过本地知识检索:按本次卦象、动爻、五运六气关系和纳甲结构精确选择原典与规则,不使用一段固定提示词代替专业知识。
- 知识记录包含来源、适用条件、采用体系和争议边界;不同传统规则不得静默混用。
- 所有结果明确属于传统文化和娱乐化观察,不构成预测或投资建议。
### 15.2 观势输入与状态
@@ -1134,6 +1139,8 @@ iFinD动态竞价不可用时,不能把Tushare最终竞价伪装成动态监
势值 = 六爻标准化分值平均值×100。显示本卦图形、箭头、之卦图形、势值和三才状态;本卦后不得出现多余圆圈。
行情和标的身份只用于确定性成卦。进入LLM的解势上下文不得包含股票名称、代码、行业、板块或原始行情证据,避免模型在成卦后补造行业、政策、基本面或资金面故事。
### 15.4 观势六爻公式
初爻,个股内核:
@@ -1219,13 +1226,21 @@ iFinD动态竞价不可用时,不能把Tushare最终竞价伪装成动态监
主页面不展示权重条。五行对应行业是传统取象,不是行情旁证;显示字体必须可读。
解运结果包含:三层气机、复合断语、情绪与判断偏差、操作惯性、个人影响、今日生克断语和制衡动作。同一账号同一天成功解运一次后保存;再次点击直接显示“已解运”和已保存结果,不重复调用LLM。旧版被截断的结果只允许重新生成一次。
解运结果按“年纲、客主加临、日辰触发、行业影响、个人合参、制衡动作”分段。行业影响只从本次中运、司天在泉和主客气实际涉及的五行映射到后台行业归类,说明象征性的关注、节奏或约束;不得读取行业实时行情、预测涨跌或形成投资推荐。没有个人资料时省略个人合参。同一账号同一天成功解运一次后保存;再次点击直接显示“已解运”和已保存结果,不重复调用LLM。旧版被截断的结果只允许重新生成一次。提示词或知识边界升级后,只复用版本一致的结果;旧结果在新版成功生成后才替换,调用失败时不得先删除已有完整结果。
页面载入时的五行权重与“某气偏显”是小白复盘的结构化概览,用于直观展示,不属于传统五运六气的原生权重结论。点击解运后,LLM上下文不得包含五行权重、主导元素排序、权重生成的复合断语、预制情绪行为结论、行业实时行情或简化喜用神。解运只使用中运太过/不及、司天在泉、主气客气、节气阶段、日辰及其已经由程序判定的关系;行业字段只提供五行传统取象与本次出现依据;个人合参只使用日主、十神和当日派生关系。
个人合参必须区分用户本命日主与当日日柱:本命日主是用户资料的派生结果,当日日柱只是本日历法。进入LLM的个人字段只保留本命日主、当日三柱和程序已经计算出的当日天干关系标签;不得自行重算十神、扩展五行生克或使用藏干,也不得把当日日柱写成用户命局,或据此推断用户命局中的十神偏重、身强身弱和喜用神。
日辰摘要中的日干运势、地支五行和六气对应是三项并列的确定性事实。解运不得把整个日柱改写为某一种五行,也不得把地支与六气的“对应”改写为地支自身具有该六气属性。
### 15.7 观心
观心有五阶段:静心、呼吸、起卦、察念、解卦。
- 用户不输入问题,只在心中默念
- 静心入口提供“交易、心境、无题”三个快捷问题;点击后自动填入问题框,用户可以继续编辑或自行输入
- 默认不选中任何快捷问题;问题为空时不能开始静心,“无题”会填入“不设具体问题,只观此刻一念。”。
- 问题在开始静心后锁定,重新观心后恢复编辑;最终问题保存于用户私有历史并按账号隔离。
- 呼吸开始前准备1秒。
- 每息:吸3秒、顿2秒、呼4秒,共9秒;5轮,总45秒。
- 呼吸阶段只显示“吸”“顿”“呼”,不显示轮数或倒计时。
@@ -1242,6 +1257,14 @@ iFinD动态竞价不可用时,不能把Tushare最终竞价伪装成动态监
- 第一次投掷时绝不能把六爻全部生成。
- 六次完成后显示本卦、之卦和卦辞;不再显示六个单独爻解释卡。
- 用户先记录或确认“第一念”,之后才允许解卦。
- 起卦时刻按`Asia/Shanghai`保存。观心确定性引擎采用京房纳甲和八宫世应,计算卦宫、六亲、六神、月建、日辰、旬空、动变、伏神及可明确编码的冲合生克关系;相同六爻、起卦时刻和问题来源必须得到相同排盘。
- “交易”重点检索世应、妻财及相关动变,“心境”重点检索世爻、动爻及压力/舒解关系,“无题”不强选事项用神。六神、空亡或冲合不得单项决定结论。
- 六亲只表示五行关系类别,不与现实人物或资金来源一一对应。股票交易问题不得擅自改写成融资、借贷或商业合作,也不得把妻财、子孙或兄弟直接解释为现金、资金提供方或合作方。
- 问题未明确说明用户已经持仓、买卖或管理仓位时,解卦不得假定用户已经入场,不得使用持仓、仓位、建仓、入场、持有、买入、卖出、止损或止盈等措辞描述用户现状;只能讨论参与条件、风险边界和决策倾向。
- 问题未明确涉及融资、借贷、合作或资源安排时,解卦不得制造外围资金、外围资源、资金进入、资源进入,或虚构资金/资源的来源、提供、注入、安排与路径。
- 旬空可以说明条件当下未落实或难发挥,但不得用填实、出空、旬空或干支日推算未来几日或具体日期应验。
- 解卦必须回应所问,并用白话解释本卦处境、世应与相关六亲、关键动变和之卦趋向;不再使用“一句卦意、一段变化、三句问心”的固定模板。
- 解卦使用纯文本和简短标题,不输出Markdown表格。
- 支持返回、重新观心、历史记录和静音。
- 夜间模式中提示文字、历史记录、静音和重新观心必须具有足够对比度。
- 用户启用减少动态效果时,提供静态进度和文本反馈,但完整流程仍可完成。
@@ -1467,6 +1490,7 @@ iFinD动态竞价不可用时,不能把Tushare最终竞价伪装成动态监
- 超时、模型满载、限流、认证失败和网络失败使用不同的用户安全提示。
- “Selected model is at capacity”表示上游模型容量不足,不是业务数据错误;可在未输出前切辅助模型。
- 同一次业务请求只结算一次额度,主/辅助重试不重复扣次。
- 问天首稿在进入统一模型回退前先执行本地一致性校验。首稿不合格时,将具体违规原因交给同一模型完整重写一次;重写仍属于同一次业务调用,不重复扣次。只有通过校验的结果才能展示和保存;同一模型重写仍失败后,才按统一模型池规则尝试辅助模型。
### 18.3 审计与隐私
@@ -1893,6 +1917,7 @@ iFinD动态竞价不可用时,不能把Tushare最终竞价伪装成动态监
| L03 | 主模型输出一半失败 | 不切辅助重放,提示连接中断并保留已输出 |
| L04 | 选择宏观模型询问市场 | 上下文含宽基指数和ETF,不强塞短线席位数据 |
| L05 | 普通用户打开问师/问天/助手 | 同结构锁定态,不能发起LLM调用 |
| L06 | 问师成功完成一轮回答 | 回答下方出现2至3条与本轮相关的动态追问;点击后只预填输入框,不自动发送,也不产生额外LLM额度调用 |
| W01 | 观势未输入股票 | 只提示输入代码或名称,不显示暂不成卦 |
| W02 | 中国平安所属保险行业仅5只成分且5只有行情 | 小样本但覆盖完整时行业安全门可通过 |
| W03 | 行业缺涨跌但用户补录客观值 | 按原公式重算;用户不能直接选阴阳 |
@@ -1902,6 +1927,7 @@ iFinD动态竞价不可用时,不能把Tushare最终竞价伪装成动态监
| W07 | 观心第一次投币 | 只出现初爻,其余显示未得 |
| W08 | 观心投满六次 | 显示本卦/之卦/卦辞,不显示六个爻解释卡 |
| W09 | 夜间打开观心 | 提示、按钮、历史和静音文字清晰可读 |
| W10 | 交易预设问题未声明已有持仓 | 解卦不假定持仓/仓位,不虚构资金或资源路径,不输出Markdown表格 |
### 25.8 复盘、主题和响应式
@@ -0,0 +1,112 @@
# 问天知识与规则说明
## 1. 目的与边界
问天采用“确定性计算、精确知识检索、LLM综合解释、结果一致性校验”的单向链路。LLM不参与起卦、纳甲、排世应、历法和五运六气计算,也不能用模型记忆补造输入中不存在的传统结构。
观势、观气和观心分别使用独立知识范围:
- 观势是市场数据生成卦象后的《周易》象义解释,不声称属于传统摇卦。
- 观气是传统五运六气关系的当日个人观察,不声称五行或气候导致股价变化。
- 观心是三枚铜钱六次起卦后的纳甲六爻解释,问题必须在起卦前确定。
## 2. 来源等级
| 等级 | 来源 | 用途 |
|---|---|---|
| A | 《周易》经文、彖传、象传 | 卦辞、爻辞、上下卦、大象与变卦 |
| A | 《黄帝内经·素问》运气七篇 | 中运、司天在泉、主客气及运气关系 |
| B | 《京氏易传》 | 京房八宫和纳甲体系脉络 |
| B | 《火珠林》 | 纳甲筮法、六亲和日月关系脉络 |
| B | 《增删卜易》 | 用神、世应、动变与日月旺衰规则参考 |
| C | 现代历法程序与公开校验样本 | 节气、干支、旬空及边界交叉验证 |
原典属于公共领域。现代解释材料只能帮助核对和白话转译,不复制现代作者的成段释文,也不能覆盖原典。知识清单位于`data/heaven_knowledge.json`,每次模型调用保存知识版本和本次命中的记录。
## 3. 检索方式
第一版不使用向量数据库。知识条件可以由程序精确确定,因此按字段键值检索:
- 观势:本卦、实际动爻位置、之卦、动爻数量。
- 观气:中运太过/不及、司天、在泉、主气、客气、客主关系、日辰。
- 观心:本卦、实际动爻、之卦、卦宫、世应、六亲、问题预设及日月动变。
精确检索避免把字面相似但适用条件不同的传统规则送给模型。
## 4. 观势规则
本卦说明当下结构,实际动爻说明变化关节,之卦说明所趋结构。无动爻以整体和上下卦为主;一爻动以该爻为变化核心;多爻动保留全部实际动爻,先找共同方向和冲突,不采用固定口诀删除部分动爻。
输出必须给出明确但非绝对的倾向,可使用偏进、偏守、先难后易、由盛转收、转机有限、内外相违或结论有条件等表达。禁止用固定的谨慎等待套话代替解读,也禁止将卦义直接宣告为股价、价格或日期预测。
市场数据只在确定性引擎中用于生成卦象。进入LLM的观势上下文只保留数据日期、本卦、实际动爻和之卦,不包含股票名称、股票代码、行业、板块及原始行情证据。模型不得猜测观察标的,也不得补入政策、行业环境、基本面或资金面叙事。
## 5. 观气规则
页面权重只用于结构化概览,不进入LLM上下文。解运顺序固定为:
1. 中运与司天在泉构成年纲。
2. 当前客气加临主气形成当时关系。
3. 日辰只说明当天如何触发,不重复计权。
4. 行业影响只使用本次已出现五行的传统行业取象,不读取行业实时行情。
5. 个人合参只使用日主、十神和当日派生关系。
相生不直接判吉,相克不直接判凶;客主同气也必须区分相得与偏盛。行业取象只允许说明象征性的关注、节奏或约束,不得预测涨跌或形成投资推荐;行业实时行情、简化强弱、喜用神和权重生成的情绪结论均不得作为解运证据。
个人合参中,`natal_day_master`才表示用户本命日主;`today_relative_to_natal_day_master.pillars`是当日历法,不是用户出生四柱;`stem_relations`只保存程序已经计算出的当日年、月、日三柱天干关系标签。模型只能解释这些标签,不得自行重算十神、扩展五行生克或使用藏干,也不得据当日日柱推断用户命局中的十神偏重、身强身弱或喜用神。日辰摘要中的日干运势、地支五行和六气对应必须保持为三项并列事实,不能把整个日柱归成单一五行,也不能把六气对应改写成地支自身属性。结果校验会拒绝输入中不存在的干支、藏干和上述错误归并。
## 6. 观心确定性排盘
### 6.1 采用体系
- 京房纳甲通行表。
- 八宫次序:本宫、一世、二世、三世、四世、五世、游魂、归魂。
- 世爻位置依次为六、一、二、三、四、五、四、三;应爻与世爻相隔三位。
- 六亲以卦宫五行为“我”,按生我、同我、我生、我克、克我计算父母、兄弟、子孙、妻财、官鬼。
- 六神按日干从初爻起排,顺序为青龙、朱雀、勾陈、螣蛇、白虎、玄武。
- 晚子时仍按民用当日排日柱;这是项目明确选择的日期边界,不与其他流派静默混用。
### 6.2 已确定计算
每次保存起卦时刻、时区、月柱、日柱、时柱、旬空、卦宫、世应、六亲、六神、月建日辰关系、动爻变爻、回头生克、进退、伏神飞神及关键爻之间的合冲刑害。
伏神只在本卦六亲缺失时,从所属卦宫的纯卦同一爻位取出;当前显爻作为飞神。六神只作辅助象义。旬空、月破、日冲、六合、六冲、相刑或六害均不能脱离所问、世应、用神和动变单独宣布结果。
六亲表示卦宫与爻五行之间的关系类别,不与现实人物或资金来源一一对应。股票交易问题中,妻财不得直接等同现金或融资,子孙不得直接等同资金提供方,兄弟不得直接等同合作方。除非用户问题明确写出融资、借贷或合作背景,模型不得自行补造这些场景。
问题没有明确说明用户已经持仓、买卖或管理仓位时,模型不得擅自使用持仓、仓位、建仓、入场、持有、买入、卖出、止损或止盈等词描述用户现状。此时只能解释参与条件、风险边界和决策倾向。问题没有明确涉及融资、借贷、合作或资源安排时,不得制造外围资金、外围资源、资金进入、资源进入,或虚构资金/资源的来源、提供、注入、安排与路径。
旬空只说明相关条件在起卦时可能未落实、难发挥或有名无实,不能单凭旬空判成败,也不得用填实、出空、旬空或干支日推算“未来几日”或具体日期应验。解卦中的可验证动作必须是用户当下能够核对的交易条件或自身判断。
解卦结果只输出普通文本和简短标题。不得输出Markdown表格,避免竖线表格作为原始字符出现在页面中。
### 6.3 问题预设
- 交易:检索世应、妻财及与交易相关的实际动变。
- 心境:检索世爻、动爻、官鬼所示压力与子孙所示舒解,不翻译为价格预测。
- 无题:不强选事项用神,只解释卦象、世爻、动爻和变化方向。
- 自定义:依据用户明确写出的股票交易问题检索;含义不清时退回一般卦义,不猜测事项。
## 7. 解读版本与缓存
观势、观气、观心分别使用独立提示词版本。当前版本为`heaven-trend-v4``heaven-fortune-v9``heaven-heart-v5`;保存的解读同时记录提示词版本和知识版本。新结果使用独立短标题和正文段落,历史旧答案由前端识别常见标题后以同样结构展示。
模型首稿必须经过本地结果校验。若首稿引用被排除的数据、补入不存在的干支/现实场景、遗漏必答项或输出禁止结论,系统会将具体违规原因反馈给同一模型重写一次;只有校验通过的结果才能展示和保存。重写属于同一次业务调用,不重复计算用户额度;同一模型重写仍失败时才进入统一备用模型策略。
观气仍遵守“同一账号同一天成功解运一次”的产品规则,但只复用与当前观气提示词版本一致的缓存。旧版本结果在新版本成功生成后才被替换;如果新模型调用失败,旧结果不先行删除,避免因升级丢失已经保存的历史内容。观势和观心每次独立保存,不使用观气的每日去重缓存。
## 8. 争议处理
传统体系存在流派差异。当前版本只实现输入明确、可重复计算的通行规则。尚未达成一致或高度依赖师承的规则不得包装成确定性事实;若以后新增,必须记录来源、采用理由、冲突组、版本和回归样本。
## 9. 验收原则
- 相同输入得到相同排盘。
- 模型上下文不包含被明确排除的数据。
- 每次解读可追溯到知识版本与命中规则。
- 模型不得修改卦象、纳甲、干支或五运六气结果。
- 回答必须有针对性和依据,不能退化为固定劝诫。
- 观心专业术语必须附白话解释。
- 未明确持仓的问题不得被改写成持仓管理,未明确融资/资源背景的问题不得生成资金或资源路径。
- 解读不得输出Markdown表格。
- 历史记录和个人资料严格按账号隔离。
+35 -24
View File
@@ -17,29 +17,29 @@
document.documentElement.style.colorScheme = theme;
})();
</script>
<link rel="stylesheet" href="/shared/tokens.css?v=20260729-1">
<link rel="stylesheet" href="/shared/base.css?v=20260802-5">
<link rel="stylesheet" href="/shared/shell.css?v=20260802-5">
<link rel="stylesheet" href="/shared/auth.css?v=20260802-5">
<link rel="stylesheet" href="/shared/components/controls.css?v=20260802-5">
<link rel="stylesheet" href="/shared/components/navigation.css?v=20260802-5">
<link rel="stylesheet" href="/shared/components/cards.css?v=20260802-5">
<link rel="stylesheet" href="/shared/components/tables.css?v=20260802-5">
<link rel="stylesheet" href="/shared/components/dialogs.css?v=20260802-5">
<link rel="stylesheet" href="/shared/components/feedback.css?v=20260802-5">
<link rel="stylesheet" href="/pages/market/foundation.css?v=20260802-5">
<link rel="stylesheet" href="/pages/sentiment/foundation.css?v=20260802-5">
<link rel="stylesheet" href="/pages/pools/foundation.css?v=20260802-5">
<link rel="stylesheet" href="/pages/ladder/foundation.css?v=20260802-5">
<link rel="stylesheet" href="/pages/rotation/foundation.css?v=20260802-5">
<link rel="stylesheet" href="/pages/auction/foundation.css?v=20260802-5">
<link rel="stylesheet" href="/pages/themes/foundation.css?v=20260802-5">
<link rel="stylesheet" href="/pages/popularity/foundation.css?v=20260802-5">
<link rel="stylesheet" href="/pages/dragon-tiger/foundation.css?v=20260802-5">
<link rel="stylesheet" href="/pages/screener/foundation.css?v=20260802-5">
<link rel="stylesheet" href="/pages/mentor/foundation.css?v=20260802-5">
<link rel="stylesheet" href="/pages/heaven/foundation.css?v=20260802-5">
<link rel="stylesheet" href="/pages/review/foundation.css?v=20260802-5">
<link rel="stylesheet" href="/shared/tokens.css?v=20260806-2">
<link rel="stylesheet" href="/shared/base.css?v=20260806-1">
<link rel="stylesheet" href="/shared/shell.css?v=20260806-2">
<link rel="stylesheet" href="/shared/auth.css?v=20260806-1">
<link rel="stylesheet" href="/shared/components/controls.css?v=20260806-1">
<link rel="stylesheet" href="/shared/components/navigation.css?v=20260806-1">
<link rel="stylesheet" href="/shared/components/cards.css?v=20260806-1">
<link rel="stylesheet" href="/shared/components/tables.css?v=20260806-1">
<link rel="stylesheet" href="/shared/components/dialogs.css?v=20260806-1">
<link rel="stylesheet" href="/shared/components/feedback.css?v=20260806-1">
<link rel="stylesheet" href="/pages/market/foundation.css?v=20260806-1">
<link rel="stylesheet" href="/pages/sentiment/foundation.css?v=20260806-2">
<link rel="stylesheet" href="/pages/pools/foundation.css?v=20260806-2">
<link rel="stylesheet" href="/pages/ladder/foundation.css?v=20260806-1">
<link rel="stylesheet" href="/pages/rotation/foundation.css?v=20260806-1">
<link rel="stylesheet" href="/pages/auction/foundation.css?v=20260806-2">
<link rel="stylesheet" href="/pages/themes/foundation.css?v=20260806-1">
<link rel="stylesheet" href="/pages/popularity/foundation.css?v=20260806-1">
<link rel="stylesheet" href="/pages/dragon-tiger/foundation.css?v=20260806-1">
<link rel="stylesheet" href="/pages/screener/foundation.css?v=20260806-2">
<link rel="stylesheet" href="/pages/mentor/foundation.css?v=20260807-1">
<link rel="stylesheet" href="/pages/heaven/foundation.css?v=20260806-2">
<link rel="stylesheet" href="/pages/review/foundation.css?v=20260806-1">
</head>
<body>
<section id="authGate" class="auth-gate" aria-label="账号登录">
@@ -64,6 +64,10 @@
<div class="main">
<header class="app-header">
<div class="mobile-page-context" aria-live="polite">
<span id="mobilePageGroup">行情</span>
<strong id="mobilePageTitle">情绪周期</strong>
</div>
<div class="market-tape" aria-label="市场概况">
<span class="market-item up">上涨 <strong id="tapeUp">--</strong></span>
<span class="market-item down">下跌 <strong id="tapeDown">--</strong></span>
@@ -83,6 +87,12 @@
<button id="assistantButton" class="icon-button assistant-button" type="button" title="复盘助手" aria-label="复盘助手"><i data-lucide="message-circle-more"></i></button>
<button id="headerMenuButton" class="icon-button header-menu-button" type="button" title="打开命令菜单" aria-label="打开命令菜单" aria-expanded="false" aria-controls="headerCommandGroup"><i data-lucide="ellipsis"></i></button>
<div id="headerCommandGroup" class="header-command-group">
<div class="mobile-command-shortcuts" aria-label="快捷工具">
<button class="button mobile-command-shortcut" type="button" data-mobile-command-target="globalSearchButton"><i data-lucide="search"></i><span>搜索</span></button>
<button class="button mobile-command-shortcut" type="button" data-mobile-command-target="themeToggle"><i data-lucide="moon"></i><span>外观</span></button>
<button class="button mobile-command-shortcut" type="button" data-mobile-command-target="alertButton"><i data-lucide="bell"></i><span>提醒</span></button>
<button class="button mobile-command-shortcut" type="button" data-mobile-command-target="assistantButton"><i data-lucide="message-circle-more"></i><span>助手</span></button>
</div>
<button id="refreshButton" class="button command-button" type="button"><i data-lucide="refresh-cw"></i><span>刷新</span></button>
<button id="syncButton" class="button primary command-button" type="button" hidden><i data-lucide="cloud-download"></i><span>后台刷新</span></button>
<button id="settingsButton" class="button command-button" type="button" hidden><i data-lucide="settings-2"></i><span>系统管理</span></button>
@@ -105,6 +115,7 @@
</div>
</div>
</header>
<button id="mobileCommandBackdrop" class="mobile-command-backdrop" type="button" aria-label="关闭命令菜单" hidden></button>
<nav class="module-nav" aria-label="复盘模块">
<div class="sidebar-brand">
@@ -128,7 +139,7 @@
</div>
<div class="nav-group tool-nav-group">
<div class="nav-group-label">智能工具</div>
<button class="module-tab mobile-primary-tab" type="button" data-view="screenerView" title="智能选股"><i data-lucide="search-check"></i><span>智能选股</span></button>
<button class="module-tab mobile-primary-tab" type="button" data-view="screenerView" title="智能选股"><i data-lucide="search-check"></i><span class="nav-label-desktop">智能选股</span><span class="nav-label-mobile">选股</span></button>
<button class="module-tab mobile-primary-tab" type="button" data-view="mentorView" title="问师"><i data-lucide="messages-square"></i><span>问师</span></button>
<button class="module-tab mobile-primary-tab" type="button" data-view="heavenView" title="问天"><i data-lucide="sparkles"></i><span>问天</span></button>
</div>
+8 -8
View File
@@ -46,8 +46,8 @@
["sentiment", "/pages/sentiment/page.html?v=20260803-1", ["sentimentCycleView"]],
["heaven", "/pages/heaven/page.html?v=20260803-1", ["heavenView"]],
["ladder", "/pages/ladder/page.html?v=20260803-1", ["ladderView"]],
["screener", "/pages/screener/page.html?v=20260803-1", ["screenerView", "screenerTrackingView"]],
["mentor", "/pages/mentor/page.html?v=20260803-1", ["mentorView"]],
["screener", "/pages/screener/page.html?v=20260804-1", ["screenerView", "screenerTrackingView"]],
["mentor", "/pages/mentor/page.html?v=20260806-2", ["mentorView"]],
["rotation", "/pages/rotation/page.html?v=20260803-1", ["rotationView"]],
["auction", "/pages/auction/page.html?v=20260803-1", ["auctionView"]],
["themes", "/pages/themes/page.html?v=20260803-1", ["themeLibraryView"]],
@@ -71,7 +71,7 @@
"/pages/market/charts.js?v=20260803-1",
"/pages/market/entity-detail.js?v=20260803-1",
"/pages/market/stock-detail.js?v=20260803-1",
"/pages/market/preview.js?v=20260803-1",
"/pages/market/preview.js?v=20260806-1",
"/pages/market/search.js?v=20260803-1",
"/pages/market/bindings.js?v=20260803-1",
"/pages/ladder/page.js?v=20260729-1",
@@ -79,17 +79,17 @@
"/pages/auction/page.js?v=20260729-1",
"/pages/themes/page.js?v=20260729-1",
"/pages/popularity/page.js?v=20260729-1",
"/pages/dragon-tiger/page.js?v=20260729-1",
"/pages/screener/page.js?v=20260729-1",
"/pages/mentor/page.js?v=20260729-1",
"/pages/dragon-tiger/page.js?v=20260806-1",
"/pages/screener/page.js?v=20260806-1",
"/pages/mentor/page.js?v=20260806-2",
"/pages/heaven/page.js?v=20260729-1",
"/pages/review/page.js?v=20260729-1",
"/shared/state.js?v=20260729-1",
"/shared/api.js?v=20260729-1",
"/shared/shell.js?v=20260729-1",
"/shared/shell.js?v=20260806-1",
"/shared/export.js?v=20260731-1",
"/pages/heaven/loading-v2.js?v=20260728-2",
"/shared/context.js?v=20260803-1",
"/shared/context.js?v=20260804-1",
"/shared/feedback.js?v=20260803-1",
"/shared/application.js?v=20260803-1",
"/shared/table.js?v=20260803-1",
+174 -70
View File
@@ -5,8 +5,8 @@
font-weight: 800;
}
@media (max-width: 720px) {
#auctionView {
@media (max-width: 767px) {
body.mobile-shell #auctionView {
border-radius: 0px;
}
}
@@ -16,25 +16,25 @@
--line-strong: var(--border-strong);
--surface-muted: #f8fafc;
--surface-muted: var(--surface-subtle);
border-radius: 8px;
border-radius: 0;
color: rgb(31, 41, 55);
color: var(--text-primary);
--action: #1769c2;
--action: var(--primary);
--action-hover: #10569f;
--action-hover: var(--primary-hover);
--action-soft: #eaf2fb;
--action-soft: var(--surface-selected);
--border: #dfe4e9;
--border: var(--color-border);
--border-strong: #cbd3dc;
--border-strong: var(--color-border-strong);
border-color: var(--border);
border-color: transparent;
background: var(--surface);
background: transparent;
}
.auction-phase-notice > div {
@@ -287,7 +287,7 @@
max-width: 155px;
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.auction-phase-notice time {
grid-column: 2;
}
@@ -378,7 +378,7 @@
}
.auction-table tbody tr:hover td {
background: rgb(248, 250, 255);
background: var(--table-hover);
}
.auction-stock-cell {
@@ -488,7 +488,7 @@
padding-left: 4px;
background: rgb(255, 255, 255);
background: var(--surface);
color: rgb(180, 83, 9);
@@ -506,7 +506,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.auction-phase-notice {
grid-template-columns: 10px minmax(0px, 1fr);
@@ -548,7 +548,7 @@
border-bottom-color: rgb(232, 237, 241);
background: rgb(244, 247, 249);
background: var(--table-header);
}
.auction-phase-notice {
@@ -566,7 +566,7 @@
border: 0px;
background: rgb(243, 244, 246);
background: var(--surface-muted);
border-radius: 5px;
}
@@ -652,13 +652,13 @@
margin-left: auto;
padding: 14px 16px 18px;
padding: var(--page-pad-y) 0 18px;
}
.auction-page-head-v2 {
min-width: 0px;
min-height: 62px;
min-height: 40px;
display: flex;
@@ -718,13 +718,15 @@
gap: 7px;
padding: 6px 10px;
padding: 0 0 0 12px;
border: 1px solid var(--r2-line);
border: 0;
border-radius: 7px;
border-left: 1px solid var(--r2-line);
background: rgb(255, 255, 255);
border-radius: 0;
background: transparent;
color: var(--r2-sub);
@@ -778,21 +780,21 @@
}
.auction-phase-notice-v2[data-phase="selection"] {
border-color: rgb(242, 214, 164);
border-color: var(--warning-color);
background: rgb(255, 250, 241);
background: transparent;
}
.auction-phase-notice-v2[data-phase="selection"] .auction-phase-marker {
background: rgb(217, 119, 6);
background: var(--warning-color);
animation: 1.8s ease-in-out 0s infinite normal none running auction-pulse;
}
.auction-phase-notice-v2[data-phase="finalized"] {
border-color: rgb(207, 224, 215);
border-color: var(--market-down);
background: rgb(244, 251, 247);
background: transparent;
}
.auction-phase-notice-v2[data-phase="finalized"] .auction-phase-marker {
@@ -831,25 +833,27 @@
}
.auction-summary-v2 {
min-width: 390px;
min-width: 348px;
display: grid;
grid-template-columns: repeat(4, minmax(82px, 1fr));
border: 1px solid var(--r2-line);
border: 0;
border-radius: 9px;
border-left: 1px solid var(--r2-line-soft);
background: rgb(255, 255, 255);
border-radius: 0;
box-shadow: var(--r2-shadow);
background: var(--surface);
box-shadow: none;
}
.auction-summary-v2 > div {
min-width: 0px;
min-height: 48px;
min-height: 44px;
display: grid;
@@ -908,9 +912,9 @@
border-radius: 7px;
background: rgb(255, 255, 255);
background: var(--surface);
color: rgb(55, 65, 81);
color: var(--text-primary);
font-size: 12px;
@@ -953,9 +957,9 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
box-shadow: var(--card-shadow);
}
.auction-primary-card {
@@ -965,9 +969,9 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
box-shadow: var(--card-shadow);
min-height: 0px;
@@ -1073,7 +1077,7 @@
justify-content: space-between;
background: rgb(250, 251, 252);
background: var(--surface-subtle);
}
.auction-expectation-v2,
@@ -1128,13 +1132,13 @@
}
.auction-filter-segments button.active {
background: rgb(255, 255, 255);
background: var(--surface);
color: var(--r2-ink);
font-weight: 600;
box-shadow: rgba(0, 0, 0, 0.08) 0px 1px 2px;
box-shadow: var(--shadow-xs);
}
.auction-search-v2 {
@@ -1154,7 +1158,7 @@
border-radius: 7px;
background: rgb(255, 255, 255);
background: var(--surface);
}
.auction-search-v2 .lucide {
@@ -1182,7 +1186,7 @@
.auction-search-v2:focus-within {
border-color: var(--r2-blue-line);
box-shadow: rgba(37, 99, 235, 0.08) 0px 0px 0px 2px;
box-shadow: 0 0 0 2px var(--focus-ring);
}
.auction-export-button:focus-visible,
@@ -1227,7 +1231,7 @@
height: 35px;
background: rgb(248, 250, 252);
background: var(--table-header);
color: var(--r2-sub);
@@ -1279,7 +1283,7 @@
}
.auction-table-v2 tbody tr:hover td {
background: rgb(248, 250, 255);
background: var(--table-hover);
}
.auction-stock-cell-v2 {
@@ -1349,7 +1353,7 @@
border-radius: 3px;
background: rgb(240, 242, 245);
background: var(--surface-muted);
color: var(--r2-sub);
@@ -1361,7 +1365,7 @@
.auction-source-tags-v2 b:nth-child(n+2) {
background: var(--r2-blue-soft);
color: rgb(70, 100, 160);
color: var(--r2-blue);
}
#auctionView .auction-core-tags {
@@ -1397,7 +1401,7 @@
}
#auctionView .auction-score {
color: rgb(49, 95, 123);
color: var(--r2-blue);
font-weight: 750;
}
@@ -1431,9 +1435,9 @@
}
#auctionView .auction-expectation.matched {
background: rgb(243, 244, 246);
background: var(--surface-muted);
color: rgb(75, 85, 99);
color: var(--text-secondary);
}
#auctionView .auction-expectation.below {
@@ -1541,7 +1545,7 @@
border-radius: 5px;
background: rgb(243, 244, 246);
background: var(--surface-muted);
color: var(--r2-sub);
@@ -1619,15 +1623,15 @@
}
#auctionView .auction-theme-status.strong {
background: rgb(219, 231, 255);
background: var(--r2-blue-soft);
color: rgb(40, 88, 188);
color: var(--r2-blue);
}
#auctionView .auction-theme-status.steady {
background: rgb(233, 240, 255);
background: var(--r2-blue-soft);
color: rgb(70, 100, 160);
color: var(--r2-blue);
}
#auctionView .auction-theme-status.mixed {
@@ -1637,7 +1641,7 @@
}
#auctionView .auction-theme-status.weak {
background: rgb(243, 244, 246);
background: var(--surface-muted);
color: var(--r2-sub);
}
@@ -1707,7 +1711,7 @@
background: var(--r2-blue-soft);
color: rgb(70, 100, 160);
color: var(--r2-blue);
font-size: 10px;
}
@@ -1873,7 +1877,7 @@
padding-left: 3px;
background: rgb(255, 255, 255);
background: var(--surface);
color: rgb(217, 119, 6);
@@ -1958,7 +1962,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.redesigned-auction-view {
padding: 0px;
}
@@ -2076,7 +2080,7 @@
}
}
@media (min-width: 721px) {
@media (min-width: 768px) {
body[data-active-view="auctionView"] .app-main {
display: flex;
@@ -2100,9 +2104,9 @@
}
}
@media (min-width: 721px) and (max-height: 900px) {
@media (min-width: 768px) and (max-height: 900px) {
.redesigned-auction-view {
padding: 10px 12px 12px;
padding: 10px 0 12px;
}
.auction-page-head-v2 {
@@ -2192,7 +2196,7 @@
padding-right: 0px;
}
@media (min-width: 721px) {
@media (min-width: 768px) {
#auctionView .auction-page-head-v2 {
flex: 0 0 auto;
}
@@ -2238,7 +2242,7 @@
}
}
@media (max-width: 720px), (max-width: 1023px) and (max-height: 600px) {
@media (max-width: 767px), (max-width: 1023px) and (max-height: 600px) {
#auctionView .auction-workspace-v2 {
display: block;
}
@@ -2250,7 +2254,7 @@
background: var(--surface);
}
:root[data-theme="dark"] #auctionView :is(.auction-phase-notice-v2, .auction-export-button, .auction-refresh-button, .auction-filter-segments, .auction-table-v2 thead th) {
:root[data-theme="dark"] #auctionView :is(.auction-export-button, .auction-refresh-button, .auction-filter-segments, .auction-table-v2 thead th) {
border-color: var(--border);
background: var(--surface-muted);
@@ -2258,16 +2262,24 @@
color: var(--text-secondary);
}
:root[data-theme="dark"] #auctionView .auction-phase-notice-v2 {
border-left-color: var(--border);
background: transparent;
color: var(--text-secondary);
}
:root[data-theme="dark"] #auctionView .auction-phase-notice-v2[data-phase="selection"] {
border-color: var(--warning-line);
background: var(--warning-soft);
background: transparent;
}
:root[data-theme="dark"] #auctionView .auction-phase-notice-v2[data-phase="finalized"] {
border-color: var(--market-down-soft);
background: var(--market-down-soft);
background: transparent;
}
:root[data-theme="dark"] #auctionView :is(.auction-filter-segments button.active, .auction-source-tags-v2 b) {
@@ -2303,3 +2315,95 @@
color: var(--text-secondary);
}
/* Mobile auction keeps one page scrollbar; dense rows scroll only sideways. */
@media (max-width: 767px) {
#auctionView {
padding-inline: 0;
}
#auctionView .auction-workspace-v2 {
min-height: 0;
display: flex;
flex-direction: column;
gap: var(--space-12);
overflow: visible;
}
#auctionView .auction-primary-card {
width: 100%;
max-width: 100%;
min-height: 0;
min-width: 0;
overflow: visible;
}
#auctionView .auction-tabs-v2 {
width: 100%;
max-width: 100%;
min-width: 0;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
padding-inline: 0;
overflow: visible;
}
#auctionView .auction-tabs-v2 button {
min-width: 0;
min-height: var(--mobile-touch-size);
justify-content: center;
padding-inline: var(--space-4);
font-size: var(--font-size-label);
}
#auctionView .auction-tabs-v2 .auction-summary-v2 {
width: 100%;
min-width: 0;
grid-column: 1 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-left: 0;
border-top: 1px solid var(--border-subtle);
border-left: 0;
}
#auctionView .auction-tools-v2,
#auctionView .auction-expectation-v2,
#auctionView .auction-tool-actions {
width: 100%;
max-width: 100%;
min-width: 0;
}
#auctionView .auction-filter-segments {
width: 100%;
min-width: 0;
}
#auctionView .auction-filter-segments button {
min-width: 0;
flex: 1 1 0;
}
#auctionView .auction-search-v2,
#auctionView .auction-search-v2 input {
min-width: 0;
}
#auctionView .auction-table-frame-v2 {
width: 100%;
max-width: 100%;
min-height: var(--mobile-table-min-height);
max-height: none;
min-width: 0;
overflow-x: auto;
overflow-y: visible;
}
#auctionView .auction-side-v2 {
max-height: none;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-12);
overflow: visible;
}
}
+57 -30
View File
@@ -68,7 +68,7 @@
.dragon-filter.active {
background: var(--blue);
color: rgb(255, 255, 255);
color: var(--text-inverse);
}
.trader-operations {
@@ -130,7 +130,7 @@
}
.unclassified-heading:hover {
background: rgb(241, 246, 248);
background: var(--surface-hover);
}
.unclassified-heading h3 {
@@ -492,7 +492,7 @@
background: var(--card-accent);
color: rgb(255, 255, 255);
color: var(--text-inverse);
font-family: STKaiti, KaiTi, serif;
@@ -661,7 +661,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
:where(#dragonView) .dragon-trader-list {
padding: 0px 15px;
}
@@ -842,7 +842,7 @@
padding: 9px 14px;
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.dragon-filterbar {
align-items: stretch;
@@ -859,7 +859,7 @@
border-radius: var(--card-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--card-shadow);
@@ -885,7 +885,7 @@
border-radius: var(--card-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--card-shadow);
}
@@ -897,7 +897,7 @@
border-radius: var(--card-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--card-shadow);
@@ -913,7 +913,7 @@
border-radius: var(--card-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--card-shadow);
@@ -999,7 +999,7 @@
border-radius: 8px;
background: rgb(244, 245, 247);
background: var(--surface-muted);
}
.dragon-view-tabs-v2 button {
@@ -1021,7 +1021,7 @@
}
.dragon-view-tabs-v2 button.active {
background: rgb(255, 255, 255);
background: var(--surface);
color: var(--r2-ink);
@@ -1077,7 +1077,7 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
}
@@ -1145,7 +1145,7 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
}
@@ -1195,7 +1195,7 @@
border-radius: 7px;
background: rgb(243, 244, 246);
background: var(--surface-muted);
}
.dragon-filter-v2 {
@@ -1223,7 +1223,7 @@
}
.dragon-filter-v2.active {
background: rgb(255, 255, 255);
background: var(--surface);
color: var(--r2-blue);
@@ -1307,7 +1307,7 @@
border-radius: var(--r2-radius);
background: rgb(248, 249, 251);
background: var(--surface-subtle);
box-shadow: var(--r2-shadow);
}
@@ -1327,7 +1327,7 @@
border-bottom: 1px solid var(--r2-line-soft);
background: rgb(255, 255, 255);
background: var(--surface);
}
.dragon-stage-heading-v2 > div {
@@ -1381,7 +1381,7 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
}
@@ -1435,11 +1435,11 @@
z-index: 2;
background: rgb(250, 251, 252);
background: var(--surface-subtle);
}
#dragonView .dragon-operation-table tbody tr:hover {
background: rgb(247, 249, 252);
background: var(--table-hover);
}
#dragonView .dragon-unclassified-v2 {
@@ -1451,7 +1451,7 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
}
@@ -1459,7 +1459,7 @@
#dragonView .dragon-unclassified-v2 .unclassified-heading {
border-bottom-color: var(--r2-line-soft);
background: rgb(255, 255, 255);
background: var(--surface);
}
.dragon-empty-state-v2 {
@@ -1479,7 +1479,7 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
@@ -1558,7 +1558,7 @@
height: 15px;
}
@media (min-width: 721px) {
@media (min-width: 768px) {
body[data-active-view="dragonView"] .app-main {
height: var(--workspace-height);
@@ -1576,7 +1576,7 @@
}
}
@media (min-width: 721px) and (max-height: 900px) {
@media (min-width: 768px) and (max-height: 900px) {
.redesigned-dragon-view {
padding-top: 9px;
@@ -1688,7 +1688,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.redesigned-dragon-view {
padding: 10px;
}
@@ -1795,7 +1795,7 @@
flex-direction: column;
}
#dragonView .dragon-trader-detail .trader-operations {
body.mobile-shell #dragonView .dragon-trader-detail .trader-operations {
overflow-x: auto;
}
@@ -2306,13 +2306,13 @@
font-weight: var(--dragon-profile-weight-semibold);
}
@media (min-width: 721px) {
@media (min-width: 768px) {
body[data-active-view="dragonView"] .hot-money-profiles-v2 {
flex: 1 1 auto;
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.hot-money-profiles-v2 {
overflow: visible;
}
@@ -2354,7 +2354,7 @@
}
}
@media (min-width: 721px) {
@media (min-width: 768px) {
body[data-active-view="dragonView"] .dragon-daily-content-v2 {
display: block;
@@ -2473,3 +2473,30 @@ table.tbl {
background: var(--surface-muted) !important;
}
/* Mobile dragon-tiger pages use document scrolling, including long operation lists. */
@media (max-width: 767px) {
#dragonView {
padding-inline: 0;
}
#dragonView :is(.dragon-daily-content-v2, .dragon-trader-detail-v2, .dragon-unclassified-v2, .hot-money-profiles-v2) {
max-height: none;
overflow: visible;
}
#dragonView .dragon-trader-detail .trader-operations {
max-height: none;
overflow-x: auto;
overflow-y: visible;
}
#dragonView .hot-money-profile-list-v2 {
max-height: none;
overflow: visible;
}
#dragonView .dragon-operation-table {
min-width: var(--table-wide);
}
}
+1 -1
View File
@@ -261,7 +261,7 @@ function layoutDragonCards(container = document.querySelector("#dragonTraderList
const cards = [...container.querySelectorAll(".dragon-trader-card")];
const hitZones = [...container.querySelectorAll(".dragon-card-hit-zone")];
if (!cards.length) return;
const compact = window.innerWidth <= 720;
const compact = window.innerWidth <= 767;
const cardWidth = compact ? 148 : 176;
const available = Math.max(cardWidth, container.clientWidth - (compact ? 30 : 72));
const spread = Math.min(available - cardWidth, compact ? 310 : 1050);
+158 -79
View File
@@ -81,7 +81,7 @@ body[data-active-view="heavenView"] .workspace-view {
margin-top: 0px;
}
@media (max-width: 720px) {
@media (max-width: 767px) {
body[data-active-view="heavenView"] .workspace-view {
margin-top: 0px;
@@ -770,7 +770,7 @@ body[data-active-view="heavenView"] .workspace-view {
border-radius: 50%;
color: rgb(255, 255, 255);
color: var(--text-inverse);
font-size: 12px;
}
@@ -1341,13 +1341,13 @@ body[data-active-view="heavenView"] .workspace-view {
border-right: 1px solid var(--border);
}
.personal-day-master > span {
.personal-day-master-label {
color: var(--text-secondary);
font-size: 10px;
}
.personal-day-master > strong {
.personal-day-master-identity {
display: flex;
align-items: center;
@@ -1355,26 +1355,58 @@ body[data-active-view="heavenView"] .workspace-view {
gap: 8px;
margin-top: 9px;
}
.personal-day-master-mark {
width: 34px;
height: 34px;
display: grid;
place-items: center;
flex: 0 0 34px;
border: 1px solid currentColor;
border-radius: var(--radius-sm);
background: var(--wt-soft-gold);
}
.personal-day-master-mark svg {
width: 18px;
height: 18px;
stroke-width: 1.7;
}
.personal-day-master-copy {
min-width: 0px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
}
.personal-day-master-copy > strong {
white-space: nowrap;
font-size: 20px;
}
.personal-day-master .phase-symbol {
width: 28px;
height: 28px;
font-size: 11px;
}
.personal-day-master > small {
.personal-day-master-copy > small {
color: var(--text-secondary);
font-size: 10px;
display: block;
margin-top: 6px;
white-space: nowrap;
}
#heavenFortunePanel .fortune-metric:nth-child(3n) {
@@ -1408,7 +1440,7 @@ body[data-active-view="heavenView"] .workspace-view {
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
body[data-active-view="heavenView"] .overview-strip {
display: none;
}
@@ -2420,7 +2452,7 @@ body[data-active-view="heavenView"] .workspace-view {
border-color: var(--heaven-rule);
}
#heavenFortunePanel .personal-day-master > strong {
#heavenFortunePanel .personal-day-master-copy > strong {
font-family: var(--heaven-serif);
}
@@ -2940,35 +2972,13 @@ body[data-active-view="heavenView"] .workspace-view {
:where(#heavenView) #heavenFortunePanel .personal-day-master {
border-color: var(--heaven-rule);
display: grid;
display: flex;
justify-items: center;
}
flex-direction: column;
.personal-day-master-character {
margin-top: 13px;
align-items: flex-start;
font-family: var(--heaven-serif);
font-weight: 500;
line-height: 1;
font-size: 58px !important;
}
.personal-day-master-element {
margin-top: 12px;
font-family: var(--heaven-serif);
font-size: 15px;
font-weight: 500;
letter-spacing: 0.32em;
text-indent: 0.32em;
justify-content: center;
}
.phase-text-wood {
@@ -5028,6 +5038,78 @@ body[data-active-view="heavenView"] .workspace-view {
color: rgb(113, 96, 71);
}
#heavenHeartPanel .heart-question-composer {
width: 100%;
max-width: 520px;
min-width: 0;
display: grid;
gap: var(--card-gap);
}
#heavenHeartPanel #heartIntro .heart-stage-inner {
grid-template-columns: minmax(0, 1fr);
}
#heavenHeartPanel .heart-question-presets {
display: flex;
justify-content: center;
gap: var(--space-4);
}
#heavenHeartPanel .heart-question-presets button {
min-width: calc(var(--control-height) * 2);
height: var(--control-height);
border: 1px solid var(--ritual-rule);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ritual-muted);
font-family: var(--heaven-serif);
cursor: pointer;
transition: border-color var(--duration-fast), background var(--duration-fast), color var(--duration-fast);
}
#heavenHeartPanel .heart-question-presets button:hover:not(:disabled),
#heavenHeartPanel .heart-question-presets button[aria-pressed="true"] {
border-color: var(--heaven-cinnabar);
background: var(--heaven-cinnabar-soft);
color: var(--heaven-cinnabar);
}
#heavenHeartPanel .heart-question-presets button:disabled {
cursor: default;
opacity: 0.62;
}
#heavenHeartPanel #heartQuestionInput {
width: 100%;
min-width: 0;
min-height: calc(var(--control-height) * 2);
box-sizing: border-box;
resize: vertical;
border: 1px solid var(--wt-control-border);
border-radius: var(--radius-md);
padding: var(--card-gap);
background: var(--wt-control-bg);
color: var(--wt-text);
font: inherit;
line-height: 1.6;
outline: none;
transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
}
#heavenHeartPanel #heartQuestionInput::placeholder {
color: var(--wt-faint);
}
#heavenHeartPanel #heartQuestionInput:focus {
border-color: var(--heaven-cinnabar);
box-shadow: 0 0 0 2px var(--heaven-cinnabar-soft);
}
#heavenHeartPanel #heartQuestionInput:disabled {
opacity: 0.72;
}
#heavenHeartPanel .button {
background: transparent;
@@ -5047,7 +5129,7 @@ body[data-active-view="heavenView"] .workspace-view {
background: rgb(154, 91, 69);
color: rgb(255, 255, 255);
color: var(--text-inverse);
}
#heavenHeartPanel .button:disabled {
@@ -5656,7 +5738,7 @@ body[data-active-view="heavenView"] .workspace-view {
margin-top: 12px;
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.heaven-calibration-heading {
align-items: flex-start;
@@ -6001,7 +6083,7 @@ body[data-active-view="heavenView"] .workspace-view {
.heaven-reading-history-item.active,
.heaven-reading-history-item:hover {
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: rgb(154, 91, 69) 3px 0px inset;
}
@@ -6057,7 +6139,7 @@ body[data-active-view="heavenView"] .workspace-view {
border-top: 1px solid var(--border);
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.heart-toolbar-controls {
top: 8px;
@@ -6208,7 +6290,7 @@ body[data-active-view="heavenView"] .workspace-view {
min-height: 340px;
}
@media (max-width: 720px) {
@media (max-width: 767px) {
#heavenView .heaven-toolbar {
min-height: 62px;
@@ -6261,13 +6343,13 @@ body[data-active-view="heavenView"] .workspace-view {
border-color: var(--blue);
color: rgb(255, 255, 255);
color: var(--text-inverse);
}
.btn.primary:hover {
background: var(--blue-d);
color: rgb(255, 255, 255);
color: var(--text-inverse);
}
.phase-pick .pp.on {
@@ -6571,7 +6653,7 @@ body[data-active-view="heavenView"] .workspace-view {
--wt-history-bg: #fbfaf6;
}
@media (min-width: 721px) {
@media (min-width: 768px) {
:root[data-theme="light"] body[data-active-view="heavenView"] {
--wt-bg: #f4f5f7;
}
@@ -9154,7 +9236,7 @@ body[data-active-view="heavenView"] .workspace-view {
}
}
@media (min-width: 721px) {
@media (min-width: 768px) {
body[data-active-view="heavenView"] {
--wt-bg: #0b1120;
}
@@ -9372,7 +9454,7 @@ body[data-active-view="heavenView"] .workspace-view {
}
#heavenView #heavenFortunePanel .personal-primary-grid {
grid-template-columns: 76px minmax(0px, 1fr);
grid-template-columns: 112px minmax(0px, 1fr);
gap: 12px;
@@ -9404,7 +9486,7 @@ body[data-active-view="heavenView"] .workspace-view {
#heavenView #heavenFortunePanel .personal-day-master {
min-height: 118px;
padding: 0px;
padding: 0px 12px 0px 0px;
border-right: 1px solid var(--wt-line);
}
@@ -10183,7 +10265,7 @@ body[data-active-view="heavenView"] .workspace-view {
.personal-primary-grid {
display: grid;
grid-template-columns: 76px minmax(0px, 1fr);
grid-template-columns: 112px minmax(0px, 1fr);
gap: 12px;
@@ -10191,42 +10273,26 @@ body[data-active-view="heavenView"] .workspace-view {
}
.personal-day-master {
display: grid;
display: flex;
place-items: center;
flex-direction: column;
align-content: center;
align-items: flex-start;
justify-content: center;
min-height: 118px;
border-right: 1px solid var(--wt-line);
}
.personal-day-master > span {
.personal-day-master-label {
color: var(--wt-faint);
font-size: 10px;
}
.personal-day-master-character {
margin-top: 7px;
font-family: var(--heaven-serif);
font-size: 35px;
line-height: 1;
}
.personal-day-master-element {
margin-top: 5px;
font-size: 11px;
}
.personal-day-master small {
margin-top: 5px;
.personal-day-master-copy small {
color: var(--wt-faint);
font-size: 9px;
@@ -11653,3 +11719,16 @@ body[data-active-view="heavenView"] .workspace-view {
width: min(100% - 40px, 1280px);
}
}
@media (max-width: 767px) {
:root[data-theme="dark"] body.mobile-shell[data-active-view="heavenView"] .app-main,
:root[data-theme="dark"] body.mobile-shell[data-active-view="heavenView"] #heavenView.heaven-shell {
background-color: var(--wt-bg);
background-image: var(--wt-stage-bg);
color: var(--wt-text);
}
:root[data-theme="dark"] body.mobile-shell[data-active-view="heavenView"] #heavenView .heaven-panel {
background: transparent;
}
}
+11 -4
View File
@@ -171,14 +171,21 @@
<div id="heartIntro" class="heart-stage active-heart-stage">
<div class="heart-stage-inner">
<span class="heart-stage-index heart-rise" data-heart-delay="0">观心 · 一</span>
<h3 class="heart-rise wt-serif" data-heart-delay="420">把所问之事留在心里</h3>
<h3 class="heart-rise wt-serif" data-heart-delay="420">把所问之事安放于心</h3>
<div class="heart-guidance heart-rise" data-heart-delay="900">
<p>只问一事,不必说出来</p>
<p>心里默念它发生的对象与时间。</p>
<p>只问一事,在起卦前定下所问</p>
<p>不求一个喜欢的答案,只看自己真正担心什么。</p>
</div>
<div class="heart-question-composer heart-rise" data-heart-delay="1250">
<div class="heart-question-presets" aria-label="快捷问题">
<button type="button" data-heart-question-preset="trade" aria-pressed="false">交易</button>
<button type="button" data-heart-question-preset="mind" aria-pressed="false">心境</button>
<button type="button" data-heart-question-preset="unthemed" aria-pressed="false">无题</button>
</div>
<textarea id="heartQuestionInput" rows="2" maxlength="300" placeholder="写下这次想问的一件事" aria-label="观心问题"></textarea>
</div>
<blockquote class="heart-motto heart-rise wt-serif" data-heart-delay="1500">遇事不决可问春风,春风不语即随本心</blockquote>
<button id="startBreathingButton" class="button primary heart-rise" data-heart-delay="2200" type="button">开始静心</button>
<button id="startBreathingButton" class="button primary heart-rise" data-heart-delay="2200" type="button" disabled>开始静心</button>
</div>
</div>
+134 -30
View File
@@ -5,6 +5,11 @@ const HEART_BREATH_PREPARE_MS = 1_000;
const HEART_BREATH_CYCLE_MS = HEART_BREATH_INHALE_MS + HEART_BREATH_HOLD_MS + HEART_BREATH_EXHALE_MS;
const HEART_BREATH_ACTIVE_MS = HEART_BREATH_CYCLE_MS * 5;
const HEART_BREATH_TOTAL_MS = HEART_BREATH_PREPARE_MS + HEART_BREATH_ACTIVE_MS;
const HEART_QUESTION_PRESETS = {
trade: "关于我心中的这笔交易,此刻最需要看清的机会、阻碍与风险是什么?",
mind: "此刻影响我交易判断的情绪、执念或盲点是什么?",
unthemed: "不设具体问题,只观此刻一念。",
};
let qiFieldAnimationFrame = 0;
let qiFieldSoloElement = "";
@@ -477,30 +482,41 @@ async function playFortunePerformance(field, token) {
const climateText = climateTone?.textContent || "";
if (climateTone) climateTone.textContent = "";
renderQiFieldCanvas(field.balance || [], { intro: true });
if (!await heavenPerformanceDelay(900, token)) return false;
panel.classList.add("performance-climate-ready");
if (!await heavenPerformanceDelay(720, token)) return false;
if (!await typeHeavenText(climateTone, climateText, token, 58)) return false;
const balanceRows = [...panel.querySelectorAll(".phase-balance-row")];
for (const row of balanceRows) {
row.classList.add("is-ready");
const percent = number(row.dataset.phasePercent);
if (!await countHeavenNumber(row.querySelector(":scope > b"), percent, token, 520, "%")) return false;
if (!await heavenPerformanceDelay(90, token)) return false;
}
const layers = [...panel.querySelectorAll(".qi-framework-layer")];
for (const layer of layers) {
layer.classList.add("is-ready");
const climateSequence = async () => {
if (!await heavenPerformanceDelay(900, token)) return false;
panel.classList.add("performance-climate-ready");
if (!await heavenPerformanceDelay(720, token)) return false;
return typeHeavenText(climateTone, climateText, token, 58);
};
const qiSequence = async () => {
if (!await heavenPerformanceDelay(900, token)) return false;
const balanceRows = [...panel.querySelectorAll(".phase-balance-row")];
for (const row of balanceRows) {
row.classList.add("is-ready");
const percent = number(row.dataset.phasePercent);
if (!await countHeavenNumber(row.querySelector(":scope > b"), percent, token, 520, "%")) return false;
if (!await heavenPerformanceDelay(90, token)) return false;
}
const layers = [...panel.querySelectorAll(".qi-framework-layer")];
for (const layer of layers) {
layer.classList.add("is-ready");
if (!await heavenPerformanceDelay(250, token)) return false;
}
return true;
};
const personalSequence = async () => {
if (!await heavenPerformanceDelay(900, token)) return false;
panel.querySelectorAll(".human-field-grid > div").forEach((item, index) => {
setTimeout(() => {
if (token === heavenPerformanceToken) item.classList.add("is-ready");
}, motionEnabled() ? index * 150 : 0);
});
if (!await heavenPerformanceDelay(250, token)) return false;
}
panel.querySelectorAll(".human-field-grid > div").forEach((item, index) => {
setTimeout(() => {
if (token === heavenPerformanceToken) item.classList.add("is-ready");
}, motionEnabled() ? index * 150 : 0);
});
if (!await heavenPerformanceDelay(820, token)) return false;
panel.querySelector(".personal-fortune-panel")?.classList.add("is-ready");
panel.querySelector(".personal-fortune-panel")?.classList.add("is-ready");
return true;
};
const sequences = await Promise.all([climateSequence(), qiSequence(), personalSequence()]);
if (sequences.some((completed) => !completed) || token !== heavenPerformanceToken) return false;
panel.classList.add("performance-use-ready");
drawQiUseConnections(true);
panel.classList.remove("heaven-performance-pending", "heaven-performance-running");
@@ -1047,19 +1063,26 @@ function renderPersonalFortune() {
const tenGods = personal.ten_god_tendency || { favorable: [], caution: [] };
const elementTendency = personal.balance_tendency || { favorable: [], caution: [] };
const preferenceTags = (items) => (items || []).map((item) => `<em>${escapeHtml(item)}</em>`).join("") || "--";
const dayMasterElement = personal.day_master?.element || "";
const dayMasterPhase = phaseClass(dayMasterElement);
container.innerHTML = `
<div class="personal-primary-grid">
<div class="personal-day-master">
<span>日主</span>
<strong class="personal-day-master-character phase-text-${phaseClass(personal.day_master?.element)}">${escapeHtml(personal.day_master?.stem || "--")}</strong>
<b class="personal-day-master-element phase-text-${phaseClass(personal.day_master?.element)}">${escapeHtml(personal.day_master?.element || "--")}</b>
<small>${escapeHtml(personal.day_master?.strength || "")}</small>
<span class="personal-day-master-label">本命日主</span>
<div class="personal-day-master-identity">
<span class="personal-day-master-mark phase-text-${dayMasterPhase}" aria-hidden="true"><i data-lucide="${phaseIcon(dayMasterElement)}"></i></span>
<span class="personal-day-master-copy">
<strong class="phase-text-${dayMasterPhase}">${escapeHtml(personal.day_master?.stem || "--")}${escapeHtml(dayMasterElement)}</strong>
<small>日主${personal.day_master?.strength ? ` · ${escapeHtml(personal.day_master.strength)}` : ""}</small>
</span>
</div>
</div>
<div class="personal-preferences">
<section><span>十神喜恶</span><div class="personal-preference-line"><strong>偏宜</strong><p>${preferenceTags(tenGods.favorable)}</p></div><div class="personal-preference-line"><strong>偏慎</strong><p>${preferenceTags(tenGods.caution)}</p></div></section>
<section><span>五行喜忌</span><div class="personal-preference-line"><strong>偏喜</strong><p>${preferenceTags(elementTendency.favorable)}</p></div><div class="personal-preference-line"><strong>偏忌</strong><p>${preferenceTags(elementTendency.caution)}</p></div></section>
</div>
</div>`;
refreshIcons();
}
function renderHexagramLines(containerId, lines, includeEvidence = false) {
@@ -1091,6 +1114,27 @@ const HEAVEN_READING_META = {
heart: { panel: "观心", action: "我已察念,开始解卦", done: "查看解卦", status: "卦已解" },
};
const HEAVEN_ANSWER_HEADINGS = [
"核心判断", "卦势依据", "动爻转折", "之卦趋向", "决策映射",
"年纲", "客主加临", "日辰触发", "行业影响", "个人合参", "制衡动作",
"所问之答", "卦象依据", "动变与之卦", "可验证之处",
"主要矛盾", "变化方向", "交易映射", "可验证动作",
];
function formatHeavenAnswer(content) {
const labels = HEAVEN_ANSWER_HEADINGS.join("|");
let normalized = String(content || "").replace(/\r\n?/g, "\n");
normalized = normalized.replace(
new RegExp(`(^|\\n)\\s*\\*\\*(${labels})[:]?\\*\\*\\s*`, "g"),
(_match, prefix, heading) => `${prefix}## ${heading}\n\n`,
);
normalized = normalized.replace(
new RegExp(`(^|[。!?;]\\s*|\\n)(${labels})[:]\\s*`, "g"),
(_match, prefix, heading) => `${prefix}\n\n## ${heading}\n\n`,
);
return formatMentorAnswer(normalized.replace(/\n{3,}/g, "\n\n"));
}
function heavenReadingMeta(mode = state.heavenReadingMode) {
return HEAVEN_READING_META[mode] || HEAVEN_READING_META.trend;
}
@@ -1216,7 +1260,7 @@ function renderHeavenReadingCurrent() {
setText("heavenReadingSubject", reading.subject || `${heavenReadingMeta().panel}解读`);
setText("heavenReadingSubjectDetail", reading.subject_detail || displayCompactDate(reading.context_date || ""));
setText("heavenReadingCreatedAt", reading.created_at ? formatTimestamp(reading.created_at) : "刚刚完成");
document.querySelector("#heavenReadingAnswer").innerHTML = formatMentorAnswer(reading.answer || "");
document.querySelector("#heavenReadingAnswer").innerHTML = formatHeavenAnswer(reading.answer || "");
}
function renderHeavenReadingHistory() {
@@ -1237,7 +1281,7 @@ function renderHeavenReadingHistory() {
detail.innerHTML = selected ? `
<header><div><span>${escapeHtml(heavenReadingMeta(mode).status)}</span><h3>${escapeHtml(selected.subject)}</h3></div><time>${formatTimestamp(selected.created_at)}</time></header>
<p>${escapeHtml(selected.subject_detail || displayCompactDate(selected.context_date))}</p>
<div class="heaven-reading-answer">${formatMentorAnswer(selected.answer || "")}</div>
<div class="heaven-reading-answer">${formatHeavenAnswer(selected.answer || "")}</div>
<footer><button class="button" type="button" data-delete-heaven-reading="${number(selected.id)}"><i data-lucide="trash-2"></i><span>删除记录</span></button></footer>
` : emptyStateHtml("选择一条记录查看完整解读");
refreshIcons();
@@ -1292,7 +1336,13 @@ async function interpretHeaven(mode) {
stock_code: state.heavenSetup?.chart?.stock?.code || "",
};
if (mode === "trend" && state.heavenManualData) payload.manual_data = state.heavenManualData;
if (mode === "heart") payload.lines = state.heartLines;
if (mode === "heart") {
payload.trade_date = todayString();
payload.lines = state.heartLines;
payload.question = state.heartQuestion;
payload.question_preset = state.heartQuestionPreset;
payload.cast_at = state.heartCastAt;
}
const result = await apiRequest("/api/heaven/interpret", "POST", payload);
state.heavenInterpretations[mode] = result.reading || {
answer: result.answer,
@@ -1457,6 +1507,12 @@ function waitForHeartMotion(duration, token = state.heartStageToken) {
}
async function startHeartBreathing() {
syncHeartQuestion();
if (!state.heartQuestion) {
showToast("请先写下问题,或选择无题观心");
return;
}
setHeartQuestionLocked(true);
if (state.heartTimer) clearInterval(state.heartTimer);
state.heartSeconds = HEART_BREATH_TOTAL_MS / 1000;
state.heartBreathingEndsAt = 0;
@@ -1534,6 +1590,7 @@ async function beginHeartCasting() {
state.heartLines = [];
state.heartThrows = [];
state.heartHexagram = null;
state.heartCastAt = "";
state.heavenInterpretations.heart = "";
heartCastingBusy = false;
resetHeartCoins();
@@ -1598,6 +1655,7 @@ async function tossHeartCoins() {
return;
}
const stageToken = state.heartStageToken;
if (!state.heartCastAt) state.heartCastAt = new Date().toISOString();
const button = document.querySelector("#tossCoinsButton");
heartCastingBusy = true;
button.disabled = true;
@@ -1872,6 +1930,7 @@ async function resetHeartRitual() {
state.heartLines = [];
state.heartThrows = [];
state.heartHexagram = null;
state.heartCastAt = "";
state.heavenInterpretations.heart = "";
heartIncenseAnimation?.cancel();
heartIncenseAnimation = null;
@@ -1881,9 +1940,45 @@ async function resetHeartRitual() {
heartCastingBusy = false;
resetHeartCoins();
hideHeavenNotice();
setHeartQuestionLocked(false);
syncHeartQuestion();
await transitionHeartStage("intro");
}
function applyHeartQuestionPreset(preset) {
if (!(preset in HEART_QUESTION_PRESETS) || state.heartStage !== "intro") return;
const input = document.querySelector("#heartQuestionInput");
state.heartQuestionPreset = preset;
input.value = HEART_QUESTION_PRESETS[preset];
syncHeartQuestion();
document.querySelectorAll("[data-heart-question-preset]").forEach((button) => {
button.setAttribute("aria-pressed", String(button.dataset.heartQuestionPreset === preset));
});
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
}
function syncHeartQuestion() {
const input = document.querySelector("#heartQuestionInput");
if (!input) return;
state.heartQuestion = input.value.trim();
const matchedPreset = Object.entries(HEART_QUESTION_PRESETS)
.find(([, question]) => question === state.heartQuestion)?.[0];
state.heartQuestionPreset = matchedPreset || "custom";
document.querySelectorAll("[data-heart-question-preset]").forEach((button) => {
button.setAttribute("aria-pressed", String(button.dataset.heartQuestionPreset === matchedPreset));
});
document.querySelector("#startBreathingButton").disabled = !state.heartQuestion;
}
function setHeartQuestionLocked(locked) {
const input = document.querySelector("#heartQuestionInput");
if (input) input.disabled = locked;
document.querySelectorAll("[data-heart-question-preset]").forEach((button) => {
button.disabled = locked;
});
}
function showHeavenNotice(message) {
const notice = document.querySelector("#heavenNotice");
notice.textContent = message;
@@ -1898,6 +1993,10 @@ function phaseClass(element) {
return { : "wood", : "fire", : "earth", : "metal", : "water" }[element] || "earth";
}
function phaseIcon(element) {
return { : "sprout", : "flame", : "mountain", : "gem", : "waves" }[element] || "circle-dot";
}
function signedScore(value) {
const parsed = number(value);
return `${parsed > 0 ? "+" : ""}${formatNumber(parsed, 2)}`;
@@ -1944,6 +2043,10 @@ function bindHeavenEvents() {
document.querySelector("#openPersonalSettingsButton").addEventListener("click", () => openSettings("profile"));
document.querySelector("#sectorPhaseForm").addEventListener("submit", saveSectorPhaseOverride);
document.querySelector("#startBreathingButton").addEventListener("click", startHeartBreathing);
document.querySelector("#heartQuestionInput").addEventListener("input", syncHeartQuestion);
document.querySelectorAll("[data-heart-question-preset]").forEach((button) => {
button.addEventListener("click", () => applyHeartQuestionPreset(button.dataset.heartQuestionPreset));
});
document.querySelector("#beginCastingButton").addEventListener("click", beginHeartCasting);
document.querySelector("#heartSoundToggle").addEventListener("click", toggleHeartSound);
document.querySelector("#historyHeartButton").addEventListener("click", () => openHeavenHistory("heart"));
@@ -1962,4 +2065,5 @@ function bindHeavenEvents() {
document.querySelectorAll("[data-heart-return]").forEach((button) => {
button.addEventListener("click", resetHeartRitual);
});
syncHeartQuestion();
}
+107 -12
View File
@@ -66,7 +66,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.ladder-more {
grid-column: auto;
@@ -81,7 +81,7 @@
box-shadow: rgba(22, 34, 46, 0.04) 0px 1px 3px;
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.ladder-board {
align-items: stretch;
@@ -380,7 +380,7 @@
border-radius: 8px;
background: rgb(243, 244, 246);
background: var(--surface-muted);
}
.ladder-sort-segment button {
@@ -394,7 +394,7 @@
}
.ladder-sort-segment button.active {
background: rgb(255, 255, 255);
background: var(--surface);
color: var(--r2-ink);
@@ -422,7 +422,7 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
}
@@ -532,7 +532,7 @@
border-radius: 8px;
background: rgb(255, 255, 255);
background: var(--surface);
color: var(--r2-ink);
@@ -742,7 +742,7 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
}
@@ -774,7 +774,7 @@
border-radius: 5px;
background: rgb(243, 244, 246);
background: var(--surface-muted);
color: var(--r2-sub);
@@ -874,7 +874,7 @@
border-radius: 4px;
background: rgb(243, 244, 246);
background: var(--surface-muted);
}
.market-ladder-pyramid-row > i b {
@@ -954,7 +954,7 @@
border-radius: 4px;
background: rgb(243, 244, 246);
background: var(--surface-muted);
}
.market-ladder-rate-list > div > i b {
@@ -1015,7 +1015,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.redesigned-ladder-view {
padding: 0px;
}
@@ -1097,7 +1097,7 @@
}
}
@media (min-width: 721px) {
@media (min-width: 768px) {
:root #ladderView.active-view {
height: auto;
@@ -1170,3 +1170,98 @@
color: var(--text-secondary);
}
/* Mobile ladder becomes a readable vertical hierarchy instead of a narrow rail. */
@media (max-width: 767px) {
#ladderView {
padding-inline: 0;
}
#ladderView .ladder-page-head {
gap: var(--space-8);
}
#ladderView .ladder-head-actions {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-8);
}
#ladderView .ladder-sort-segment {
min-width: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
#ladderView .market-ladder-workspace,
#ladderView .lad-grid {
display: flex;
flex-direction: column;
gap: var(--space-12);
}
#ladderView .market-ladder-board {
width: 100%;
display: flex;
flex-direction: column;
}
#ladderView .market-ladder-tier {
width: 100%;
min-height: 0;
display: block;
}
#ladderView .market-ladder-label {
width: 100%;
min-height: var(--mobile-touch-size);
display: flex;
align-items: center;
gap: var(--space-8);
padding: var(--space-8) var(--space-12);
border-right: 0;
border-bottom: 1px solid var(--border-subtle);
writing-mode: horizontal-tb;
}
#ladderView .market-ladder-level {
display: inline-flex;
margin: 0;
writing-mode: horizontal-tb;
}
#ladderView .market-ladder-rate {
display: inline-flex;
writing-mode: horizontal-tb;
margin-left: auto;
}
#ladderView .market-ladder-stocks {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-8);
padding: var(--space-8);
}
#ladderView .market-ladder-stock {
width: 100%;
min-width: 0;
}
#ladderView .market-ladder-gap-note,
#ladderView .market-ladder-more {
width: 100%;
min-height: var(--mobile-touch-size);
margin: 0;
justify-content: center;
}
#ladderView .market-ladder-insights {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-12);
}
}
+12 -12
View File
@@ -1,5 +1,5 @@
/* Canonical CSS owner: market. Historical layers consolidated 2026-08-02. */
@media (max-width: 720px) {
@media (max-width: 767px) {
.stock-dialog {
width: calc(-16px + 100vw);
@@ -628,7 +628,7 @@
height: 14px;
}
@media (max-width: 720px) {
@media (max-width: 767px) {
body.stock-preview-open {
overflow: hidden;
}
@@ -770,7 +770,7 @@
overflow-wrap: anywhere;
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.entity-detail-dialog .detail-grid {
grid-template-columns: repeat(2, minmax(0px, 1fr));
}
@@ -791,21 +791,21 @@
}
:is(.global-search-dialog, .stock-dialog, .settings-dialog:not(.heaven-reading-dialog)) {
--dialog-line: #e1e6ec;
--dialog-line: var(--border);
--dialog-line-strong: #cfd7e1;
--dialog-line-strong: var(--border-strong);
--dialog-muted: #f7f9fb;
--dialog-muted: var(--surface-subtle);
--dialog-ink: #1f2937;
--dialog-ink: var(--text-primary);
--dialog-sub: #687586;
--dialog-sub: var(--text-secondary);
border: 1px solid var(--dialog-line-strong);
border-radius: 10px;
background: rgb(255, 255, 255);
background: var(--surface);
color: var(--dialog-ink);
@@ -1026,7 +1026,7 @@
border-radius: 7px;
background: rgb(255, 255, 255);
background: var(--surface);
grid-template-columns: repeat(4, minmax(0px, 1fr));
}
@@ -1040,7 +1040,7 @@
border-radius: 7px;
background: rgb(255, 255, 255);
background: var(--surface);
grid-template-columns: repeat(3, minmax(0px, 1fr));
}
@@ -1060,7 +1060,7 @@
border-bottom: 1px solid var(--dialog-line);
background: rgb(255, 255, 255);
background: var(--surface);
}
.stock-dialog .moneyflow-grid > div:last-child {
+6 -6
View File
@@ -96,7 +96,7 @@ function findStockFallback(code) {
function supportsStockPreviewHover() {
return window.matchMedia("(hover: hover) and (pointer: fine)").matches
&& window.innerWidth > 720;
&& window.innerWidth > 767;
}
function handleStockPreviewPointerOver(event) {
@@ -138,7 +138,7 @@ function handleStockPreviewFocusOut(event) {
}
function handleMobileStockPreviewClick(event) {
if (window.innerWidth > 720) return;
if (window.innerWidth > 767) return;
const trigger = event.target.closest?.(".stock-preview-trigger");
if (!trigger) return;
const code = stockCodeFromTrigger(trigger);
@@ -160,7 +160,7 @@ function handleStockPreviewKeydown(event) {
const code = stockCodeFromTrigger(trigger);
if (!code) return;
event.preventDefault();
if (window.innerWidth <= 720) showStockPreview(code, trigger);
if (window.innerWidth <= 767) showStockPreview(code, trigger);
else openStock(code, findStockFallback(code));
}
@@ -186,7 +186,7 @@ async function showStockPreview(code, trigger) {
state.stockPreviewChart = "daily";
renderStockPreviewLoading();
elements.stockPreview.hidden = false;
const mobile = window.innerWidth <= 720;
const mobile = window.innerWidth <= 767;
elements.stockPreviewBackdrop.hidden = !mobile;
document.body.classList.toggle("stock-preview-open", mobile);
requestAnimationFrame(repositionStockPreview);
@@ -239,7 +239,7 @@ async function showEntityPreview(item, trigger) {
state.stockPreviewChart = "daily";
renderStockPreviewLoading();
elements.stockPreview.hidden = false;
const mobile = window.innerWidth <= 720;
const mobile = window.innerWidth <= 767;
elements.stockPreviewBackdrop.hidden = !mobile;
document.body.classList.toggle("stock-preview-open", mobile);
requestAnimationFrame(repositionStockPreview);
@@ -433,7 +433,7 @@ function openStockDetailFromPreview() {
}
function repositionStockPreview() {
if (elements.stockPreview.hidden || window.innerWidth <= 720 || !stockPreviewAnchor?.isConnected) return;
if (elements.stockPreview.hidden || window.innerWidth <= 767 || !stockPreviewAnchor?.isConnected) return;
const anchor = stockPreviewAnchor.getBoundingClientRect();
const preview = elements.stockPreview.getBoundingClientRect();
const gap = 12;
+698 -2482
View File
File diff suppressed because it is too large Load Diff
+69 -52
View File
@@ -1,72 +1,89 @@
<section id="mentorView" class="workspace-view page member-feature-view redesigned-mentor-view">
<div class="member-gate" hidden><div class="member-gate-icon"><i data-lucide="lock-keyhole"></i></div><div><strong>问师仅对会员开放</strong><span>开通会员后可使用游资思维模型进行对话。会员状态可从顶部账号标识进入。</span></div></div>
<div class="member-gate" hidden><div class="member-gate-icon"><i data-lucide="lock-keyhole"></i></div><div><strong>问师仅对会员开放</strong><span>开通会员后可使用思维模型进行对话。会员状态可从顶部账号标识进入。</span></div></div>
<header class="section-toolbar lad-head mentor-page-header">
<div class="section-title-group mentor-page-title">
<h2>问师</h2>
<span class="section-subtitle">向思维模型请教 · <span id="mentorDataDate">--</span></span>
</div>
<div class="toolbar-controls mentor-page-controls">
<div class="mentor-evidence-filters seg" role="group" aria-label="按素材等级筛选">
<button class="active" type="button" data-mentor-grade="all">全部</button>
<button type="button" data-mentor-grade="A">A级</button>
<button type="button" data-mentor-grade="B">B级</button>
<button type="button" data-mentor-grade="C">C级</button>
</div>
<span class="section-subtitle">与不同交易思维模型持续对话 · <span id="mentorDataDate">--</span></span>
</div>
</header>
<div id="mentorNotice" class="inline-notice" hidden></div>
<div class="mentor-layout mentor-grid">
<aside class="mentor-sidebar mentor-library-card card">
<button id="mentorDirectoryToggle" class="mentor-directory-toggle" type="button" aria-expanded="false" aria-controls="mentorDirectoryContent">
<span><i data-lucide="users-round"></i><span><small>当前思维模型</small><strong id="mobileActiveMentorName">--</strong></span></span>
<i data-lucide="chevron-up"></i>
</button>
<div id="mentorDirectoryBackdrop" class="mentor-directory-backdrop" hidden></div>
<div id="mentorDirectoryContent" class="mentor-directory-content">
<div class="workspace-heading card-h mentor-directory-heading">
<div class="mentor-directory-title"><h3>模型库</h3><span>语料完整度决定回答质量</span></div>
<div class="mentor-directory-actions">
<strong id="mentorCount" class="mentor-count">0 位</strong>
<button id="mentorSortToggle" class="mentor-sort-toggle" type="button" aria-pressed="false" title="整理顺序"><i data-lucide="list-ordered"></i><span>整理</span></button>
<button id="closeMentorDirectory" class="icon-button mentor-directory-close" type="button" aria-label="关闭思维模型目录" title="关闭"><i data-lucide="x"></i></button>
</div>
</div>
<div class="mentor-layout">
<aside class="mentor-sidebar" aria-label="思维模型目录">
<div class="mentor-directory-heading">
<div class="mentor-directory-title"><strong>联系人</strong><span id="mentorCount">0 位</span></div>
<button id="mentorSortToggle" class="mentor-sort-toggle" type="button" aria-pressed="false" title="整理联系人顺序"><i data-lucide="list-ordered"></i><span>整理</span></button>
</div>
<div class="mentor-directory-tools">
<label class="mentor-search-field">
<span class="visually-hidden">搜索思维模型</span>
<i data-lucide="search"></i>
<input id="mentorSearchInput" type="search" maxlength="50" placeholder="搜索姓名、模式或标签" autocomplete="off">
<input id="mentorSearchInput" type="search" maxlength="50" placeholder="搜索联系人或标签" autocomplete="off">
</label>
<p id="mentorSortHint" class="mentor-sort-hint" hidden>拖动卡片,或使用箭头调整顺序</p>
<div id="mentorList" class="mentor-list"></div>
<div id="mentorListEmpty" class="mentor-list-empty" hidden>没有符合条件的思维模型</div>
<p class="mentor-evidence-legend">素材等级反映蒸馏依据,不代表人物能力或收益水平。</p>
</div>
</aside>
<section class="mentor-chat-panel card">
<header class="mentor-chat-header card-h">
<div class="mentor-active-profile">
<div class="mentor-active-title"><h3 id="activeMentorName">--</h3><span id="activeMentorBadges" class="mentor-active-badges"></span></div>
<p id="activeMentorEvidence">--</p>
<div id="activeMentorFocus" class="mentor-active-focus"></div>
<div class="mentor-evidence-filters" role="group" aria-label="按素材等级筛选">
<button class="active" type="button" data-mentor-grade="all">全部</button>
<button type="button" data-mentor-grade="A">A级</button>
<button type="button" data-mentor-grade="B">B级</button>
<button type="button" data-mentor-grade="C">C级</button>
</div>
<button id="clearMentorChatButton" class="button ghost mentor-clear-button" type="button" disabled><i data-lucide="trash-2"></i><span>清空对话</span></button>
</header>
<div class="chat-box">
<div id="mentorMessages" class="mentor-messages chat-log" aria-live="polite"></div>
<div id="mentorQuickPrompts" class="mentor-quick-prompts">
<span class="mentor-prompt-label">试着这样问</span>
<button type="button" data-mentor-prompt="怎么看今天的市场环境?">市场环境</button>
<button type="button" data-mentor-prompt="当前的市场主线和情绪周期是什么?">主线与周期</button>
<button type="button" data-mentor-prompt="如果今天是空仓状态,你会怎么制定操作预案?">空仓预案</button>
<button type="button" data-mentor-prompt="现在最需要防范的风险是什么?">风险检查</button>
<p id="mentorSortHint" class="mentor-sort-hint" hidden>拖动联系人,或使用箭头调整顺序</p>
</div>
<form id="mentorChatForm" class="mentor-chat-form chat-input">
<div id="mentorList" class="mentor-list"></div>
<div id="mentorListEmpty" class="mentor-list-empty" hidden>没有符合条件的思维模型</div>
<p class="mentor-evidence-legend">等级仅表示公开素材完整度</p>
</aside>
<section class="mentor-chat-panel" aria-label="问师对话">
<header class="mentor-chat-header">
<div class="mentor-chat-identity">
<span id="activeMentorAvatar" class="mentor-avatar" aria-hidden="true"></span>
<div>
<div class="mentor-active-title"><h3 id="activeMentorName">--</h3><span id="activeMentorBadges" class="mentor-active-badges"></span></div>
<p id="activeMentorStatus">思维模型已就绪</p>
</div>
</div>
<button id="clearMentorChatButton" class="icon-button mentor-clear-button" type="button" disabled aria-label="清空当前对话" title="清空对话"><i data-lucide="trash-2"></i></button>
</header>
<div id="mentorMessages" class="mentor-messages" aria-live="polite"></div>
<div id="mentorQuickPrompts" class="mentor-quick-prompts">
<span class="mentor-prompt-label">开始一个话题</span>
<button type="button" data-mentor-prompt="怎么看今天的市场环境?"><i data-lucide="activity"></i>市场环境</button>
<button type="button" data-mentor-prompt="当前的市场主线和情绪周期是什么?"><i data-lucide="route"></i>主线与周期</button>
<button type="button" data-mentor-prompt="如果今天是空仓状态,你会怎么制定操作预案?"><i data-lucide="notebook-tabs"></i>空仓预案</button>
<button type="button" data-mentor-prompt="现在最需要防范的风险是什么?"><i data-lucide="shield-alert"></i>风险检查</button>
</div>
<form id="mentorChatForm" class="mentor-chat-form">
<label class="visually-hidden" for="mentorQuestion">向当前思维模型提问</label>
<textarea id="mentorQuestion" maxlength="2000" placeholder="输入市场、板块、个股代码或交易问题"></textarea>
<button id="sendMentorQuestion" class="button primary" type="submit"><i data-lucide="send-horizontal"></i><span>发送</span></button>
<div class="mentor-composer-actions">
<span>Enter 发送 · Shift + Enter 换行</span>
<button id="stopMentorQuestion" class="button ghost mentor-stop-button" type="button" hidden><i data-lucide="square"></i><span>停止</span></button>
<button id="sendMentorQuestion" class="button primary mentor-send-button" type="submit"><i data-lucide="send-horizontal"></i><span>发送</span></button>
</div>
</form>
<p class="mentor-disclaimer">基于公开资料提炼的思维模型模拟,不代表本人观点,不构成投资建议。</p>
</div>
</section>
<aside class="mentor-profile-panel" aria-label="当前思维模型资料">
<div class="mentor-profile-hero">
<span id="mentorProfileAvatar" class="mentor-profile-avatar" aria-hidden="true"></span>
<h3 id="mentorProfileName">--</h3>
<p id="mentorProfileTagline">--</p>
<div id="mentorProfileBadges" class="mentor-profile-badges"></div>
</div>
<section class="mentor-profile-section">
<h4>关注维度</h4>
<div id="activeMentorFocus" class="mentor-active-focus"></div>
</section>
<section class="mentor-profile-section">
<h4>资料依据</h4>
<strong id="mentorProfileSource">--</strong>
<p id="activeMentorEvidence">--</p>
</section>
<section class="mentor-profile-section mentor-profile-boundary">
<h4>数据边界</h4>
<p><i data-lucide="calendar-days"></i><span id="mentorProfileDataDate">--</span></p>
<p><i data-lucide="database"></i><span>仅使用网页已提供的市场数据</span></p>
</section>
</aside>
</div>
</section>
+75 -51
View File
@@ -37,8 +37,14 @@ function renderMentorWorkspace() {
const selected = setup.mentors.find((item) => item.id === state.selectedMentorId) || null;
setText("mentorDataDate", `数据日期 ${displayCompactDate(setup.trade_date)}`);
setText("activeMentorName", selected?.name || "--");
setText("mobileActiveMentorName", selected?.name || "选择思维模型");
setText("mentorProfileName", selected?.name || "--");
setText("mentorProfileTagline", selected?.tagline || selected?.description || "--");
setText("mentorProfileSource", selected?.evidence?.label || "公开资料整理");
setText("mentorProfileDataDate", `行情数据 ${displayCompactDate(setup.trade_date)}`);
setText("activeMentorAvatar", mentorAvatarText(selected));
setText("mentorProfileAvatar", mentorAvatarText(selected));
document.querySelector("#activeMentorBadges").innerHTML = selected ? renderMentorBadges(selected, true) : "";
document.querySelector("#mentorProfileBadges").innerHTML = selected ? renderMentorBadges(selected, true) : "";
setText("activeMentorEvidence", selected?.evidence?.note || selected?.description || "--");
document.querySelector("#activeMentorFocus").innerHTML = (selected?.focus || []).slice(0, 4)
.map((item) => `<span>${escapeHtml(item)}</span>`).join("");
@@ -82,16 +88,14 @@ function renderMentorDirectory() {
<article class="mentor-option ${mentor.id === state.selectedMentorId ? "active" : ""} ${mentor.pinned ? "is-pinned" : ""}"
data-mentor-card="${escapeHtml(mentor.id)}" draggable="${state.mentorSortMode && !state.mentorSavingPreferences}">
<button type="button" class="mentor-option-main" data-mentor-id="${escapeHtml(mentor.id)}" aria-pressed="${mentor.id === state.selectedMentorId}" ${state.mentorLoading ? "disabled" : ""}>
<span class="mentor-avatar" data-grade="${escapeHtml(String(mentor.evidence?.grade || "").toLowerCase())}" aria-hidden="true">${escapeHtml(mentorAvatarText(mentor))}</span>
<span class="mentor-option-copy">
<span class="mentor-option-heading">
<strong>${escapeHtml(mentor.name)}</strong>
<span class="mentor-option-badges">${renderMentorBadges(mentor)}</span>
</span>
<em title="${escapeHtml(mentor.description || "")}">${escapeHtml(mentor.description || mentor.tagline || "思维模型")}</em>
<span class="mentor-option-meta">
${mentor.evidence?.label ? `<span class="mentor-evidence-source" title="${escapeHtml(mentor.evidence?.note || "素材说明")}">${escapeHtml(mentor.evidence.label)}</span>` : ""}
${(mentor.focus || []).slice(0, 2).map((item) => `<span>#${escapeHtml(item)}</span>`).join("")}
</span>
<span class="mentor-option-meta">${escapeHtml((mentor.focus || [])[0] || mentor.evidence?.label || "公开资料模型")}</span>
</span>
</button>
<span class="mentor-option-tools">
@@ -254,29 +258,16 @@ function renderMentorBadges(mentor, expanded = false) {
return badges.join("");
}
function toggleMentorDirectory(open) {
const mobileOpen = Boolean(open) && window.innerWidth <= 720;
state.mentorDirectoryOpen = mobileOpen;
const sidebar = document.querySelector("#mentorView .mentor-sidebar");
const backdrop = document.querySelector("#mentorDirectoryBackdrop");
const toggle = document.querySelector("#mentorDirectoryToggle");
sidebar.classList.toggle("is-open", mobileOpen);
backdrop.hidden = !mobileOpen;
toggle.setAttribute("aria-expanded", String(mobileOpen));
document.body.classList.toggle("mentor-directory-open", mobileOpen);
if (mobileOpen) requestAnimationFrame(() => document.querySelector("#mentorSearchInput").focus());
function mentorAvatarText(mentor) {
return Array.from(String(mentor?.name || "师").trim())[0] || "师";
}
async function selectMentor(mentorId) {
if (mentorId === state.selectedMentorId) {
toggleMentorDirectory(false);
return;
}
if (mentorId === state.selectedMentorId) return;
state.selectedMentorId = mentorId;
state.mentorMessages = [];
hideMentorNotice();
renderMentorWorkspace();
toggleMentorDirectory(false);
state.mentorMessages = await loadMentorMessages();
renderMentorMessages();
}
@@ -289,35 +280,53 @@ function renderMentorMessages() {
<div class="mentor-empty-state">
<span class="mentor-empty-mark" aria-hidden="true"><i data-lucide="messages-square"></i></span>
<strong>${escapeHtml(selected?.name || "问师")}请教</strong>
<p>${escapeHtml(selected?.tagline || selected?.description || "选择一个问题开始对话")}</p>
<p>${escapeHtml(selected?.tagline || selected?.description || "从一个具体问题开始对话")}</p>
</div>
`;
refreshIcons();
} else {
container.innerHTML = state.mentorMessages.map((message) => `
<article class="mentor-message ${message.role} ${message.error ? "is-error" : ""}">
<div class="mentor-message-label">${message.role === "user" ? "我" : escapeHtml(selected?.name || "问师")}</div>
<div class="mentor-message-content">${message.role === "assistant" ? formatMentorAnswer(message.content) : escapeHtml(message.content)}</div>
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
${message.meta && !message.streaming ? `<small>${escapeHtml(message.meta)}</small>` : ""}
<span class="mentor-message-avatar" aria-hidden="true">${message.role === "user" ? "我" : escapeHtml(mentorAvatarText(selected))}</span>
<div class="mentor-message-body">
<div class="mentor-message-label">${message.role === "user" ? "我" : escapeHtml(selected?.name || "问师")}</div>
<div class="mentor-message-content">${message.role === "assistant"
? (message.content ? formatMentorAnswer(message.content) : '<p class="mentor-loading-copy">正在读取复盘数据并推演...</p>')
: escapeHtml(message.content)}</div>
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
${renderMentorFollowUps(message)}
${message.meta && !message.streaming ? `<small>${escapeHtml(message.meta)}</small>` : ""}
</div>
</article>
`).join("");
if (state.mentorLoading && !state.mentorMessages.some((message) => message.streaming)) {
container.insertAdjacentHTML("beforeend", `
<article class="mentor-message assistant loading-message">
<div class="mentor-message-label">${escapeHtml(selected?.name || "问师")}</div>
<p>正在读取复盘数据并推演...</p>
</article>
`);
}
}
document.querySelector("#mentorQuickPrompts").hidden = state.mentorMessages.length > 0 || state.mentorLoading;
document.querySelector("#clearMentorChatButton").disabled = !state.mentorMessages.length || state.mentorLoading;
document.querySelector("#mentorQuestion").disabled = state.mentorLoading || !state.selectedMentorId;
document.querySelector("#sendMentorQuestion").disabled = state.mentorLoading || !state.selectedMentorId;
document.querySelector("#sendMentorQuestion").hidden = state.mentorLoading;
document.querySelector("#stopMentorQuestion").hidden = !state.mentorLoading;
document.querySelector("#mentorSortToggle").disabled = state.mentorLoading;
setText("activeMentorStatus", state.mentorLoading ? "正在生成回答..." : "思维模型已就绪");
container.querySelectorAll("[data-mentor-follow-up]").forEach((button) => {
button.addEventListener("click", () => useMentorQuickPrompt(button.dataset.mentorFollowUp));
});
refreshIcons();
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
}
function renderMentorFollowUps(message) {
if (message.role !== "assistant" || message.streaming || message.error || !Array.isArray(message.followUps)) return "";
const items = message.followUps.filter(Boolean).slice(0, 3);
if (items.length < 2) return "";
return `
<div class="mentor-follow-ups" aria-label="继续追问">
<span>继续追问</span>
${items.map((item) => `<button type="button" data-mentor-follow-up="${escapeHtml(item)}"><i data-lucide="corner-down-right"></i><span>${escapeHtml(item)}</span></button>`).join("")}
</div>
`;
}
async function sendMentorQuestion(event) {
event.preventDefault();
if (state.mentorLoading || !state.selectedMentorId) return;
@@ -328,8 +337,9 @@ async function sendMentorQuestion(event) {
role: item.role,
content: item.content.slice(0, 3500),
}));
state.mentorMessages.forEach((message) => { delete message.followUps; });
state.mentorMessages.push({ role: "user", content: question });
const responseMessage = { role: "assistant", content: "", streaming: true, meta: "" };
const responseMessage = { role: "assistant", content: "", streaming: true, meta: "", followUps: [] };
state.mentorMessages.push(responseMessage);
input.value = "";
state.mentorLoading = true;
@@ -353,6 +363,9 @@ async function sendMentorQuestion(event) {
},
(meta) => {
responseMessage.meta = `${displayCompactDate(meta.data_trade_date || elements.tradeDate.value)} · 回答完成`;
responseMessage.followUps = Array.isArray(meta.follow_ups)
? meta.follow_ups.filter((item) => typeof item === "string" && item.trim()).slice(0, 3)
: [];
if (meta.notice) showMentorNotice(meta.notice);
},
);
@@ -360,13 +373,23 @@ async function sendMentorQuestion(event) {
setStatus("问师回答完成");
} catch (error) {
responseMessage.streaming = false;
responseMessage.error = true;
if (!responseMessage.content) {
state.mentorMessages = state.mentorMessages.filter((item) => item !== responseMessage);
responseMessage.followUps = [];
if (state.mentorController?.signal.aborted) {
if (responseMessage.content) {
responseMessage.meta = "生成已停止";
} else {
state.mentorMessages = state.mentorMessages.filter((item) => item !== responseMessage);
}
setStatus("已停止问师回答");
} else {
responseMessage.error = true;
if (!responseMessage.content) {
state.mentorMessages = state.mentorMessages.filter((item) => item !== responseMessage);
}
showMentorNotice(error.message || "问师回答失败");
showToast(error.message || "问师回答失败");
setStatus("问师回答失败");
}
showMentorNotice(error.message || "问师回答失败");
showToast(error.message || "问师回答失败");
setStatus("问师回答失败");
} finally {
state.mentorLoading = false;
state.mentorController = null;
@@ -376,6 +399,12 @@ async function sendMentorQuestion(event) {
}
}
function stopMentorGeneration() {
if (!state.mentorLoading || !state.mentorController) return;
state.mentorController.abort();
setText("activeMentorStatus", "正在停止...");
}
let mentorRenderFrame = 0;
function scheduleMentorRender() {
@@ -497,12 +526,8 @@ function formatMentorInline(content) {
function bindMentorEvents() {
document.querySelector("#mentorChatForm").addEventListener("submit", sendMentorQuestion);
document.querySelector("#stopMentorQuestion").addEventListener("click", stopMentorGeneration);
document.querySelector("#clearMentorChatButton").addEventListener("click", clearMentorConversation);
document.querySelector("#mentorDirectoryToggle").addEventListener("click", () => {
toggleMentorDirectory(!state.mentorDirectoryOpen);
});
document.querySelector("#closeMentorDirectory").addEventListener("click", () => toggleMentorDirectory(false));
document.querySelector("#mentorDirectoryBackdrop").addEventListener("click", () => toggleMentorDirectory(false));
document.querySelector("#mentorSortToggle").addEventListener("click", toggleMentorSortMode);
document.querySelector("#mentorSearchInput").addEventListener("input", (event) => {
state.mentorQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
@@ -520,10 +545,9 @@ function bindMentorEvents() {
document.querySelectorAll("[data-mentor-prompt]").forEach((button) => {
button.addEventListener("click", () => useMentorQuickPrompt(button.dataset.mentorPrompt));
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") toggleMentorDirectory(false);
});
window.addEventListener("resize", () => {
if (window.innerWidth > 720) toggleMentorDirectory(false);
document.querySelector("#mentorQuestion").addEventListener("keydown", (event) => {
if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
event.preventDefault();
document.querySelector("#mentorChatForm").requestSubmit();
});
}
+123 -47
View File
@@ -24,7 +24,7 @@
border-bottom: 1px solid var(--r2-line-soft);
background: rgb(255, 255, 255);
background: var(--surface);
vertical-align: middle;
}
@@ -54,7 +54,7 @@
padding-block: 8px;
background: rgb(248, 250, 252);
background: var(--table-header);
color: var(--r2-sub);
@@ -64,13 +64,13 @@
}
.redesigned-pool-view .data-table th[data-sort]:hover {
background: rgb(248, 250, 252);
background: var(--table-header);
color: var(--r2-blue);
}
.redesigned-pool-view .data-table tbody tr:hover td {
background: rgb(248, 250, 255);
background: var(--table-hover);
}
.redesigned-pool-view .data-table .row-number {
@@ -127,7 +127,7 @@
border-bottom: 1px solid var(--r2-line-soft);
background: rgb(255, 255, 255);
background: var(--surface);
vertical-align: middle;
}
@@ -157,7 +157,7 @@
padding-block: 8px;
background: rgb(248, 250, 252);
background: var(--table-header);
color: var(--r2-sub);
@@ -199,7 +199,7 @@
}
.redesigned-broken-view .data-table tbody tr:hover td {
background: rgb(248, 250, 255);
background: var(--table-hover);
}
.redesigned-broken-view .data-table .row-number {
@@ -255,7 +255,7 @@
border-bottom: 1px solid var(--r2-line-soft);
background: rgb(255, 255, 255);
background: var(--surface);
vertical-align: middle;
}
@@ -285,7 +285,7 @@
padding-block: 8px;
background: rgb(248, 250, 252);
background: var(--table-header);
color: var(--r2-sub);
@@ -327,7 +327,7 @@
}
.redesigned-down-view .data-table tbody tr:hover td {
background: rgb(248, 250, 255);
background: var(--table-hover);
}
.redesigned-down-view .data-table .row-number {
@@ -375,7 +375,7 @@
border-bottom: 1px solid var(--r2-line-soft);
background: rgb(255, 255, 255);
background: var(--surface);
vertical-align: middle;
}
@@ -405,7 +405,7 @@
padding-block: 8px;
background: rgb(248, 250, 252);
background: var(--table-header);
color: var(--r2-sub);
@@ -447,7 +447,7 @@
}
.redesigned-yesterday-view .data-table tbody tr:hover td {
background: rgb(248, 250, 255);
background: var(--table-hover);
}
.redesigned-yesterday-view .data-table .row-number {
@@ -584,7 +584,7 @@
border-bottom-color: var(--border);
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.main-grid {
display: block;
@@ -635,7 +635,7 @@
border-radius: var(--card-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--card-shadow);
@@ -667,7 +667,7 @@
border-radius: var(--card-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--card-shadow);
}
@@ -743,7 +743,7 @@
#limitPool .table-frame {
border-radius: 10px;
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--shadow-xs);
}
@@ -755,7 +755,7 @@
border-radius: 10px;
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--shadow-xs);
@@ -813,7 +813,7 @@
border-radius: 7px;
background: rgb(255, 255, 255);
background: var(--surface);
}
.pool-search-field .lucide {
@@ -873,7 +873,7 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
}
@@ -917,7 +917,7 @@
}
.pool-state-tag.broken {
background: rgb(243, 244, 246);
background: var(--surface-muted);
color: rgb(107, 114, 128);
}
@@ -947,7 +947,7 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
}
@@ -981,7 +981,7 @@
border-radius: 5px;
background: rgb(243, 244, 246);
background: var(--surface-muted);
color: var(--r2-sub);
@@ -1146,7 +1146,7 @@
}
}
@media (max-width: 720px), (max-width: 1023px) and (max-height: 600px) {
@media (max-width: 767px), (max-width: 1023px) and (max-height: 600px) {
.pool-page-head .toolbar-controls {
flex-wrap: wrap;
}
@@ -1233,7 +1233,7 @@
border-radius: 7px;
background: rgb(255, 255, 255);
background: var(--surface);
color: rgb(55, 65, 81);
@@ -1263,7 +1263,7 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
}
@@ -1290,7 +1290,7 @@
line-height: 1.6;
}
@media (max-width: 720px), (max-width: 1023px) and (max-height: 600px) {
@media (max-width: 767px), (max-width: 1023px) and (max-height: 600px) {
.broken-page-head .toolbar-controls {
flex-wrap: nowrap;
}
@@ -1377,7 +1377,7 @@
border-radius: 7px;
background: rgb(255, 255, 255);
background: var(--surface);
color: rgb(55, 65, 81);
@@ -1407,7 +1407,7 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
}
@@ -1493,7 +1493,7 @@
border-radius: 7px;
background: rgb(255, 255, 255);
background: var(--surface);
color: rgb(55, 65, 81);
@@ -1519,7 +1519,7 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
}
@@ -1547,7 +1547,7 @@
border-radius: 0px;
background: rgb(255, 255, 255);
background: var(--surface);
color: var(--r2-ink);
@@ -1559,7 +1559,7 @@
}
.yesterday-summary-cell:hover {
background: rgb(248, 250, 255);
background: var(--table-hover);
}
.yesterday-summary-cell.active {
@@ -1678,7 +1678,7 @@
.yesterday-outcome-tag.fail {
border: 1px solid var(--r2-line);
background: rgb(243, 244, 246);
background: var(--surface-muted);
color: rgb(75, 85, 99);
}
@@ -1800,7 +1800,7 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
}
@@ -1854,7 +1854,7 @@
.performance-status-tag.is-neutral {
border-color: var(--r2-line);
background: rgb(243, 244, 246);
background: var(--surface-muted);
color: rgb(75, 85, 99);
}
@@ -1918,7 +1918,7 @@
border-radius: 3px;
background: rgb(240, 242, 245);
background: var(--surface-muted);
}
.performance-stage-track i {
@@ -1954,7 +1954,7 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
color: var(--r2-faint);
@@ -1980,7 +1980,7 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
}
@@ -1990,7 +1990,7 @@
padding: 0px;
background: rgb(255, 255, 255);
background: var(--surface);
}
.performance-panel-head {
@@ -2024,7 +2024,7 @@
border-radius: 5px;
background: rgb(243, 244, 246);
background: var(--surface-muted);
color: var(--r2-sub);
@@ -2066,7 +2066,7 @@
border-radius: 6px;
background: rgb(240, 242, 245);
background: var(--surface-muted);
}
.performance-width-bar i {
@@ -2182,7 +2182,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.redesigned-performance-view .performance-cards {
grid-template-columns: repeat(2, minmax(0px, 1fr));
}
@@ -2211,7 +2211,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.redesigned-performance-view {
padding: 0px;
}
@@ -2284,7 +2284,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) .pool-reason-cell {
white-space: normal;
}
@@ -2305,7 +2305,7 @@
font-size: 12.5px;
background: rgb(255, 255, 255);
background: var(--surface);
color: rgb(55, 65, 81);
@@ -2374,7 +2374,7 @@
overflow: auto;
}
@media (min-width: 721px) {
@media (min-width: 768px) {
#yesterdayView.active-view {
min-height: 0px;
@@ -2489,3 +2489,79 @@
.ladder-mini.pool-side-list {
display: block;
}
/* Mobile pool workspaces keep filters usable and tables locally horizontal. */
@media (max-width: 767px) {
:is(#limitPool, #brokenView, #downView, #yesterdayView, #performanceView) {
padding-inline: 0;
}
:is(.pool-page-head, .broken-page-head, .down-page-head, .yesterday-page-head) {
gap: var(--space-8);
}
body.mobile-shell :is(.pool-page-head, .broken-page-head, .down-page-head, .yesterday-page-head) .toolbar-controls {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-8);
overflow: visible;
}
.pool-page-head .pool-filter-segments {
width: 100%;
min-width: 0;
grid-column: 1 / -1;
}
.pool-page-head .pool-filter-segments .segment {
min-width: 0;
flex: 1 1 0;
padding-inline: var(--space-8);
}
:is(.pool-page-head, .broken-page-head, .down-page-head, .yesterday-page-head) .pool-search-field {
width: 100%;
min-width: 0;
}
:is(.pool-page-head, .broken-page-head, .down-page-head, .yesterday-page-head) .pool-search-field input {
width: 100%;
min-width: 0;
flex: 1 1 auto;
}
#limitPool .redesigned-pool-grid {
display: flex;
flex-direction: column;
gap: var(--space-12);
}
:is(#limitPool, #brokenView, #downView, #yesterdayView) .tbl-wrap {
min-height: var(--mobile-table-min-height);
max-height: none;
overflow-x: auto;
overflow-y: visible;
}
#limitPool .pool-insight-rail {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-12);
}
#limitPool .pool-insight-rail .rail-section {
min-height: 0;
}
#yesterdayView .yesterday-result-summary {
display: flex;
overflow-x: auto;
scrollbar-width: none;
}
#yesterdayView .yesterday-summary-cell {
min-width: var(--mobile-summary-card-width);
flex: 0 0 auto;
}
}
+24 -11
View File
@@ -40,7 +40,7 @@
:where(#popularityView) #popularitySummary {
margin-bottom: 12px;
background: rgb(255, 255, 255);
background: var(--surface);
}
#popularityView .data-table {
@@ -148,7 +148,7 @@
border-radius: 8px;
background: rgb(244, 245, 247);
background: var(--surface-muted);
}
.popularity-source-tabs-v2 button {
@@ -174,7 +174,7 @@
}
.popularity-source-tabs-v2 button.active {
background: rgb(255, 255, 255);
background: var(--surface);
color: var(--r2-ink);
@@ -308,7 +308,7 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
}
@@ -489,7 +489,7 @@
}
.popularity-table-v2 tbody tr:hover {
background: rgb(247, 249, 252);
background: var(--table-hover);
}
.popularity-rank-v2 {
@@ -587,7 +587,7 @@
border-radius: 5px;
background: rgb(243, 244, 246);
background: var(--surface-muted);
color: var(--r2-sub);
@@ -602,7 +602,7 @@
color: var(--r2-amber);
}
@media (min-width: 721px) {
@media (min-width: 768px) {
body[data-active-view="popularityView"] .app-main {
display: flex;
@@ -626,7 +626,7 @@
}
}
@media (min-width: 721px) and (max-height: 900px) {
@media (min-width: 768px) and (max-height: 900px) {
.redesigned-popularity-view {
padding-top: 9px;
@@ -684,7 +684,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.redesigned-popularity-view {
padding: 10px;
}
@@ -792,7 +792,7 @@
}
.hot3 .hc {
background: rgb(255, 255, 255);
background: var(--surface);
border: 1px solid var(--line);
@@ -869,7 +869,7 @@
width: var(--col-number);
}
@media (min-width: 721px) {
@media (min-width: 768px) {
#popularityView .popularity-glance-v2,
#popularityView .popularity-page-head-v2 {
flex: 0 0 auto;
@@ -919,3 +919,16 @@
box-shadow: var(--control-shadow);
}
@media (max-width: 767px) {
#popularityView {
padding-inline: 0;
}
#popularityView .popularity-table-frame-v2 {
min-height: var(--mobile-table-min-height);
max-height: none;
overflow-x: auto;
overflow-y: visible;
}
}
+80 -46
View File
@@ -109,7 +109,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.trade-log-dialog {
width: calc(-16px + 100vw);
@@ -154,7 +154,7 @@
border-bottom-color: var(--border);
}
@media (max-width: 720px) {
@media (max-width: 767px) {
:where(#reviewWorkspaceView) .workspace-section {
min-width: 0px;
@@ -176,7 +176,7 @@
@media (max-width: 900px) {
body[data-active-view="reviewWorkspaceView"] .workspace-view {
padding: 10px 10px 84px;
padding: 10px 0 84px;
}
.review-workspace .notes-history-section,
@@ -200,14 +200,14 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
box-shadow: none;
padding: 14px 16px 24px;
padding: var(--page-pad-y) 0 24px;
}
.review-workspace .workspace-section + .workspace-section {
border-top: 1px solid var(--border);
}
@media (max-width: 720px) {
@media (max-width: 767px) {
body[data-active-view="reviewWorkspaceView"] .workspace-view {
margin-top: 0px;
@@ -234,7 +234,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
.review-workspace .workspace-section {
background: rgb(255, 255, 255);
background: var(--surface);
border: 1px solid var(--border);
@@ -315,7 +315,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
color: var(--primary);
}
@media (max-width: 720px) {
@media (max-width: 767px) {
:where(#reviewWorkspaceView) .review-workspace {
display: block;
@@ -326,23 +326,23 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
#reviewWorkspaceView {
--review-blue: #2563eb;
--review-blue: var(--action);
--review-blue-dark: #1d4ed8;
--review-blue-dark: var(--action-hover);
--review-blue-soft: #eff4ff;
--review-blue-soft: var(--action-soft);
--review-blue-line: #c7d8fb;
--review-blue-line: var(--color-action-line);
--review-line: #e5e7eb;
--review-line: var(--border);
--review-line-soft: #eef0f3;
--review-line-soft: var(--border-subtle);
--review-ink: #1f2937;
--review-ink: var(--text-primary);
--review-sub: #6b7280;
--review-sub: var(--text-secondary);
--review-faint: #9ca3af;
--review-faint: var(--text-tertiary);
color: var(--review-ink);
}
@@ -400,7 +400,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
border-radius: 7px;
background: rgb(255, 255, 255);
background: var(--surface);
color: rgb(55, 65, 81);
@@ -467,7 +467,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
border-radius: 10px;
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: rgba(16, 24, 40, 0.05) 0px 1px 2px;
}
@@ -503,7 +503,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
border-radius: 5px;
background: rgb(243, 244, 246);
background: var(--surface-muted);
color: var(--review-sub);
@@ -521,7 +521,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
border-bottom: 1px solid var(--review-line-soft);
background: rgb(250, 251, 252);
background: var(--surface-subtle);
color: var(--review-faint);
@@ -547,7 +547,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
#reviewWorkspaceView .data-table tbody tr:hover {
background: rgb(248, 250, 255);
background: var(--table-hover);
}
#reviewWorkspaceView .stock-cell {
@@ -625,7 +625,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
#reviewWorkspaceView .table-action.down:hover {
background: rgb(245, 246, 248);
background: var(--surface-hover);
color: rgb(209, 67, 67);
}
@@ -683,7 +683,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
background: var(--review-blue);
color: rgb(255, 255, 255);
color: var(--text-inverse);
font-size: 11.5px;
}
@@ -715,7 +715,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
border-bottom: 1px solid var(--review-line-soft);
background: rgb(255, 255, 255);
background: var(--surface);
}
#reviewWorkspaceView .trade-log-summary:empty {
@@ -799,7 +799,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
border-radius: 5px;
background: rgb(248, 249, 251);
background: var(--surface-subtle);
color: var(--review-sub);
@@ -821,7 +821,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
border-radius: 5px;
background: rgb(248, 249, 251);
background: var(--surface-subtle);
color: var(--review-sub);
@@ -947,7 +947,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
border-radius: 6px;
background: rgb(248, 249, 251);
background: var(--surface-subtle);
color: var(--review-sub);
@@ -961,7 +961,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
padding: 14px 16px 16px;
background: rgb(255, 255, 255);
background: var(--surface);
}
#reviewWorkspaceView .journal-form .form-field:nth-of-type(2) textarea {
@@ -973,11 +973,11 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
#reviewWorkspaceView .journal-form textarea:focus {
border-color: var(--review-blue-line);
box-shadow: rgba(37, 99, 235, 0.08) 0px 0px 0px 2px;
box-shadow: 0 0 0 2px var(--focus-ring);
}
#reviewWorkspaceView .journal-form textarea::placeholder {
color: rgb(166, 173, 183);
color: var(--review-faint);
}
#reviewWorkspaceView .journal-form .dialog-actions {
@@ -1005,7 +1005,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
background: var(--review-blue);
color: rgb(255, 255, 255);
color: var(--text-inverse);
font-size: 12px;
}
@@ -1035,7 +1035,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
overflow-y: auto;
background: rgb(255, 255, 255);
background: var(--surface);
}
#reviewWorkspaceView .note-row {
@@ -1058,7 +1058,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
#reviewWorkspaceView .note-row {
grid-template-columns: 1fr auto;
}
@@ -1091,7 +1091,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
#reviewWorkspaceView .review-page-header {
min-height: 68px;
@@ -1273,7 +1273,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
border-bottom: 1px solid var(--review-line-soft);
background: rgb(255, 255, 255);
background: var(--surface);
min-height: 48px;
@@ -1418,7 +1418,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
outline: 0px;
background: rgb(255, 255, 255);
background: var(--surface);
color: var(--review-ink);
@@ -1470,7 +1470,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
outline: 0px;
background: rgb(255, 255, 255);
background: var(--surface);
color: var(--review-ink);
@@ -1565,7 +1565,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
border-radius: 8px;
background: rgb(255, 255, 255);
background: var(--surface);
}
.watchlist-search-control:focus-within {
@@ -1649,7 +1649,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
border-image: none;
background: rgb(255, 255, 255);
background: var(--surface);
color: rgb(31, 41, 55);
@@ -1657,7 +1657,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
.watchlist-search-results button:hover {
background: rgb(245, 248, 255);
background: var(--surface-selected);
}
.watchlist-search-results button > span {
@@ -1713,7 +1713,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
border-radius: 8px;
background: rgb(247, 249, 254);
background: var(--surface-subtle);
}
.watchlist-selection[hidden] {
@@ -1869,7 +1869,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
#reviewWorkspaceView .journal-form .form-field > span {
color: rgb(55, 65, 81);
color: var(--review-sub);
font-size: 12px;
@@ -1908,7 +1908,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
#reviewWorkspaceView .review-watchlist-table {
min-width: 760px;
}
@@ -1937,7 +1937,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
padding: 18px 20px 20px;
background: rgb(250, 251, 252);
background: var(--surface-subtle);
}
.trade-log-dialog .trade-log-form-grid {
@@ -1992,7 +1992,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
padding: 5px 10px;
background: rgb(255, 255, 255);
background: var(--surface);
}
.search .ico {
@@ -2057,7 +2057,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
white-space: normal;
}
@media (min-width: 721px) {
@media (min-width: 768px) {
:root #reviewWorkspaceView.active-view {
height: auto;
@@ -2167,3 +2167,37 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
:root[data-theme="dark"] #reviewWorkspaceView .data-table tbody td {
color: var(--text-primary);
}
/* Mobile review is a single vertical workflow with locally horizontal tables. */
@media (max-width: 767px) {
body.mobile-shell #reviewWorkspaceView {
padding-inline: 0;
}
body.mobile-shell #reviewWorkspaceView .review-workspace,
body.mobile-shell #reviewWorkspaceView .review-grid,
body.mobile-shell #reviewWorkspaceView .review-left-stack {
width: 100%;
height: auto;
display: flex;
flex-direction: column;
gap: var(--space-12);
}
body.mobile-shell #reviewWorkspaceView :is(.workspace-table-frame, .trade-log-table-frame) {
min-height: 0;
max-height: none;
overflow-x: auto;
overflow-y: visible;
}
body.mobile-shell #reviewWorkspaceView :is(.notes-history-section, .notes-history) {
max-height: none;
overflow: visible;
}
body.mobile-shell #reviewWorkspaceView .journal-section {
width: 100%;
height: auto;
}
}
+32 -21
View File
@@ -62,7 +62,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.rotation-history {
min-height: 224px;
}
@@ -89,7 +89,7 @@
border-radius: 4px;
background: rgb(255, 255, 255);
background: var(--surface);
}
.rotation-legend span {
@@ -225,7 +225,7 @@
border-radius: 8px;
background: rgb(243, 244, 246);
background: var(--surface-muted);
}
.rotation-order-control button {
@@ -247,7 +247,7 @@
}
.rotation-order-control button.active {
background: rgb(255, 255, 255);
background: var(--surface);
color: var(--r2-ink);
@@ -280,7 +280,7 @@
border-radius: 7px;
background: rgb(255, 255, 255);
background: var(--surface);
color: rgb(55, 65, 81);
@@ -303,7 +303,7 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
}
@@ -315,7 +315,7 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
@@ -379,7 +379,7 @@
border-radius: 5px;
background: rgb(243, 244, 246);
background: var(--surface-muted);
color: var(--r2-sub);
@@ -579,7 +579,7 @@
border-radius: 7px;
background: rgb(255, 255, 255);
background: var(--surface);
color: rgb(55, 65, 81);
@@ -623,7 +623,7 @@
border-radius: 0px;
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: none;
}
@@ -639,7 +639,7 @@
border-bottom: 1px solid var(--r2-line-soft);
background: rgb(248, 250, 252);
background: var(--table-header);
}
#rotationView .rotation-day > header time {
@@ -681,7 +681,7 @@
background: var(--r2-blue);
color: rgb(255, 255, 255);
color: var(--text-inverse);
font-size: 9px;
@@ -757,19 +757,19 @@
#rotationView .rotation-rank.rank-1 {
background: rgb(224, 69, 54);
color: rgb(255, 255, 255);
color: var(--text-inverse);
}
#rotationView .rotation-rank.rank-2 {
background: rgb(240, 113, 79);
color: rgb(255, 255, 255);
color: var(--text-inverse);
}
#rotationView .rotation-rank.rank-3 {
background: rgb(245, 166, 35);
color: rgb(255, 255, 255);
color: var(--text-inverse);
}
#rotationView .rotation-sector-chip strong {
@@ -837,7 +837,7 @@
background: var(--r2-ink);
color: rgb(255, 255, 255);
color: var(--text-inverse);
font-size: 10.5px;
@@ -870,7 +870,7 @@
border-bottom: 1px solid var(--r2-line);
background: rgb(248, 250, 252);
background: var(--table-header);
color: var(--r2-sub);
@@ -966,7 +966,7 @@
}
#rotationView .trend-flat {
background: rgb(243, 244, 246);
background: var(--surface-muted);
color: var(--r2-sub);
}
@@ -991,7 +991,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.redesigned-rotation-view {
padding: 0px;
}
@@ -1087,7 +1087,7 @@
min-width: var(--table-wide);
}
@media (min-width: 721px) {
@media (min-width: 768px) {
#rotationView.active-view {
display: grid;
@@ -1157,7 +1157,7 @@
}
}
@media (max-width: 720px), (max-width: 1023px) and (max-height: 600px) {
@media (max-width: 767px), (max-width: 1023px) and (max-height: 600px) {
#rotationView.active-view {
display: block;
@@ -1245,3 +1245,14 @@
background: var(--surface-muted);
}
@media (max-width: 767px) {
#rotationView :is(.rotation-table-frame, .rotation-detail-card) {
max-height: none;
overflow-y: visible;
}
#rotationView .rotation-table-frame {
overflow-x: auto;
}
}
File diff suppressed because it is too large Load Diff
+21 -11
View File
@@ -55,15 +55,6 @@
</div>
</section>
</div>
<div class="screener-runbar runbar">
<div class="screener-run-actions">
<button id="screenerExportButton" class="button" type="button">导出 CSV</button>
</div>
<div class="screener-pipeline-status" aria-live="polite">
<span>因子 <strong id="factorTaskStatus">等待检查</strong></span>
<span>编译 <strong id="compilerStatus">本地模板编译</strong></span>
</div>
</div>
<div data-screener-results-slot="smart"></div>
</div>
<div class="curated-screener-panel" data-screener-panel="curated" hidden>
@@ -149,10 +140,23 @@
</section>
<div class="section-toolbar result-toolbar">
<div class="section-title-group"><h2 id="screenerResultTitle">候选结果</h2><span id="screenerResultCount" class="count-badge">0 只</span><span id="screenerResultSource" class="screener-result-source" hidden></span></div>
<span id="screenerDisclaimer" class="section-subtitle">历史统计不代表未来收益</span>
<div class="screener-result-tools">
<div class="screener-pipeline-status" aria-live="polite">
<span>因子 <strong id="factorTaskStatus">等待检查</strong></span>
<span>编译 <strong id="compilerStatus">本地模板编译</strong></span>
</div>
<div class="segmented screener-archive-tabs" role="tablist" aria-label="候选结果范围">
<button class="segment active" type="button" role="tab" aria-selected="true" data-screener-archive-view="latest">最新候选</button>
<button class="segment" type="button" role="tab" aria-selected="false" data-screener-archive-view="active">持续有效</button>
<button class="segment" type="button" role="tab" aria-selected="false" data-screener-archive-view="history">入选历史</button>
</div>
<span id="screenerDisclaimer" class="section-subtitle">历史统计不代表未来收益</span>
<button id="screenerExportButton" class="button" type="button"><i data-lucide="download"></i>导出 CSV</button>
</div>
</div>
<div id="screenerBatchNotice" class="screener-batch-notice" hidden></div>
<div class="table-frame card tbl-wrap screener-result-frame">
<table class="data-table tbl">
<table id="screenerLatestTable" class="data-table tbl">
<colgroup class="screener-result-columns"><col><col><col><col><col><col><col><col><col><col><col><col></colgroup>
<thead><tr>
<th class="num">排名</th><th>股票</th><th>板块</th><th class="number num sortable">综合分<span class="arr"></span></th>
@@ -161,6 +165,12 @@
</tr></thead>
<tbody id="screenerTableBody"></tbody>
</table>
<table id="screenerArchiveTable" class="data-table tbl screener-archive-table" hidden>
<thead><tr>
<th>入选日</th><th>股票</th><th>板块</th><th>状态</th><th>命中策略</th><th class="number num sortable">最新评分<span class="arr"></span></th><th>有效期</th>
</tr></thead>
<tbody id="screenerArchiveTableBody"></tbody>
</table>
<div id="screenerEmpty" class="empty-state">尚未执行选股</div>
</div>
</div>
+219 -20
View File
@@ -49,7 +49,15 @@ function selectedScreenerResultKey(mode) {
function activeScreenerResultEntry(mode = state.screenerMode) {
const key = selectedScreenerResultKey(mode);
return key ? state.screenerResultStore[key] || null : null;
const stored = key ? state.screenerResultStore[key] || null : null;
if (stored) return stored;
const normalizedMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart";
const latest = state.screenerResults[normalizedMode];
const context = state.screenerResultContexts[normalizedMode];
if (!latest || !context) return null;
if (normalizedMode === "quant") return { result: latest, context };
const selected = currentScreenerStrategy(normalizedMode);
return selected?.name === context.strategyName ? { result: latest, context } : null;
}
function activeScreenerResult(mode = state.screenerMode) {
@@ -84,11 +92,10 @@ function setScreenerResult(mode, result, options = {}) {
function applyScreenerSetup(payload, requestKey) {
const dateChanged = Boolean(state.screenerSetupKey && state.screenerSetupKey !== requestKey);
if (dateChanged) {
state.screenerResults = { smart: null, curated: null, quant: null };
state.screenerResultContexts = { smart: null, curated: null, quant: null };
state.screenerResultStore = {};
}
state.screenerResults = { smart: null, curated: null, quant: null };
state.screenerResultContexts = { smart: null, curated: null, quant: null };
state.screenerResultStore = {};
if (dateChanged) state.screenerArchiveView = "latest";
state.screenerSetup = payload;
state.screenerSetupKey = requestKey;
@@ -182,7 +189,13 @@ async function loadScreenerSetup(force = false) {
function renderScreenerSetup() {
const setup = state.screenerSetup;
if (!setup) return;
setText("screenerDateLabel", `数据日期 ${displayCompactDate(setup.trade_date)}`);
const publishedDate = setup.published_batch?.trade_date;
setText(
"screenerDateLabel",
publishedDate && publishedDate !== setup.trade_date
? `数据日期 ${displayCompactDate(setup.trade_date)} · 候选批次 ${displayCompactDate(publishedDate)}`
: `数据日期 ${displayCompactDate(setup.trade_date)}`,
);
setText("regimeLabel", setup.regime.label);
setText("regimeConfidence", `置信度 ${formatNumber(setup.regime.confidence, 0)}%`);
setText("regimeStepStatus", `${setup.regime.label} · 置信度 ${formatNumber(setup.regime.confidence, 0)}%`);
@@ -233,6 +246,7 @@ function renderStrategySummary() {
function selectScreenerMode(mode) {
state.screenerMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart";
state.screenerArchiveView = "latest";
localStorage.setItem("xiaobaiScreenerMode", state.screenerMode);
state.screenerMobileView = "strategy";
renderScreenerMode();
@@ -293,6 +307,17 @@ function curatedSchoolIcon(school) {
}
function curatedStrategyRunState(strategy, result) {
const publishedRun = strategy?.published_run || {};
if (publishedRun.status === "missing_data") {
return { label: "数据不足", className: "missing", verifiedEmpty: false };
}
if (publishedRun.status === "no_signal") {
return { label: "暂无信号", className: "quiet", verifiedEmpty: true };
}
if (publishedRun.status === "ready") {
const count = (result?.candidates || []).length;
return { label: `${count} 只候选`, className: "ready", verifiedEmpty: false };
}
const missingData = strategy?.missing_data || [];
if (!strategy?.data_ready || missingData.length) {
return { label: "数据不足", className: "missing", verifiedEmpty: false };
@@ -397,15 +422,24 @@ function renderCuratedStrategyDetail() {
}
document.querySelector("#curatedHealthMetrics").innerHTML = [
["运行状态", statusLabel, statusClass],
["当日信号", result ? `${candidateCount}` : "--", ""],
["批次信号", result ? `${candidateCount}` : "--", ""],
["字段覆盖", health.coverage != null ? `${formatNumber(health.coverage, 1)}%` : strategy.data_ready ? "数据已就绪" : "--", ""],
["最近更新", updatedLabel, ""],
].map(([label, value, className]) => `<div><span>${escapeHtml(label)}</span><strong class="${className}">${escapeHtml(value)}</strong></div>`).join("");
const status = document.querySelector("#curatedDataStatus");
status.classList.toggle("missing", statusClass === "missing");
status.innerHTML = strategy.data_ready
? `<i data-lucide="${runState.verifiedEmpty ? "circle-check" : "database"}"></i><span><strong>${runState.verifiedEmpty ? "本日暂无信号" : "盘后自动更新"}</strong><small>${runState.verifiedEmpty ? `必需数据已完整,本日没有股票同时满足 ${filters.length} 项准入条件` : result ? health.required_field_count != null ? `已核验 ${number(health.required_field_count)} 项因子 · ${number(health.complete_rows)} 只股票` : "盘后定格结果已载入" : "等待当日行情定格后生成"}</small></span>`
: `<i data-lucide="circle-alert"></i><span><strong>数据尚未完备</strong><small>${escapeHtml((strategy.missing_data || []).join("、") || "等待后台同步")}</small></span>`;
const publishedRun = strategy.published_run || {};
if (publishedRun.status === "missing_data") {
status.innerHTML = `<i data-lucide="circle-alert"></i><span><strong>缺少必需数据</strong><small>${escapeHtml(publishedRun.detail || "等待后台同步")}</small></span>`;
} else if (publishedRun.status === "no_signal") {
status.innerHTML = `<i data-lucide="circle-check"></i><span><strong>数据完整,暂无信号</strong><small>必需数据已完整,本日没有股票同时满足 ${filters.length} 项准入条件</small></span>`;
} else if (result) {
status.innerHTML = `<i data-lucide="database"></i><span><strong>盘后批次已发布</strong><small>${health.required_field_count != null ? `已核验 ${number(health.required_field_count)} 项因子 · ${number(health.complete_rows)} 只股票` : "盘后定格结果已载入"}</small></span>`;
} else if (!strategy.data_ready) {
status.innerHTML = `<i data-lucide="circle-alert"></i><span><strong>数据尚未完备</strong><small>${escapeHtml((strategy.missing_data || []).join("、") || "等待后台同步")}</small></span>`;
} else {
status.innerHTML = "<i data-lucide=\"clock-3\"></i><span><strong>等待成功批次</strong><small>运行中或失败的批次不会覆盖上一成功结果</small></span>";
}
refreshIcons();
}
@@ -825,7 +859,7 @@ async function executeScreenerFormula({ mode, formula, strategyName, strategyId
renderScreenerResult();
if (executionMode === "smart") setText("screenerRunStatus", `完成 · ${payload.result.candidates.length}`);
updateBacktestTaskStatus();
if (window.innerWidth <= 720) selectScreenerMobileView("results");
if (window.innerWidth <= 767) selectScreenerMobileView("results");
setStatus(`${strategyName}完成 · ${payload.result.candidates.length} 只候选`);
} catch (error) {
showToast(error.message);
@@ -842,22 +876,163 @@ async function executeScreenerFormula({ mode, formula, strategyName, strategyId
}
function renderScreenerResult() {
renderScreenerArchiveTabs();
renderScreenerBatchNotice();
if (state.screenerArchiveView === "latest") {
renderLatestScreenerResult();
} else {
renderScreenerArchiveResult();
}
}
function renderScreenerArchiveTabs() {
document.querySelectorAll("[data-screener-archive-view]").forEach((button) => {
const active = button.dataset.screenerArchiveView === state.screenerArchiveView;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
});
}
function renderScreenerBatchNotice() {
const notice = document.querySelector("#screenerBatchNotice");
const setup = state.screenerSetup || {};
const batch = setup.published_batch;
const requestStatus = setup.automatic_status || {};
notice.classList.toggle("warning", Boolean(batch?.is_fallback) || ["failed", "partial"].includes(requestStatus.status));
if (state.screenerMode === "quant") {
notice.innerHTML = "<strong>手动执行</strong><span>自定义选股结果独立保存,不会覆盖阶段选股或策略选股。</span>";
notice.hidden = false;
return;
}
if (!batch) {
const statusText = {
running: "盘后候选正在生成,完成前不会发布局部结果。",
failed: "最近一次盘后任务失败,尚无可展示的成功批次。",
partial: "最近一次盘后任务未完整完成,尚无可展示的成功批次。",
pending: "盘后候选尚未生成。",
}[requestStatus.status] || "尚无成功发布的盘后候选批次。";
notice.innerHTML = `<strong>尚未发布</strong><span>${escapeHtml(statusText)}</span>`;
notice.hidden = false;
return;
}
const completed = number(batch.completed_count);
const skipped = number(batch.skipped_count);
const detail = batch.notice
|| `盘后候选已完整发布${completed ? ` · 完成 ${completed} 套策略` : ""}${skipped ? ` · ${skipped} 套因数据不足跳过` : ""}`;
const time = batch.finished_at ? ` · ${formatTimestamp(batch.finished_at)}` : "";
notice.innerHTML = `<strong>候选基准 ${escapeHtml(displayCompactDate(batch.trade_date))}${time}</strong><span>${escapeHtml(detail)}</span>`;
notice.hidden = false;
}
function selectedScreenerArchiveRows() {
const mode = state.screenerMode || "smart";
const source = state.screenerArchiveView === "active"
? state.screenerSetup?.active_signals || []
: state.screenerSetup?.candidate_history || [];
const strategy = currentScreenerStrategy(mode);
return source.filter((row) => {
if (row.mode !== mode) return false;
if (mode === "quant") return true;
return (row.hits || []).some((hit) => hit.strategy_name === strategy?.name);
}).map((row) => {
let hits = row.hits || [];
if (mode !== "quant") hits = hits.filter((hit) => hit.strategy_name === strategy?.name);
if (state.screenerArchiveView === "active") hits = hits.filter((hit) => hit.active);
const latestHit = [...hits].sort((left, right) => String(right.selection_date).localeCompare(String(left.selection_date)))[0] || {};
const active = hits.some((hit) => hit.active);
const labels = [...new Set(hits.map((hit) => hit.validity?.label).filter(Boolean))];
return {
...row,
selection_date: latestHit.selection_date || row.selection_date,
score_display: latestHit.score_display ?? row.score_display,
matched_strategies: [...new Set(hits.map((hit) => hit.strategy_name).filter(Boolean))],
status: active ? "持续有效" : "已到期",
active,
validity_label: labels.join(" / ") || row.validity_label || "--",
valid_until: latestHit.valid_until || "",
remaining_trading_days: latestHit.remaining_trading_days,
};
});
}
function renderScreenerArchiveResult() {
const mode = state.screenerMode || "smart";
const rows = selectedScreenerArchiveRows();
setText("screenerResultTitle", state.screenerArchiveView === "active" ? "持续有效信号" : "历史入选记录");
const latestTable = document.querySelector("#screenerLatestTable");
const archiveTable = document.querySelector("#screenerArchiveTable");
latestTable.hidden = true;
archiveTable.hidden = false;
setText("screenerResultCount", `${rows.length}`);
const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "自定义选股" };
const strategy = currentScreenerStrategy(mode);
const source = document.querySelector("#screenerResultSource");
source.textContent = [modeLabels[mode], strategy?.name].filter(Boolean).join(" · ");
source.hidden = false;
setText(
"screenerDisclaimer",
state.screenerArchiveView === "active"
? "持续有效按策略频率与阶段变化计算,不代表未来收益"
: "历史入选仅保留当时策略结果,不使用未来数据回写",
);
const body = document.querySelector("#screenerArchiveTableBody");
body.innerHTML = rows.map((row) => {
const validity = row.valid_until
? `${row.validity_label} · 至 ${displayCompactDate(row.valid_until)}`
: row.validity_label;
const matched = (row.matched_strategies || []).join("、");
return `<tr data-code="${escapeHtml(row.code)}">
<td class="num">${escapeHtml(displayCompactDate(row.selection_date))}</td>
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
<td>${escapeHtml(row.sector)}</td>
<td><span class="screener-signal-status ${row.active ? "active" : "expired"}">${escapeHtml(row.status)}</span></td>
<td class="screener-archive-strategies" title="${escapeHtml(matched)}">${escapeHtml(matched)}</td>
<td class="number num">${row.score_display == null ? "" : formatNumber(row.score_display, 1)}</td>
<td>${escapeHtml(validity)}</td>
</tr>`;
}).join("");
bindStockRows(body);
const empty = document.querySelector("#screenerEmpty");
empty.textContent = state.screenerArchiveView === "active"
? "当前策略暂无仍在有效期内的信号"
: "当前策略暂无历史入选记录";
empty.hidden = rows.length > 0;
renderBacktest(null);
updateBacktestTaskStatus();
}
function latestScreenerEmptyMessage(mode) {
if (mode === "quant") return "尚未执行自定义选股";
const strategy = currentScreenerStrategy(mode);
const publishedRun = strategy?.published_run || {};
if (publishedRun.status === "missing_data") {
return `缺少必需数据:${publishedRun.detail || "等待后台同步"}`;
}
if (publishedRun.status === "no_signal") return "数据完整,暂无符合条件个股";
const status = state.screenerSetup?.automatic_status?.status;
if (status === "running") return "盘后候选正在生成,成功发布前不会显示局部结果";
if (status === "failed") return "本次盘后任务失败,当前策略暂无已发布结果";
if (status === "partial") return "本次盘后任务未完整完成,当前策略暂无已发布结果";
return "尚无成功发布的盘后候选结果";
}
function renderLatestScreenerResult() {
const mode = state.screenerMode || "smart";
const titles = { smart: "盘后候选结果", curated: "策略候选结果", quant: "自定义选股结果" };
setText("screenerResultTitle", titles[mode]);
const result = activeScreenerResult(mode);
const context = activeScreenerResultContext(mode);
const source = document.querySelector("#screenerResultSource");
const emptyMessages = {
smart: "当日盘后候选尚未生成",
curated: "所选策略的当日候选尚未生成",
quant: "尚未执行自定义选股",
};
const emptyMessage = latestScreenerEmptyMessage(mode);
document.querySelector("#screenerLatestTable").hidden = false;
document.querySelector("#screenerArchiveTable").hidden = true;
if (!result) {
setText("screenerResultCount", "0 只");
source.hidden = true;
source.textContent = "";
setText("screenerDisclaimer", "历史统计不代表未来收益");
document.querySelector("#screenerTableBody").innerHTML = "";
document.querySelector("#screenerEmpty").textContent = emptyMessages[mode];
document.querySelector("#screenerEmpty").textContent = emptyMessage;
document.querySelector("#screenerEmpty").hidden = false;
renderBacktest(null);
if (mode === "smart") setText("screenerRunStatus", "等待执行");
@@ -881,7 +1056,10 @@ function renderScreenerResult() {
: `盘后数据 ${displayCompactDate(meta.trade_date)} · ${result.disclaimer}`,
);
const empty = document.querySelector("#screenerEmpty");
empty.textContent = mode === "curated" ? "暂无符合条件个股" : emptyMessages[mode];
const verifiedEmpty = mode === "curated" ? "暂无符合条件个股" : "数据完整,暂无符合条件个股";
empty.textContent = mode === "quant"
? emptyMessage
: `${verifiedEmpty}${mode === "curated" ? "(必需数据已完整)" : ""}`;
empty.hidden = candidates.length > 0;
const body = document.querySelector("#screenerTableBody");
const runId = number(meta.run_id);
@@ -1053,7 +1231,20 @@ function parseFormulaEditor() {
}
function exportScreenerResults() {
const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "量化选股" };
const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "自定义选股" };
if (state.screenerArchiveView !== "latest") {
const viewLabel = state.screenerArchiveView === "active" ? "持续有效" : "入选历史";
const rows = selectedScreenerArchiveRows().map((row) => ({
...row,
matched_strategy_names: (row.matched_strategies || []).join("、"),
}));
exportRows(`${modeLabels[state.screenerMode]}-${viewLabel}`, rows, [
["入选日", "selection_date"], ["股票代码", "code"], ["股票名称", "name"],
["板块", "sector"], ["状态", "status"], ["命中策略", "matched_strategy_names"],
["最新评分", "score_display"], ["有效期", "validity_label"],
]);
return;
}
exportRows(modeLabels[state.screenerMode] || "智能选股", activeScreenerResult()?.candidates || [], [
["股票代码", "code"], ["股票名称", "name"], ["板块", "sector"], ["综合分", "score_display"],
["历史条件估计%", "historical_probability"], ["当日涨幅%", "pct_chg"], ["5日涨幅%", "return_5d"],
@@ -1091,6 +1282,14 @@ function bindScreenerEvents() {
document.querySelectorAll("[data-screener-mode]").forEach((button) => {
button.addEventListener("click", () => selectScreenerMode(button.dataset.screenerMode));
});
document.querySelectorAll("[data-screener-archive-view]").forEach((button) => {
button.addEventListener("click", () => {
state.screenerArchiveView = ["active", "history"].includes(button.dataset.screenerArchiveView)
? button.dataset.screenerArchiveView
: "latest";
renderScreenerResult();
});
});
document.querySelector("#curatedStrategyList").addEventListener("click", (event) => {
if (event.target.closest("button")) return;
const card = event.target.closest("[data-curated-strategy]");
+115 -29
View File
@@ -1,5 +1,5 @@
/* Canonical CSS owner: sentiment. Historical layers consolidated 2026-08-02. */
@media (max-width: 720px), (max-width: 1023px) and (max-height: 600px) {
@media (max-width: 767px), (max-width: 1023px) and (max-height: 600px) {
#sentimentCycleView {
--sentiment-history-max-height: min(480px, calc(100dvh - 210px));
}
@@ -240,11 +240,11 @@
}
.sentiment-history-table .sentiment-history-groups .group-ladder {
background: rgb(244, 245, 239);
background: var(--warning-soft);
}
.sentiment-history-table .sentiment-history-groups .group-risk {
background: rgb(248, 238, 238);
background: var(--market-up-soft);
}
.sentiment-history-table .sentiment-history-groups .group-feedback {
@@ -258,7 +258,7 @@
z-index: 5;
background: rgb(247, 249, 250);
background: var(--table-header);
color: rgb(57, 74, 86);
@@ -298,7 +298,7 @@
z-index: 7;
background: rgb(247, 249, 250);
background: var(--table-header);
}
.sentiment-history-table .sentiment-history-columns th:nth-child(2) {
@@ -310,19 +310,19 @@
z-index: 7;
background: rgb(247, 249, 250);
background: var(--table-header);
}
.sentiment-history-table tbody tr:nth-child(2n) td {
background: rgb(250, 251, 252);
background: var(--surface-subtle);
}
.sentiment-history-table tbody tr:hover td {
background: rgb(241, 246, 249);
background: var(--table-hover);
}
.sentiment-history-table tbody tr.latest-row td {
background: rgb(242, 247, 253);
background: var(--table-selected);
font-weight: 650;
}
@@ -428,7 +428,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
body[data-active-view="sentimentCycleView"] .overview-strip {
display: none;
}
@@ -495,7 +495,7 @@
border-radius: 50%;
background: conic-gradient(var(--coral) calc(var(--score) * 1%), #e5eaee 0);
background: conic-gradient(var(--coral) calc(var(--score) * 1%), var(--border) 0);
position: relative;
@@ -541,7 +541,7 @@
background: rgb(238, 243, 246);
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.sentiment-block {
min-height: 0px;
@@ -619,7 +619,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.overview-strip .sentiment-block {
grid-area: 1 / 1 / 3;
@@ -810,7 +810,7 @@
border-radius: var(--card-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--card-shadow);
@@ -838,7 +838,7 @@
border-radius: var(--card-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--card-shadow);
@@ -854,7 +854,7 @@
border-radius: var(--card-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--card-shadow);
@@ -973,7 +973,7 @@
border-radius: 5px;
background: rgb(243, 244, 246);
background: var(--surface-muted);
color: var(--r2-sub);
@@ -1021,7 +1021,7 @@
background: var(--r2-ink);
color: rgb(255, 255, 255);
color: var(--text-inverse);
font-size: 11px;
@@ -1243,7 +1243,7 @@
border-radius: 5px;
background: rgb(240, 242, 245);
background: var(--surface-muted);
}
.redesigned-sentiment-view .sentiment-component-item:nth-child(n) .sentiment-component-track i,
@@ -1280,7 +1280,7 @@
border-radius: var(--r2-radius) var(--r2-radius) 0 0;
background: rgb(255, 255, 255);
background: var(--surface);
}
.redesigned-sentiment-view .sentiment-detail-toolbar h2 {
@@ -1298,7 +1298,7 @@
border-radius: 0 0 var(--r2-radius) var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
}
@@ -1317,7 +1317,7 @@
}
.redesigned-sentiment-view .sentiment-history-table thead th {
background: rgb(248, 250, 252);
background: var(--table-header);
color: var(--r2-sub);
@@ -1327,7 +1327,7 @@
}
.redesigned-sentiment-view .sentiment-history-groups th {
background: rgb(243, 245, 247);
background: var(--table-header);
}
@media (max-width: 1180px) {
@@ -1336,7 +1336,7 @@
}
}
@media (max-width: 1023px) and (min-width: 721px) {
@media (max-width: 1023px) and (min-width: 768px) {
.redesigned-emotion-grid {
grid-template-columns: 1fr;
}
@@ -1348,7 +1348,7 @@
}
}
@media (max-width: 720px), (max-width: 1023px) and (max-height: 600px) {
@media (max-width: 767px), (max-width: 1023px) and (max-height: 600px) {
.redesigned-emotion-grid {
grid-template-columns: 1fr;
}
@@ -1508,7 +1508,7 @@
text-align: center;
}
@media (min-width: 721px) {
@media (min-width: 768px) {
#sentimentCycleView.active-view {
min-height: 0px;
@@ -1534,7 +1534,7 @@
}
}
@media (min-width: 721px) and (max-height: 1100px) {
@media (min-width: 768px) and (max-height: 1100px) {
#sentimentCycleView .sentiment-phase-block {
gap: 12px;
@@ -1570,7 +1570,7 @@
}
}
@media (min-width: 721px) {
@media (min-width: 768px) {
:root #sentimentCycleView.active-view {
height: auto;
@@ -1661,7 +1661,7 @@
padding-bottom: var(--card-gap);
}
@media (min-width: 721px) {
@media (min-width: 768px) {
.redesigned-sentiment-view .sentiment-chart-shell {
padding-right: 16px;
@@ -1674,3 +1674,89 @@
grid-template-columns: minmax(270px, 1.25fr) repeat(3, minmax(160px, 1fr));
}
}
/* Mobile information order: trend, current phase, composition, then history. */
@media (max-width: 767px) {
#sentimentCycleView .sentiment-cycle-toolbar {
gap: var(--space-8);
padding: 0;
}
#sentimentCycleView .sentiment-cycle-analysis,
#sentimentCycleView .redesigned-emotion-grid {
display: flex;
flex-direction: column;
gap: var(--space-12);
}
#sentimentCycleView .sentiment-analysis-main {
order: 1;
}
#sentimentCycleView .sentiment-analysis-rail {
order: 2;
display: grid !important;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-12);
}
#sentimentCycleView .sentiment-trend-panel,
#sentimentCycleView .sentiment-components-panel {
padding: 0;
}
#sentimentCycleView .sentiment-chart-legend {
gap: var(--space-12);
padding: 0 var(--space-12);
overflow-x: auto;
white-space: nowrap;
scrollbar-width: none;
}
#sentimentCycleView .sentiment-chart-shell,
#sentimentCycleView .sentiment-chart-shell canvas {
height: var(--mobile-chart-height);
}
#sentimentCycleView .sentiment-phase-block {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: var(--space-12);
}
#sentimentCycleView .sentiment-current-phase-badge {
min-width: var(--mobile-phase-badge-width);
}
#sentimentCycleView .sentiment-detail-toolbar {
min-height: var(--mobile-touch-size);
padding: var(--space-8) 0;
}
#sentimentCycleView .sentiment-history-frame {
min-height: var(--mobile-table-min-height);
max-height: none;
margin-bottom: 0;
padding-bottom: 0;
overflow-x: auto;
overflow-y: visible;
}
}
@media (max-width: 359px) {
#sentimentCycleView .sentiment-phase-block {
grid-template-columns: minmax(0, 1fr);
}
}
@media (min-width: 768px) and (max-width: 1023px) {
#sentimentCycleView .redesigned-emotion-grid {
grid-template-columns: minmax(0, 1fr);
}
#sentimentCycleView .sentiment-analysis-rail {
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+47 -29
View File
@@ -117,7 +117,7 @@
border-right: 0px;
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.theme-detail-empty {
min-height: 260px;
}
@@ -231,7 +231,7 @@
border-color: var(--border);
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.theme-directory {
max-height: 300px;
@@ -245,7 +245,7 @@
border-radius: var(--card-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--card-shadow);
@@ -313,7 +313,7 @@
font-size: 11px;
font-weight: 650;
font-weight: var(--font-weight-semibold);
white-space: nowrap;
}
@@ -341,11 +341,11 @@
padding: 0px 10px;
border: 1px solid rgb(216, 221, 229);
border: 1px solid var(--control-border);
border-radius: 7px;
background: rgb(255, 255, 255);
background: var(--surface);
color: var(--r2-faint);
@@ -353,9 +353,9 @@
}
.theme-search-v2:focus-within {
border-color: rgb(150, 181, 242);
border-color: var(--action);
box-shadow: rgba(37, 99, 235, 0.09) 0px 0px 0px 3px;
box-shadow: 0 0 0 3px var(--focus-ring);
}
.theme-search-v2 .lucide {
@@ -417,7 +417,7 @@
border-radius: var(--r2-radius);
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: var(--r2-shadow);
}
@@ -451,7 +451,7 @@
font-size: 19px;
font-weight: 750;
font-weight: var(--font-weight-bold);
font-variant-numeric: tabular-nums;
}
@@ -549,13 +549,13 @@
border-radius: 5px;
background: rgb(243, 244, 246);
background: var(--surface-muted);
color: var(--r2-sub);
font-size: 10.5px;
font-weight: 650;
font-weight: var(--font-weight-semibold);
}
.theme-directory-labels-v2 {
@@ -571,7 +571,7 @@
border-bottom: 1px solid var(--r2-line-soft);
background: rgb(250, 251, 252);
background: var(--surface-subtle);
color: var(--r2-faint);
@@ -651,7 +651,7 @@
}
.theme-directory-item-v2:hover {
background: rgb(247, 249, 252);
background: var(--surface-hover);
}
.theme-directory-item-v2.active {
@@ -685,7 +685,7 @@
.theme-directory-item-v2:nth-child(-n+3) .theme-rank-v2 {
color: var(--r2-amber);
font-weight: 750;
font-weight: var(--font-weight-bold);
}
.theme-directory-copy-v2 {
@@ -703,7 +703,7 @@
font-size: 13px;
font-weight: 650;
font-weight: var(--font-weight-semibold);
text-overflow: ellipsis;
@@ -848,7 +848,7 @@
font-size: 18px;
font-weight: 750;
font-weight: var(--font-weight-bold);
text-overflow: ellipsis;
@@ -862,7 +862,7 @@
border-radius: 4px;
background: rgb(243, 244, 246);
background: var(--surface-muted);
color: var(--r2-sub);
@@ -888,7 +888,7 @@
.theme-change-v2 strong {
font-size: 22px;
font-weight: 750;
font-weight: var(--font-weight-bold);
font-variant-numeric: tabular-nums;
}
@@ -902,7 +902,7 @@
border-bottom: 1px solid var(--r2-line-soft);
background: rgb(252, 252, 253);
background: var(--table-header);
}
.theme-detail-metrics-v2 > div {
@@ -1034,7 +1034,7 @@
border-bottom-color: var(--r2-line);
background: rgb(250, 251, 252);
background: var(--surface-subtle);
color: var(--r2-sub);
@@ -1054,17 +1054,17 @@
}
.theme-members-table-v2 tbody tr:hover {
background: rgb(247, 249, 252);
background: var(--table-hover);
}
.theme-members-table-v2 .stock-name {
color: var(--r2-ink);
font-weight: 650;
font-weight: var(--font-weight-semibold);
}
.theme-members-table-v2 .stock-code {
color: rgb(64, 85, 115);
color: var(--action);
}
@media (min-width: 1181px) {
@@ -1100,7 +1100,7 @@
}
}
@media (min-width: 721px) and (max-width: 1180px) {
@media (min-width: 768px) and (max-width: 1180px) {
body[data-active-view="themeLibraryView"] .app-main {
overflow: hidden auto;
}
@@ -1128,7 +1128,7 @@
}
}
@media (min-width: 721px) and (max-height: 900px) {
@media (min-width: 768px) and (max-height: 900px) {
.redesigned-theme-view {
padding-top: 9px;
@@ -1192,7 +1192,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.redesigned-theme-view {
padding: 10px;
}
@@ -1404,7 +1404,7 @@
grid-template-columns: var(--right-rail-wide) minmax(0,1fr);
}
@media (min-width: 721px) {
@media (min-width: 768px) {
#themeLibraryView .theme-page-head-v2,
#themeLibraryView .theme-summary-v2 {
flex: 0 0 auto;
@@ -1452,7 +1452,7 @@
}
}
@media (max-width: 720px), (max-width: 1023px) and (max-height: 600px) {
@media (max-width: 767px), (max-width: 1023px) and (max-height: 600px) {
#themeLibraryView .theme-library-workspace-v2 {
height: auto;
@@ -1534,3 +1534,21 @@
transition: none;
}
}
@media (max-width: 767px) {
#themeLibraryView {
padding-inline: 0;
}
#themeLibraryView :is(.theme-directory-v2, .theme-detail-column-v2, .theme-detail-stack-v2) {
max-height: none;
overflow: visible;
}
#themeLibraryView .theme-members-frame-v2 {
min-height: var(--mobile-table-min-height);
max-height: none;
overflow-x: auto;
overflow-y: visible;
}
}
+10 -10
View File
@@ -315,7 +315,7 @@
border-radius: 4px;
background: rgb(255, 255, 255);
background: var(--surface);
}
.membership-expiry {
@@ -479,13 +479,13 @@
white-space: nowrap;
}
@media (min-width: 721px) and (max-width: 1279px) {
@media (min-width: 768px) and (max-width: 1279px) {
.account-button {
width: 32px;
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.header-command-group .account-button {
width: 100%;
@@ -729,7 +729,7 @@ button.account-role-badge:focus-visible {
.vip-role-badge.is-nonmember b {
background: rgb(135, 147, 158);
color: rgb(255, 255, 255);
color: var(--text-inverse);
}
.vip-role-badge b {
@@ -1020,7 +1020,7 @@ button.account-role-badge:focus-visible {
gap: 10px;
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.account-menu-shell {
width: 100%;
@@ -1128,7 +1128,7 @@ button.account-role-badge:focus-visible {
box-shadow: rgba(22, 34, 46, 0.15) 0px 14px 35px;
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.account-settings-dialog,
.settings-dialog {
width: calc(-16px + 100vw);
@@ -1216,7 +1216,7 @@ button.account-role-badge:focus-visible {
background: rgb(37, 99, 235);
color: rgb(255, 255, 255);
color: var(--text-inverse);
box-shadow: none;
@@ -1401,7 +1401,7 @@ button.account-role-badge:focus-visible {
border-radius: 7px;
background: rgb(248, 250, 252);
background: var(--table-header);
}
.account-settings-dialog .membership-status-grid span {
@@ -1439,7 +1439,7 @@ button.account-role-badge:focus-visible {
}
.account-settings-dialog .membership-comparison-head {
background: rgb(245, 247, 249);
background: var(--table-header);
}
.account-settings-dialog .membership-topup-row {
@@ -1648,7 +1648,7 @@ button.account-role-badge:focus-visible {
box-shadow: var(--shadow);
}
@media (min-width: 721px) {
@media (min-width: 768px) {
.account-menu-shell {
display: inline-flex;
}
+33 -8
View File
@@ -54,7 +54,7 @@ html {
color: var(--r2-ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-family: inherit;
font-size: 13px;
}
@@ -80,7 +80,7 @@ body {
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-family: inherit;
font-size: 13px;
@@ -98,7 +98,7 @@ time {
}
[tabindex]:focus-visible {
outline: rgba(8, 127, 174, 0.48) solid 2px;
outline: var(--action) solid 2px;
outline-offset: 2px;
}
@@ -110,28 +110,28 @@ dialog {
border: 1px solid var(--border);
border-radius: 9px;
border-radius: var(--radius-lg);
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow);
box-shadow: var(--shadow-float);
}
dialog::backdrop {
background: rgba(27, 38, 49, 0.48);
background: var(--backdrop);
backdrop-filter: blur(2px);
}
@media (min-width: 721px) {
@media (min-width: 768px) {
body {
overflow-y: hidden;
}
}
@media (max-width: 720px), (max-width: 1023px) and (max-height: 600px) {
@media (max-width: 767px), (max-width: 1023px) and (max-height: 600px) {
html {
width: 100%;
@@ -185,3 +185,28 @@ dialog::backdrop {
animation: auto ease 0s 1 normal none running none;
}
}
/* Mobile document contract: the page owns the only vertical scrollbar. */
@media (max-width: 767px) {
html {
width: 100%;
min-width: var(--mobile-min-width);
min-height: 100%;
height: auto;
overflow-x: hidden;
}
body.mobile-shell {
width: 100%;
min-width: var(--mobile-min-width);
min-height: 100%;
height: auto;
padding-bottom: var(--mobile-content-bottom);
overflow-y: auto;
overscroll-behavior-y: contain;
}
body.mobile-shell.mobile-command-open {
overflow: hidden;
}
}
+32 -30
View File
@@ -168,7 +168,7 @@
font-size: 11px;
}
@media (max-width: 720px), (max-width: 1023px) and (max-height: 600px) {
@media (max-width: 767px), (max-width: 1023px) and (max-height: 600px) {
.redesigned-page-head {
align-items: flex-start;
@@ -178,7 +178,7 @@
}
}
@media (min-width: 721px) {
@media (min-width: 768px) {
:is(.redesigned-auction-view, .redesigned-theme-view, .redesigned-popularity-view, .redesigned-dragon-view) {
width: min(100%, 2200px);
@@ -201,13 +201,13 @@
}
.card {
background: var(--card);
background: var(--card-bg);
border: 1px solid var(--line);
border: 1px solid var(--card-border);
border-radius: var(--radius);
border-radius: var(--card-radius);
box-shadow: var(--shadow);
box-shadow: var(--card-shadow);
}
.card-h {
@@ -219,19 +219,21 @@
padding: 11px 14px;
border-bottom: 1px solid var(--line-soft);
border-bottom: 1px solid var(--border-subtle);
}
.card-h h3 {
font-size: 14px;
color: var(--text-primary);
font-weight: 700;
font-size: var(--font-size-card-title);
font-weight: var(--font-weight-bold);
}
.card-h .sub {
font-size: 11px;
font-size: var(--font-size-caption);
color: var(--faint);
color: var(--text-tertiary);
}
.card-h .right {
@@ -285,11 +287,11 @@
}
.tag.neu {
background: rgb(243, 244, 246);
background: var(--surface-muted);
color: rgb(75, 85, 99);
color: var(--text-secondary);
border-color: rgb(229, 231, 235);
border-color: var(--border);
}
.tag.red {
@@ -313,7 +315,7 @@
.tag.b3 {
background: var(--blue);
color: rgb(255, 255, 255);
color: var(--text-inverse);
}
.field {
@@ -335,9 +337,9 @@
.src {
display: inline-block;
background: rgb(243, 244, 246);
background: var(--surface-muted);
color: rgb(107, 114, 128);
color: var(--text-secondary);
border-radius: 4px;
@@ -435,7 +437,7 @@
background: var(--up-soft);
border: 1px solid rgb(245, 207, 201);
border: 1px solid var(--up);
border-radius: 10px;
@@ -467,7 +469,7 @@
.phase-info .sum {
font-size: 12.5px;
color: rgb(55, 65, 81);
color: var(--text-primary);
line-height: 1.7;
}
@@ -651,19 +653,19 @@
}
.tier.t4 .lab {
background: linear-gradient(90deg, rgb(253, 241, 239), rgb(255, 255, 255));
background: var(--up-soft);
}
.tier.t3 .lab {
background: linear-gradient(90deg, rgb(253, 246, 236), rgb(255, 255, 255));
background: var(--warning-soft);
}
.tier.t2 .lab {
background: linear-gradient(90deg, rgb(236, 247, 241), rgb(255, 255, 255));
background: var(--down-soft);
}
.tier.t1 .lab {
background: linear-gradient(90deg, rgb(238, 244, 253), rgb(255, 255, 255));
background: var(--action-soft);
}
.tier.gap .cards {
@@ -797,7 +799,7 @@
height: 8px;
background: rgb(243, 244, 246);
background: var(--surface-muted);
border-radius: 4px;
@@ -875,7 +877,7 @@
height: 9px;
background: rgb(240, 242, 245);
background: var(--surface-muted);
border-radius: 5px;
@@ -887,7 +889,7 @@
height: 100%;
background: linear-gradient(90deg,#93b4f5,var(--blue));
background: var(--blue);
border-radius: 5px;
}
@@ -913,7 +915,7 @@
}
.perf-card {
background: rgb(255, 255, 255);
background: var(--surface);
border: 1px solid var(--line);
@@ -939,7 +941,7 @@
.perf-card .bar {
height: 6px;
background: rgb(240, 242, 245);
background: var(--surface-muted);
border-radius: 3px;
@@ -973,7 +975,7 @@
}
.width-bar .dn {
background: rgb(22, 163, 74);
background: var(--down);
}
.width-legend {
@@ -1011,7 +1013,7 @@
}
.model:hover {
background: rgb(248, 250, 255);
background: var(--table-hover);
}
.model .r1 {
+110 -60
View File
@@ -9,7 +9,7 @@
stroke-width: 1.75;
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.header-command-group .command-button {
width: 100%;
@@ -87,7 +87,7 @@ textarea:focus-visible {
}
.sidebar-collapse-button:hover {
background: rgb(241, 244, 247);
background: var(--control-hover);
color: var(--text-primary);
}
@@ -106,7 +106,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
transform: rotate(180deg);
}
@media (min-width: 721px) and (max-width: 1279px) {
@media (min-width: 768px) and (max-width: 1279px) {
.command-button {
width: 32px;
@@ -118,7 +118,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
}
}
@media (min-width: 721px) and (max-width: 1023px) {
@media (min-width: 768px) and (max-width: 1023px) {
.sidebar-collapse-button span {
display: none;
}
@@ -132,7 +132,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.header-command-group .button.primary {
border-color: var(--action);
@@ -186,7 +186,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
height: 15px;
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.stock-heaven-button span {
display: none;
}
@@ -203,7 +203,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
.icon-button:hover {
border-color: rgb(148, 181, 216);
background: rgb(248, 251, 255);
background: var(--surface-selected);
color: var(--action-hover);
@@ -241,7 +241,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
font-weight: 670;
}
@media (min-width: 721px) and (max-width: 1279px) {
@media (min-width: 768px) and (max-width: 1279px) {
.sidebar-collapse-button span {
display: none;
}
@@ -253,7 +253,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.header-menu-button {
width: 34px;
@@ -454,7 +454,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
min-height: var(--control-height);
border-color: var(--border-strong);
border-color: var(--control-border);
border-radius: var(--radius-md);
@@ -462,13 +462,13 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
padding: 0px 12px;
background: var(--surface-raised);
background: var(--control-surface);
color: var(--xb-gray-700);
color: var(--text-primary);
font-size: 12px;
font-size: var(--font-size-label);
font-weight: 620;
font-weight: var(--font-weight-semibold);
transition: border-color var(--duration-fast), color var(--duration-fast), background var(--duration-fast), transform var(--duration-fast);
}
@@ -476,17 +476,17 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
.button:hover {
box-shadow: none;
border-color: rgb(184, 199, 232);
border-color: var(--color-action-line);
color: var(--primary);
background: var(--xb-gray-25);
background: var(--control-hover);
}
.button:active {
box-shadow: none;
transform: translateY(1px);
transform: scale(.98);
}
.button.primary {
@@ -494,7 +494,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
background: var(--primary);
color: rgb(255, 255, 255);
color: var(--on-action);
}
.button.primary:hover {
@@ -502,7 +502,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
background: var(--primary-hover);
color: rgb(255, 255, 255);
color: var(--on-action);
}
.form-field input {
@@ -520,7 +520,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
border-radius: var(--radius-md);
background: var(--surface-raised);
background: var(--control-surface);
box-shadow: none;
}
@@ -531,7 +531,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
border-radius: var(--radius-md);
background: var(--surface-raised);
background: var(--control-surface);
box-shadow: none;
}
@@ -579,7 +579,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
border-radius: var(--radius-md);
background: var(--surface-raised);
background: var(--control-surface);
box-shadow: none;
}
@@ -588,12 +588,12 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
.form-field select:focus,
.form-field textarea:focus,
.search-field:focus-within {
border-color: rgb(142, 172, 239);
border-color: var(--action);
box-shadow: rgba(53, 106, 230, 0.1) 0px 0px 0px 3px;
box-shadow: 0 0 0 3px var(--focus-ring);
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.module-nav .sidebar-collapse-button {
display: none;
}
@@ -682,7 +682,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
border-radius: 0px;
background: rgb(255, 255, 255);
background: var(--surface);
color: var(--r2-sub);
@@ -714,7 +714,7 @@ body.sidebar-collapsed .sidebar-collapse-button {
border-radius: 7px;
background: rgb(255, 255, 255);
background: var(--surface);
color: rgb(75, 85, 99);
@@ -732,7 +732,7 @@ body.sidebar-collapsed .sidebar-collapse-button {
border-radius: 7px;
background: rgb(255, 255, 255);
background: var(--surface);
color: rgb(55, 65, 81);
@@ -748,10 +748,10 @@ body.sidebar-collapsed .sidebar-collapse-button {
background: var(--r2-blue);
color: rgb(255, 255, 255);
color: var(--text-inverse);
}
@media (max-width: 1023px) and (min-width: 721px) {
@media (max-width: 1023px) and (min-width: 768px) {
.sidebar-collapse-button span {
display: none;
}
@@ -830,7 +830,7 @@ textarea {
}
.seg button.on {
background: rgb(255, 255, 255);
background: var(--surface);
color: var(--ink);
@@ -922,7 +922,7 @@ textarea {
.method button.on {
background: var(--blue);
color: rgb(255, 255, 255);
color: var(--text-inverse);
}
.factor input[type="range"] {
@@ -944,33 +944,7 @@ textarea {
font-size: 12px;
}
.chat-input {
display: flex;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--line-soft);
}
.chat-input input {
flex: 1 1 0%;
border: 1px solid var(--line);
border-radius: 8px;
padding: 9px 12px;
outline: none;
}
.chat-input input:focus {
border-color: var(--blue-line);
}
@media (max-width: 720px), (max-width: 1023px) and (max-height: 600px) {
@media (max-width: 767px), (max-width: 1023px) and (max-height: 600px) {
.sidebar-collapse-button {
display: none;
}
@@ -1034,8 +1008,84 @@ textarea {
box-shadow: var(--control-shadow);
}
@media (min-width: 721px) {
@media (min-width: 768px) {
.header-menu-button {
display: none;
}
}
/* Mobile control contract. */
@media (max-width: 767px) {
body.mobile-shell .header-menu-button,
body.mobile-shell .header-actions > .header-menu-button {
width: var(--mobile-touch-size);
min-width: var(--mobile-touch-size);
height: var(--mobile-touch-size);
min-height: var(--mobile-touch-size);
display: grid;
place-items: center;
}
body.mobile-shell .header-command-group .command-button {
width: 100%;
height: auto;
min-height: var(--mobile-touch-size);
justify-content: flex-start;
padding: 0 var(--space-12);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--control-surface);
color: var(--text-primary);
}
body.mobile-shell .header-command-group .command-button > span {
display: inline;
}
body.mobile-shell .mobile-command-shortcut {
width: 100%;
height: auto;
min-height: var(--mobile-touch-size);
}
body.mobile-shell .mobile-market-selector:not([hidden]) {
position: relative;
width: 100%;
height: var(--mobile-touch-size);
min-height: var(--mobile-touch-size);
display: grid;
grid-template-columns: auto minmax(0, 1fr) var(--mobile-nav-icon-size);
align-items: center;
gap: var(--space-8);
margin: var(--mobile-page-pad) 0 0;
padding: 0 var(--space-12);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
box-shadow: none;
}
body.mobile-shell .mobile-market-selector select {
width: 100%;
height: calc(var(--mobile-touch-size) - 2px);
min-height: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--text-primary);
font-size: var(--font-size-body);
font-weight: var(--font-weight-semibold);
text-align: right;
appearance: none;
}
body.mobile-shell :is(.button, .icon-button, button) {
touch-action: manipulation;
}
}
:root[data-theme="dark"] .mobile-command-shortcut {
border-color: var(--border);
background: var(--surface-subtle);
color: var(--text-secondary);
}
+8 -8
View File
@@ -40,7 +40,7 @@
border-radius: 7px;
background: rgb(250, 251, 252);
background: var(--surface-subtle);
}
@keyframes overlay-enter {
@@ -53,7 +53,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.dialog-header-actions {
flex-wrap: wrap;
@@ -189,7 +189,7 @@
font-size: 12px;
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.global-search-dialog {
width: calc(-16px + 100vw);
@@ -266,7 +266,7 @@
background: var(--market-up);
color: rgb(255, 255, 255);
color: var(--text-inverse);
font-size: 9px;
@@ -431,7 +431,7 @@
height: 34px;
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.alerts-dialog {
width: calc(-16px + 100vw);
@@ -713,7 +713,7 @@
font-size: 10px;
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.assistant-dialog {
max-width: none;
@@ -977,7 +977,7 @@
border-radius: 7px;
background: rgb(250, 251, 252);
background: var(--surface-subtle);
}
.global-search-empty {
@@ -1193,7 +1193,7 @@
bottom: 0px;
background: rgb(255, 255, 255);
background: var(--surface);
z-index: 95;
+2 -10
View File
@@ -14,10 +14,6 @@
}
}
.loading-message p {
color: var(--text-muted);
}
@keyframes breathe-core {
0%,
100% {
@@ -132,7 +128,7 @@
background: rgb(33, 49, 60);
color: rgb(255, 255, 255);
color: var(--text-inverse);
box-shadow: var(--shadow);
@@ -163,7 +159,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
@keyframes command-menu-enter {
0% {
opacity: 0;
@@ -364,14 +360,10 @@
@keyframes stage18-dialog-enter {
0% {
opacity: 0;
transform: translateY(7px) scale(0.992);
}
100% {
opacity: 1;
transform: translateY(0px) scale(1);
}
}
+42 -6
View File
@@ -23,7 +23,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.toolbar-controls {
width: 100%;
@@ -114,7 +114,7 @@
border-radius: 8px;
background: rgb(243, 244, 246);
background: var(--surface-muted);
}
.redesigned-page-head .segment {
@@ -134,7 +134,7 @@
}
.redesigned-page-head .segment.active {
background: rgb(255, 255, 255);
background: var(--surface);
color: var(--r2-ink);
@@ -143,7 +143,7 @@
box-shadow: rgba(0, 0, 0, 0.08) 0px 1px 2px;
}
@media (max-width: 720px), (max-width: 1023px) and (max-height: 600px) {
@media (max-width: 767px), (max-width: 1023px) and (max-height: 600px) {
.redesigned-page-head .toolbar-controls {
width: 100%;
@@ -204,7 +204,7 @@
.seg {
display: inline-flex;
background: rgb(243, 244, 246);
background: var(--surface-muted);
border-radius: 8px;
@@ -219,8 +219,44 @@
}
}
@media (min-width: 721px) {
@media (min-width: 768px) {
.toolbar-controls {
align-items: center;
}
}
/* Mobile navigation and filter contract. */
@media (max-width: 767px) {
body.mobile-shell .toolbar-controls,
body.mobile-shell .redesigned-page-head .toolbar-controls {
width: 100%;
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-8);
margin-left: 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
body.mobile-shell .toolbar-controls::-webkit-scrollbar,
body.mobile-shell .redesigned-page-head .toolbar-controls::-webkit-scrollbar {
display: none;
}
body.mobile-shell .segmented,
body.mobile-shell .redesigned-page-head .segmented {
width: max-content;
min-width: max-content;
min-height: var(--mobile-touch-size);
flex: 0 0 auto;
}
body.mobile-shell .segment,
body.mobile-shell .redesigned-page-head .segment {
min-width: var(--mobile-touch-size);
min-height: calc(var(--mobile-touch-size) - var(--space-4));
padding: 0 var(--space-12);
}
}
+21 -21
View File
@@ -6,7 +6,7 @@
}
.data-table th[data-sort]:hover {
background: rgb(227, 237, 242);
background: var(--surface-hover);
color: var(--blue-dark);
}
@@ -76,7 +76,7 @@
}
.data-table:not(#limitTable) th[data-auto-sort]:hover {
background: rgb(227, 237, 242);
background: var(--surface-hover);
color: var(--action-hover);
}
@@ -110,7 +110,7 @@
}
.data-table td {
background: var(--surface);
background: transparent;
text-align: left;
@@ -120,7 +120,7 @@
border-right: 0px;
border-bottom: 1px solid rgb(232, 237, 241);
border-bottom: 1px solid var(--border-subtle);
line-height: 1.4;
}
@@ -138,26 +138,26 @@
border-right: 0px;
border-bottom: 1px solid rgb(232, 237, 241);
border-bottom: 1px solid var(--border);
line-height: 1.4;
height: 38px;
background: rgb(244, 247, 249);
background: var(--table-header);
color: rgb(82, 98, 115);
color: var(--text-secondary);
font-size: 11.5px;
font-size: var(--font-size-label);
font-weight: 700;
font-weight: var(--font-weight-semibold);
}
.data-table tbody tr.selected td {
background: rgb(237, 245, 255);
background: var(--table-selected);
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.data-table {
font-size: 12.5px;
}
@@ -188,13 +188,13 @@
white-space: nowrap;
color: rgb(38, 51, 64);
color: var(--text-primary);
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
font-size: var(--font-size-table);
}
.data-table tbody tr:last-child td {
@@ -208,7 +208,7 @@
}
.data-table tbody tr:hover td {
background: rgb(248, 250, 255);
background: var(--table-hover);
}
.data-table .stock-name {
@@ -246,25 +246,25 @@
border-bottom: 1px solid var(--border);
background: var(--surface-subtle);
background: var(--table-header);
color: var(--text-secondary);
font-weight: 650;
font-weight: var(--font-weight-semibold);
white-space: nowrap;
height: 36px;
font-size: 11.5px;
font-size: var(--font-size-label);
}
.data-table tbody td {
padding: 6px 11px;
border-bottom: 1px solid rgb(237, 240, 244);
border-bottom: 1px solid var(--border-subtle);
color: var(--xb-gray-700);
color: var(--text-primary);
height: 41px;
}
@@ -294,7 +294,7 @@
top: 0px;
background: rgb(248, 250, 252);
background: var(--table-header);
color: var(--sub);
@@ -346,7 +346,7 @@
}
.tbl tbody tr:hover {
background: rgb(248, 250, 255);
background: var(--table-hover);
}
.tbl.compact tbody td {
+4
View File
@@ -137,6 +137,7 @@ const state = window.XiaobaiState.create({
screenerResults: { smart: null, curated: null, quant: null },
screenerResultContexts: { smart: null, curated: null, quant: null },
screenerResultStore: {},
screenerArchiveView: "latest",
screenerTracking: null,
screenerMode: ["smart", "curated", "quant"].includes(localStorage.getItem("xiaobaiScreenerMode"))
? localStorage.getItem("xiaobaiScreenerMode")
@@ -181,6 +182,9 @@ const state = window.XiaobaiState.create({
heartLines: [],
heartThrows: [],
heartHexagram: null,
heartQuestion: "",
heartQuestionPreset: "custom",
heartCastAt: "",
heartCurtainTimer: null,
heartStageToken: 0,
heartRevealToken: 0,
+9 -1
View File
@@ -96,7 +96,15 @@ function applyMembershipAccess() {
if (gate) gate.hidden = unlocked;
view.querySelectorAll("button, input, textarea, select").forEach((control) => {
if (control.closest(".member-gate") || control.hasAttribute("data-member-navigation")) return;
control.disabled = !unlocked;
if (!unlocked) {
if (!("memberDisabled" in control.dataset)) {
control.dataset.memberDisabled = String(control.disabled);
}
control.disabled = true;
} else if ("memberDisabled" in control.dataset) {
control.disabled = control.dataset.memberDisabled === "true";
delete control.dataset.memberDisabled;
}
});
});
const assistantButton = document.querySelector("#assistantButton");
+500 -76
View File
@@ -65,21 +65,13 @@ body.sidebar-collapsed .nav-group + .nav-group {
border-top: 1px solid var(--border);
}
@media (min-width: 721px) and (max-width: 1279px) {
@media (min-width: 768px) and (max-width: 1279px) {
.header-actions {
grid-column: 3;
}
}
@media (min-width: 721px) and (max-width: 1023px) {
.module-nav {
width: 64px;
padding-right: 7px;
padding-left: 7px;
}
@media (min-width: 768px) and (max-width: 1023px) {
.nav-group:first-of-type {
margin-top: 10px;
}
@@ -93,7 +85,7 @@ body.sidebar-collapsed .nav-group + .nav-group {
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.market-tape {
display: none;
}
@@ -139,6 +131,16 @@ body.sidebar-collapsed .nav-group + .nav-group {
.status-bar {
display: none;
}
body:is(
[data-active-view="screenerView"],
[data-active-view="screenerTrackingView"],
[data-active-view="mentorView"],
[data-active-view="heavenView"],
[data-active-view="reviewWorkspaceView"]
) .overview-strip {
display: none;
}
}
.market-breadth-panel .workspace-heading {
@@ -295,7 +297,7 @@ body.sidebar-collapsed .nav-group + .nav-group {
font-weight: 770;
}
@media (min-width: 721px) and (max-width: 1279px) {
@media (min-width: 768px) and (max-width: 1279px) {
body.sidebar-collapsed .module-nav {
width: 68px;
@@ -315,7 +317,7 @@ body.sidebar-collapsed .nav-group + .nav-group {
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.header-command-group {
position: fixed;
@@ -441,7 +443,7 @@ body.sidebar-collapsed .module-tab {
padding: 0px;
}
@media (min-width: 721px) and (max-width: 1023px) {
@media (min-width: 768px) and (max-width: 1023px) {
.module-tab span {
display: none;
}
@@ -457,7 +459,7 @@ body.sidebar-collapsed .module-tab {
}
}
@media (min-width: 721px) and (max-width: 1279px) {
@media (min-width: 768px) and (max-width: 1279px) {
.module-tab span {
display: none;
}
@@ -473,7 +475,7 @@ body.sidebar-collapsed .module-tab {
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.header-date-group .icon-button {
width: 30px;
@@ -535,7 +537,7 @@ body.sidebar-collapsed .module-tab {
border-right-color: var(--border);
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.metric {
gap: 2px;
@@ -557,7 +559,7 @@ body.sidebar-collapsed .module-tab {
font-size: 11px;
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.metric-label {
font-size: 10.5px;
}
@@ -695,7 +697,7 @@ body.sidebar-collapsed .app-main {
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
.module-nav .market-sub-tab {
display: none;
}
@@ -848,7 +850,7 @@ body.sidebar-collapsed .app-main {
.header-actions > .icon-button:hover {
border-color: transparent;
background: rgb(241, 243, 246);
background: var(--control-hover);
color: var(--primary);
}
@@ -880,7 +882,7 @@ body.sidebar-collapsed .app-main {
text-align: left;
color: rgb(75, 85, 101);
color: var(--text-secondary);
transition: color var(--duration-fast), background var(--duration-fast);
@@ -906,13 +908,13 @@ body.sidebar-collapsed .app-main {
stroke-width: 1.75;
color: rgb(136, 147, 164);
color: var(--text-tertiary);
}
.module-tab.active {
border: 0px;
background: var(--xb-blue-50);
background: var(--surface-selected);
color: var(--primary);
@@ -920,7 +922,7 @@ body.sidebar-collapsed .app-main {
position: relative;
font-weight: 650;
font-weight: var(--font-weight-semibold);
}
.module-tab.active::before {
@@ -1015,7 +1017,7 @@ body.sidebar-collapsed .app-main {
}
}
@media (min-width: 721px) and (max-width: 1279px) {
@media (min-width: 768px) and (max-width: 1279px) {
.app-header {
padding-left: 8px;
@@ -1033,7 +1035,7 @@ body.sidebar-collapsed .app-main {
}
}
@media (max-width: 720px) {
@media (max-width: 767px) {
body.sidebar-collapsed {
display: block;
@@ -1053,17 +1055,17 @@ body.sidebar-collapsed .app-main {
width: 100%;
height: 108px;
height: var(--mobile-header-height);
min-height: 108px;
min-height: var(--mobile-header-height);
position: relative;
display: flex;
align-items: flex-start;
align-items: center;
padding: 8px 10px 0px;
padding: var(--mobile-shell-pad) 10px;
}
.brand-mark {
@@ -1075,25 +1077,29 @@ body.sidebar-collapsed .app-main {
.header-actions {
grid-area: 1 / 2;
position: absolute;
position: static;
inset: 56px 10px auto;
inset: auto;
display: flex;
justify-content: space-between;
justify-content: flex-start;
gap: 6px;
gap: var(--space-4);
}
.header-date-group {
gap: 1px;
height: 42px;
height: 36px;
min-width: 0px;
flex: 1 1 0%;
flex: 1 1 auto;
}
.header-date-group > .icon-button {
display: none;
}
.header-date-group .date-input {
@@ -1101,17 +1107,17 @@ body.sidebar-collapsed .app-main {
font-size: 11.5px;
width: 104px;
width: 100%;
flex: 1 1 0%;
}
.header-actions > .icon-button {
width: 40px;
width: 36px;
min-width: 40px;
min-width: 36px;
min-height: 42px;
min-height: 36px;
}
.module-nav {
@@ -1354,7 +1360,7 @@ body.sidebar-collapsed {
background: var(--r2-blue);
color: rgb(255, 255, 255);
color: var(--on-action);
font-size: 14px;
@@ -1450,7 +1456,7 @@ body.sidebar-collapsed .module-nav .nav-group + .nav-group {
background: transparent;
color: rgb(55, 65, 81);
color: var(--text-secondary);
font-size: 13px;
@@ -1470,7 +1476,7 @@ body.sidebar-collapsed .module-nav .nav-group + .nav-group {
}
.module-nav .module-tab:hover {
background: rgb(246, 247, 249);
background: var(--surface-hover);
color: var(--r2-ink);
}
@@ -1492,11 +1498,11 @@ body.sidebar-collapsed .module-nav .nav-group + .nav-group {
}
.module-nav .module-tab.active::before {
display: none;
display: block;
}
.module-nav .module-tab.mobile-active::before {
display: none;
display: block;
}
body.sidebar-collapsed .module-nav {
@@ -1518,7 +1524,7 @@ body.sidebar-collapsed .module-nav {
border-right: 1px solid var(--r2-line);
background: rgb(255, 255, 255);
background: var(--surface);
width: 64px;
@@ -1566,7 +1572,7 @@ body.sidebar-collapsed .module-nav .module-tab {
background: transparent;
color: rgb(55, 65, 81);
color: var(--text-secondary);
font-size: 13px;
@@ -1632,7 +1638,7 @@ body.sidebar-collapsed .module-nav .module-tab {
border-radius: 7px;
background: rgb(255, 255, 255);
background: var(--control-surface);
}
.header-date-group .date-input {
@@ -1666,9 +1672,9 @@ body.sidebar-collapsed .module-nav .module-tab {
border-radius: 7px;
background: rgb(255, 255, 255);
background: var(--control-surface);
color: rgb(75, 85, 99);
color: var(--text-secondary);
box-shadow: none;
}
@@ -1684,9 +1690,9 @@ body.sidebar-collapsed .module-nav .module-tab {
border-radius: 7px;
background: rgb(255, 255, 255);
background: var(--control-surface);
color: rgb(75, 85, 99);
color: var(--text-secondary);
box-shadow: none;
@@ -1726,7 +1732,7 @@ body.sidebar-collapsed .module-nav .module-tab {
margin: 0px;
padding: 0px 16px 14px;
padding: 0 var(--page-pad-x) var(--page-pad-y);
overflow: auto;
}
@@ -1738,9 +1744,9 @@ body.sidebar-collapsed .module-nav .module-tab {
grid-template-columns: minmax(178px, 1.15fr) repeat(5, minmax(68px, 0.52fr)) minmax(168px, 0.9fr) 92px;
height: 32px;
height: var(--summary-height);
min-height: 34px;
min-height: var(--summary-height);
display: flex;
@@ -1764,7 +1770,7 @@ body.sidebar-collapsed .module-nav .module-tab {
border-radius: 0px;
background: rgb(255, 255, 255);
background: var(--surface);
box-shadow: none;
}
@@ -1832,7 +1838,7 @@ body.sidebar-collapsed .module-nav .module-tab {
}
.overview-strip .full .cell {
background: rgb(255, 255, 255);
background: var(--surface);
padding: 10px 16px;
}
@@ -1954,7 +1960,7 @@ body.sidebar-collapsed .module-nav .module-tab {
max-width: none;
margin: 14px 0px 0px;
margin: 0;
border-width: 0px;
@@ -1962,15 +1968,15 @@ body.sidebar-collapsed .module-nav .module-tab {
border-image: none;
padding: 14px 16px 24px;
padding: var(--page-pad-y) 0 24px;
border-color: var(--r2-line);
border-color: transparent;
border-radius: var(--r2-radius);
border-radius: 0;
background: rgb(255, 255, 255);
background: transparent;
box-shadow: var(--r2-shadow);
box-shadow: none;
}
.status-bar {
@@ -2006,7 +2012,7 @@ body.sidebar-collapsed .module-nav .module-tab {
border-top: 1px solid var(--line);
background: rgb(255, 255, 255);
background: var(--surface);
color: var(--faint);
@@ -2051,7 +2057,7 @@ body.sidebar-collapsed .status-bar {
}
}
@media (max-width: 1023px) and (min-width: 721px) {
@media (max-width: 1023px) and (min-width: 768px) {
.module-nav {
width: 64px;
@@ -2085,7 +2091,7 @@ body.sidebar-collapsed .status-bar {
}
}
@media (max-width: 720px), (max-width: 1023px) and (max-height: 600px) {
@media (max-width: 767px), (max-width: 1023px) and (max-height: 600px) {
.redesigned-page-head .section-title-group {
width: 100%;
@@ -2097,7 +2103,7 @@ body.sidebar-collapsed .status-bar {
}
}
@media (min-width: 721px) {
@media (min-width: 768px) {
.app-main {
min-height: 0px;
@@ -2109,9 +2115,9 @@ body.sidebar-collapsed .status-bar {
.workspace-view:is(#auctionView, #themeLibraryView, #popularityView, #dragonView) {
width: auto;
margin: 14px 0px 0px;
margin: 0;
padding: 14px 16px 22px;
padding: var(--page-pad-y) var(--page-pad-x) 22px;
overflow: visible;
@@ -2152,7 +2158,7 @@ body.sidebar-collapsed .status-bar {
width: var(--sidebar-width);
background: rgb(255, 255, 255);
background: var(--surface);
border-right: 1px solid var(--line);
@@ -2184,7 +2190,7 @@ body.sidebar-collapsed .status-bar {
background: var(--blue);
color: rgb(255, 255, 255);
color: var(--text-inverse);
display: flex;
@@ -2282,7 +2288,7 @@ body.sidebar-collapsed .status-bar {
z-index: 50;
background: rgb(255, 255, 255);
background: var(--surface);
border-bottom: 1px solid var(--line);
@@ -2393,7 +2399,7 @@ body.sidebar-collapsed .status-bar {
background: transparent;
}
@media (min-width: 721px) {
@media (min-width: 768px) {
body:is([data-active-view="sentimentCycleView"], [data-active-view="yesterdayView"]) .app-main {
height: var(--workspace-height);
@@ -2445,7 +2451,7 @@ body.sidebar-collapsed .status-bar {
}
}
@media (max-width: 720px), (max-width: 1023px) and (max-height: 600px) {
@media (max-width: 767px), (max-width: 1023px) and (max-height: 600px) {
body.sidebar-collapsed {
display: block;
@@ -2481,7 +2487,17 @@ body.sidebar-collapsed .status-bar {
}
.header-command-group {
position: absolute;
position: fixed;
top: calc(var(--mobile-header-height) + var(--space-4));
right: var(--mobile-shell-pad);
display: none;
}
.header-command-group.is-open {
display: flex;
}
.module-nav {
@@ -2620,6 +2636,11 @@ body.sidebar-collapsed .status-bar {
display: inline;
}
.module-nav .module-tab.active::before,
.module-nav .module-tab.mobile-active::before {
display: none;
}
.module-nav .module-tab.mobile-primary-tab .nav-label-desktop {
display: none;
}
@@ -2664,6 +2685,12 @@ body.sidebar-collapsed .status-bar {
padding-inline: var(--mobile-shell-pad);
overflow-x: auto;
scrollbar-width: none;
}
.overview-strip::-webkit-scrollbar {
display: none;
}
.overview-strip .row {
@@ -2783,7 +2810,7 @@ body.sidebar-collapsed .status-bar {
color: var(--text-primary);
}
@media (min-width: 721px) {
@media (min-width: 768px) {
.header-actions > .icon-button {
min-width: 31px;
}
@@ -2798,3 +2825,400 @@ body.sidebar-collapsed .status-bar {
align-items: center;
}
}
@media (min-width: 768px) and (max-width: 1023px) {
.main,
body.sidebar-collapsed .main {
width: calc(100% - var(--sidebar-compact-width));
min-width: 0;
margin-left: var(--sidebar-compact-width);
}
.module-nav,
body.sidebar-collapsed .module-nav {
width: var(--sidebar-compact-width);
padding-right: 7px;
padding-left: 7px;
}
.status-bar,
body.sidebar-collapsed .status-bar {
left: var(--sidebar-compact-width);
}
}
/* Canonical mobile shell. Page-specific responsive rules remain with each page. */
.mobile-page-context,
.mobile-command-shortcuts,
.mobile-command-backdrop {
display: none;
}
@media (max-width: 767px) {
.mobile-shell,
.mobile-shell.sidebar-collapsed {
display: block;
min-height: 100dvh;
padding-bottom: var(--mobile-content-bottom);
}
body.mobile-shell .main,
body.mobile-shell.sidebar-collapsed .main {
width: 100%;
min-width: 0;
min-height: 100dvh;
margin-left: 0;
}
body.mobile-shell .app-header {
position: sticky;
top: 0;
z-index: var(--mobile-layer-header);
width: 100%;
height: var(--mobile-header-height);
min-height: var(--mobile-header-height);
display: flex;
align-items: center;
gap: var(--space-8);
padding: var(--space-4) var(--mobile-shell-pad);
border-bottom: 1px solid var(--border);
background: var(--surface);
box-shadow: none;
}
body.mobile-shell.mobile-command-open .app-header {
z-index: var(--mobile-layer-menu);
}
body.mobile-shell .mobile-page-context {
min-width: 0;
display: flex;
flex: 1 1 auto;
flex-direction: column;
justify-content: center;
gap: 0;
}
body.mobile-shell .mobile-page-context span {
color: var(--text-tertiary);
font-size: var(--font-size-aux);
line-height: 1.2;
}
body.mobile-shell .mobile-page-context strong {
overflow: hidden;
color: var(--text-primary);
font-size: var(--font-size-body);
font-weight: var(--font-weight-bold);
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
body.mobile-shell .market-tape,
body.mobile-shell .header-actions > :is(.global-search-button, .theme-toggle, .alert-button, .assistant-button) {
display: none;
}
body.mobile-shell .header-actions {
position: static;
width: auto;
min-width: 0;
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-4);
margin: 0;
}
body.mobile-shell .header-date-group {
width: var(--mobile-date-width);
height: var(--mobile-touch-size);
min-height: var(--mobile-touch-size);
padding: 0 var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--control-surface);
}
body.mobile-shell .header-date-group > .icon-button {
display: none;
}
body.mobile-shell .header-date-group .date-input {
width: 100%;
height: calc(var(--mobile-touch-size) - 2px);
min-width: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--text-primary);
font-size: var(--font-size-caption);
font-weight: var(--font-weight-semibold);
}
body.mobile-shell .app-header .header-actions > .header-menu-button {
width: var(--mobile-touch-size);
min-width: var(--mobile-touch-size);
height: var(--mobile-touch-size);
min-height: var(--mobile-touch-size);
display: grid;
place-items: center;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--control-surface);
}
body.mobile-shell .header-command-group {
position: fixed;
inset: auto var(--mobile-shell-pad) var(--mobile-content-bottom);
z-index: var(--mobile-layer-menu);
width: auto;
max-height: calc(100dvh - var(--mobile-header-height) - var(--mobile-content-bottom) - var(--space-16));
display: none;
grid-template-columns: 1fr;
align-items: stretch;
gap: var(--space-8);
padding: var(--space-12);
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface-overlay);
box-shadow: var(--shadow-float);
}
body.mobile-shell .header-command-group.is-open {
display: grid;
}
body.mobile-shell .mobile-command-shortcuts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-8);
padding-bottom: var(--space-8);
border-bottom: 1px solid var(--border-subtle);
}
body.mobile-shell .header-command-group .mobile-command-shortcuts > .mobile-command-shortcut {
min-width: 0;
min-height: var(--mobile-touch-size);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-8) var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-subtle);
color: var(--text-secondary);
font-size: var(--font-size-caption);
}
body.mobile-shell .header-command-group > .command-button,
body.mobile-shell .header-command-group .account-button {
width: 100%;
min-height: var(--mobile-touch-size);
justify-content: flex-start;
}
body.mobile-shell .header-command-group .account-dropdown {
position: static;
grid-column: 1 / -1;
width: 100%;
margin-top: var(--space-8);
}
body.mobile-shell .mobile-command-backdrop:not([hidden]) {
position: fixed;
inset: 0;
z-index: var(--mobile-layer-backdrop);
display: block;
width: 100%;
height: 100%;
border: 0;
background: var(--backdrop);
}
body.mobile-shell .module-nav,
body.mobile-shell.sidebar-collapsed .module-nav {
position: fixed;
inset: auto 0 0;
z-index: var(--mobile-layer-nav);
width: 100%;
height: calc(var(--mobile-nav-height) + var(--mobile-safe-bottom));
min-height: calc(var(--mobile-nav-height) + var(--mobile-safe-bottom));
max-height: calc(var(--mobile-nav-height) + var(--mobile-safe-bottom));
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
align-items: start;
gap: 0;
padding: var(--space-4) var(--space-4) max(var(--space-4), var(--mobile-safe-bottom));
overflow: hidden;
border: 0;
border-top: 1px solid var(--border);
background: var(--surface-overlay);
box-shadow: var(--elevation-raised);
}
body.mobile-shell .module-nav :is(.sidebar-brand, .nav-group-label, .market-sub-tab, .sidebar-collapse-button),
body.mobile-shell.sidebar-collapsed .module-nav :is(.sidebar-brand, .nav-group-label, .market-sub-tab, .sidebar-collapse-button) {
display: none;
}
body.mobile-shell .module-nav .nav-group,
body.mobile-shell.sidebar-collapsed .module-nav .nav-group {
display: contents;
margin: 0;
padding: 0;
border: 0;
}
body.mobile-shell .module-nav .module-tab,
body.mobile-shell.sidebar-collapsed .module-nav .module-tab {
display: none;
}
body.mobile-shell .module-nav .module-tab.mobile-primary-tab,
body.mobile-shell.sidebar-collapsed .module-nav .module-tab.mobile-primary-tab {
position: relative;
min-width: 0;
min-height: var(--mobile-tab-height);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: var(--space-4);
margin: 0;
padding: var(--space-4);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--text-tertiary);
font-size: var(--font-size-aux);
font-weight: var(--font-weight-medium);
}
body.mobile-shell .module-nav .module-tab.mobile-primary-tab .lucide {
width: var(--mobile-nav-icon-size);
height: var(--mobile-nav-icon-size);
}
body.mobile-shell .module-nav .module-tab.mobile-primary-tab:is(.active, .mobile-active) {
background: var(--action-soft);
color: var(--action);
font-weight: var(--font-weight-semibold);
}
body.mobile-shell .module-nav .module-tab.mobile-primary-tab span,
body.mobile-shell.sidebar-collapsed .module-nav .module-tab.mobile-primary-tab span {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
body.mobile-shell .module-nav .module-tab.mobile-primary-tab .nav-label-desktop {
display: none;
}
body.mobile-shell .module-nav .module-tab.mobile-primary-tab .nav-label-mobile {
display: block;
}
body.mobile-shell .app-main,
body.mobile-shell.sidebar-collapsed .app-main,
body.mobile-shell:is([data-active-view]) .app-main {
width: 100%;
height: auto;
min-height: calc(100dvh - var(--mobile-header-height) - var(--mobile-content-bottom));
display: block;
margin: 0;
padding: 0 var(--mobile-page-pad) var(--mobile-page-pad);
overflow: visible;
background: var(--surface-canvas);
}
body.mobile-shell .overview-strip,
body.mobile-shell .overview-strip[data-overview-expanded="true"] {
width: calc(100% + var(--mobile-page-pad) + var(--mobile-page-pad));
height: var(--mobile-tab-height);
min-height: var(--mobile-tab-height);
display: flex;
margin: 0 calc(var(--mobile-page-pad) * -1);
padding: 0 var(--mobile-page-pad);
overflow-x: auto;
overflow-y: hidden;
border-bottom: 1px solid var(--border);
background: var(--surface);
scrollbar-width: none;
}
body.mobile-shell .overview-strip::-webkit-scrollbar {
display: none;
}
body.mobile-shell .overview-strip .row {
width: max-content;
min-width: 100%;
display: flex;
align-items: center;
gap: var(--space-8);
padding: 0;
}
body.mobile-shell .overview-strip .sentiment-block,
body.mobile-shell .overview-strip .metric,
body.mobile-shell .overview-strip[data-overview-expanded="true"] .metric {
min-width: max-content;
min-height: var(--mobile-touch-size);
display: flex;
flex: 0 0 auto;
flex-direction: row;
align-items: center;
gap: var(--space-4);
padding: 0 var(--space-8);
border: 0;
background: transparent;
}
body.mobile-shell .overview-strip .metric:nth-of-type(n) {
display: flex;
}
body.mobile-shell .overview-strip .metric-wide,
body.mobile-shell .overview-toggle {
display: none;
}
body.mobile-shell .workspace-view,
body.mobile-shell .workspace-view.page:not(#heavenView),
body.mobile-shell:is([data-active-view]) .workspace-view.active-view {
width: 100%;
height: auto;
min-height: 0;
margin: 0;
padding: var(--mobile-page-pad) 0 0;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
body.mobile-shell .status-bar {
display: none;
}
}
@media (max-width: 359px) {
.header-date-group {
width: calc(var(--mobile-date-width) - var(--space-16));
}
.mobile-page-context span {
display: none;
}
}
+41 -3
View File
@@ -2,25 +2,42 @@
"use strict";
const SIDEBAR_STORAGE_KEY = "xiaobai-sidebar-collapsed";
const MOBILE_BREAKPOINT = 767;
function isMobileViewport() {
return global.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches;
}
function create(options) {
const state = options.state;
const registry = options.pages;
let initialized = false;
function syncViewportMode() {
document.body.classList.toggle("mobile-shell", isMobileViewport());
}
function toggleHeaderCommandMenu(force) {
const menu = document.querySelector("#headerCommandGroup");
const button = document.querySelector("#headerMenuButton");
const backdrop = document.querySelector("#mobileCommandBackdrop");
if (!menu || !button) return;
const open = typeof force === "boolean" ? force : !menu.classList.contains("is-open");
menu.classList.toggle("is-open", open);
button.setAttribute("aria-expanded", String(open));
document.body.classList.toggle("mobile-command-open", open && isMobileViewport());
if (backdrop) backdrop.hidden = !(open && isMobileViewport());
if (open && isMobileViewport()) {
global.requestAnimationFrame(() => menu.querySelector("button:not([hidden])")?.focus({ preventScroll: true }));
} else if (force === false && document.activeElement && menu.contains(document.activeElement)) {
button.focus({ preventScroll: true });
}
}
function updateSidebarControl() {
const button = document.querySelector("#sidebarCollapseButton");
if (!button) return;
const automaticallyCollapsed = global.innerWidth <= 1023 && global.innerWidth > 720;
const automaticallyCollapsed = global.innerWidth <= 1023 && !isMobileViewport();
const collapsed = document.body.classList.contains("sidebar-collapsed") || automaticallyCollapsed;
button.setAttribute("aria-expanded", String(!collapsed));
button.setAttribute("aria-label", collapsed ? "展开侧栏" : "收起侧栏");
@@ -44,11 +61,19 @@
const navigationId = page?.navigation_alias || viewId;
const marketView = page?.group === "market";
document.body.dataset.activeView = viewId;
const mobileGroup = document.querySelector("#mobilePageGroup");
const mobileTitle = document.querySelector("#mobilePageTitle");
if (mobileGroup) {
mobileGroup.textContent = page?.group === "market"
? "行情"
: page?.group === "personal" ? "复盘" : "工具";
}
if (mobileTitle) mobileTitle.textContent = page?.title || "小白复盘";
document.querySelectorAll(".module-tab").forEach((button) => {
button.classList.toggle("active", button.dataset.view === navigationId);
button.classList.toggle(
"mobile-active",
global.innerWidth <= 720
isMobileViewport()
&& marketView
&& button.dataset.view === "limitPool"
&& viewId !== "limitPool",
@@ -127,6 +152,7 @@
collapsed = false;
}
document.body.classList.toggle("sidebar-collapsed", collapsed);
syncViewportMode();
updateSidebarControl();
syncNavigation(state.activeView);
document.querySelectorAll(".module-tab").forEach((button) => {
@@ -143,6 +169,16 @@
event.stopPropagation();
toggleHeaderCommandMenu();
});
document.querySelector("#mobileCommandBackdrop")?.addEventListener("click", () => {
toggleHeaderCommandMenu(false);
});
document.querySelectorAll("[data-mobile-command-target]").forEach((button) => {
button.addEventListener("click", () => {
const target = document.getElementById(button.dataset.mobileCommandTarget || "");
toggleHeaderCommandMenu(false);
target?.click();
});
});
document.querySelector("#headerCommandGroup")?.addEventListener("click", (event) => {
if (event.target.closest("button") && !event.target.closest(".account-menu-shell")) {
toggleHeaderCommandMenu(false);
@@ -170,7 +206,8 @@
if (event.key === "Escape") toggleHeaderCommandMenu(false);
});
global.addEventListener("resize", () => {
if (global.innerWidth > 720) toggleHeaderCommandMenu(false);
toggleHeaderCommandMenu(false);
syncViewportMode();
updateSidebarControl();
syncNavigation(state.activeView);
});
@@ -184,6 +221,7 @@
setPageStatus,
setStatus,
syncNavigation,
syncViewportMode,
toggleHeaderCommandMenu,
toggleSidebar,
updateSidebarControl,
+100 -33
View File
@@ -14,38 +14,38 @@
--color-white: #ffffff;
--color-gray-25: #fcfcfd;
--color-gray-50: #f8f9fb;
--color-gray-100: #f2f4f7;
--color-gray-200: #e5e8ee;
--color-gray-300: #d4d9e2;
--color-gray-500: #697386;
--color-gray-700: #344054;
--color-gray-900: #172033;
--color-shell-canvas: #f1f4f6;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-300: #d1d5db;
--color-gray-500: #6b7280;
--color-gray-700: #374151;
--color-gray-900: #1f2937;
--color-shell-canvas: #f4f5f7;
--color-page-canvas: #f4f5f7;
--color-surface-muted: #f6f8fa;
--color-surface-muted: #f3f4f6;
--color-surface-subtle: #f8fafc;
--color-border: #e5e7eb;
--color-border-strong: #d1d5db;
--color-text-primary: #1f2937;
--color-text-secondary: #6b7280;
--color-text-tertiary: #9ca3af;
--color-action-base: #1769c2;
--color-action-base-hover: #10569f;
--color-action-base-soft: #eaf2fb;
--color-action-base: #2563eb;
--color-action-base-hover: #1d4ed8;
--color-action-base-soft: #eff4ff;
--color-action: #2563eb;
--color-action-hover: #1d4ed8;
--color-action-soft: #eff4ff;
--color-action-line: #c7d8fb;
--color-market-up-base: #d33f49;
--color-market-up-base-soft: #fff0f1;
--color-market-up-base: #e04536;
--color-market-up-base-soft: #fdecea;
--color-market-up: #e04536;
--color-market-up-soft: #fdecea;
--color-market-down-base: #07805b;
--color-market-down-base-soft: #eaf7f2;
--color-market-down-base: #16a34a;
--color-market-down-base-soft: #e9f7ee;
--color-market-down: #16a34a;
--color-market-down-soft: #e9f7ee;
--color-warning-base: #aa6800;
--color-warning-base-soft: #fff6e5;
--color-warning-base: #b45309;
--color-warning-base-soft: #fdf3e3;
--color-warning: #b45309;
--color-warning-soft: #fdf3e3;
@@ -55,16 +55,16 @@
--size-control: 32px;
--size-sidebar: 200px;
--size-topbar: 46px;
--size-summary: 34px;
--size-summary: 36px;
--size-statusbar: 30px;
--size-page-pad-y: 14px;
--size-page-pad-x: 16px;
--size-card-gap: 12px;
--elevation-card: 0 1px 2px rgba(16, 24, 40, .05);
--elevation-soft: 0 1px 2px rgba(22, 34, 46, .04), 0 5px 18px rgba(22, 34, 46, .035);
--elevation-soft: 0 1px 2px rgba(16, 24, 40, .05);
--elevation-raised: 0 4px 14px rgba(16, 24, 40, .06);
--elevation-float: 0 14px 38px rgba(16, 24, 40, .14);
--elevation-float: 0 12px 32px rgba(0, 0, 0, .18);
--motion-instant: 100ms;
--motion-fast: 140ms;
--motion-medium: 200ms;
@@ -79,12 +79,18 @@
--surface-subtle: var(--color-surface-subtle);
--surface-canvas: var(--color-page-canvas);
--surface-raised: var(--color-white);
--surface-selected: #eef4ff;
--surface-sunken: #eef0f3;
--surface-hover: #f8faff;
--surface-selected: #eff4ff;
--surface-overlay: var(--color-white);
--border: var(--color-border);
--border-strong: var(--color-border-strong);
--border-subtle: #eef0f3;
--text-primary: var(--color-text-primary);
--text-secondary: var(--color-text-secondary);
--text-tertiary: var(--color-text-tertiary);
--text-inverse: #ffffff;
--on-action: #ffffff;
--action: var(--color-action-base);
--action-hover: var(--color-action-base-hover);
--action-soft: var(--color-action-base-soft);
@@ -94,6 +100,14 @@
--market-down-soft: var(--color-market-down-base-soft);
--warning-color: var(--color-warning-base);
--warning-soft: var(--color-warning-base-soft);
--control-surface: var(--surface-raised);
--control-hover: var(--surface-hover);
--control-border: var(--border-strong);
--table-header: var(--surface-subtle);
--table-hover: #f8faff;
--table-selected: var(--surface-selected);
--focus-ring: rgba(37, 99, 235, .16);
--backdrop: rgba(17, 24, 39, .48);
--primary: var(--color-action);
--primary-hover: var(--color-action-hover);
--danger: var(--color-market-up);
@@ -116,7 +130,27 @@
--duration-fast: 150ms;
--duration-normal: 220ms;
--font-size-aux: 10.5px;
--font-size-caption: 11px;
--font-size-label: 12px;
--font-size-table: 12.5px;
--font-size-body: 13px;
--font-size-card-title: 14px;
--font-size-page-title: 17px;
--font-size-metric: 22px;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--space-4: 4px;
--space-8: 8px;
--space-12: 12px;
--space-16: 16px;
--space-24: 24px;
--sidebar-width: var(--size-sidebar);
--sidebar-compact-width: 64px;
--topbar-height: var(--size-topbar);
--summary-height: var(--size-summary);
--statusbar-height: var(--size-statusbar);
@@ -140,14 +174,26 @@
--sentiment-history-min-height: 220px;
--primary-share: 1.45fr;
--secondary-share: .75fr;
--mobile-nav-height: 58px;
--mobile-header-height: 50px;
--mobile-tab-height: 54px;
--mobile-shell-pad: 8px;
--mobile-page-pad: 10px;
--mobile-nav-height: 64px;
--mobile-header-height: 52px;
--mobile-tab-height: 56px;
--mobile-touch-size: 44px;
--mobile-date-width: 116px;
--mobile-nav-icon-size: 20px;
--mobile-chart-height: 240px;
--mobile-table-min-height: 260px;
--mobile-phase-badge-width: 88px;
--mobile-summary-card-width: 124px;
--mobile-shell-pad: var(--space-8);
--mobile-page-pad: var(--space-12);
--mobile-min-width: 320px;
--space-4: 4px;
--font-aux: 10.5px;
--mobile-safe-bottom: env(safe-area-inset-bottom, 0px);
--mobile-content-bottom: calc(var(--mobile-nav-height) + var(--mobile-safe-bottom) + var(--mobile-page-pad));
--mobile-layer-header: 50;
--mobile-layer-nav: 70;
--mobile-layer-menu: 80;
--mobile-layer-backdrop: 75;
--font-aux: var(--font-size-aux);
--dragon-profile-list-width: 340px;
--dragon-profile-detail-min-height: 460px;
@@ -173,6 +219,14 @@
--dragon-profile-weight-strong: 750;
--dragon-profile-weight-semibold: 600;
--mentor-directory-width: 280px;
--mentor-profile-width: 272px;
--mentor-pane-header-height: 58px;
--mentor-avatar-size: 38px;
--mentor-profile-avatar-size: 68px;
--mentor-composer-min-height: 94px;
--mentor-message-max-width: 82%;
--chart-background: #fbfcfd;
--chart-grid: #e2e8ec;
--chart-axis: #6c7983;
@@ -216,7 +270,7 @@
--sub: var(--text-secondary);
--faint: var(--text-tertiary);
--line: var(--border);
--line-soft: #eef0f3;
--line-soft: var(--border-subtle);
--line-strong: var(--border-strong);
--text: var(--text-primary);
--text-muted: var(--text-secondary);
@@ -253,7 +307,7 @@
--r2-sub: var(--text-secondary);
--r2-faint: var(--text-tertiary);
--r2-line: var(--border);
--r2-line-soft: #eef0f3;
--r2-line-soft: var(--border-subtle);
--r2-bg: var(--surface-canvas);
--r2-card: var(--surface-raised);
--r2-radius: var(--size-radius-lg);
@@ -271,12 +325,17 @@
--surface-subtle: #24282d;
--surface-canvas: var(--canvas);
--surface-raised: var(--surface);
--surface-sunken: #16191c;
--surface-hover: #24282d;
--surface-selected: #23364a;
--surface-overlay: #24282d;
--border: #343a40;
--border-strong: #474f57;
--border-subtle: #2a2f34;
--text-primary: #e8eaed;
--text-secondary: #adb5bd;
--text-tertiary: #7f8993;
--text-inverse: #ffffff;
--action: #6ca8e8;
--action-hover: #8bbcf0;
--action-soft: #23364a;
@@ -286,6 +345,14 @@
--market-down-soft: #1d382f;
--warning-color: #e2ad58;
--warning-soft: #3d3220;
--control-surface: #202428;
--control-hover: #2a2f34;
--control-border: #474f57;
--table-header: #202428;
--table-hover: #242f39;
--table-selected: #23364a;
--focus-ring: rgba(108, 168, 232, .22);
--backdrop: rgba(0, 0, 0, .66);
--primary: var(--action);
--primary-hover: var(--action-hover);
--danger: var(--market-up);
@@ -319,7 +386,7 @@
--sub: var(--text-secondary);
--faint: var(--text-tertiary);
--line: var(--border);
--line-soft: #2a2f34;
--line-soft: var(--border-subtle);
--line-strong: var(--border-strong);
--text: var(--text-primary);
--text-muted: var(--text-secondary);
@@ -352,7 +419,7 @@
--r2-sub: var(--text-secondary);
--r2-faint: var(--text-tertiary);
--r2-line: var(--border);
--r2-line-soft: #2a2f34;
--r2-line-soft: var(--border-subtle);
--r2-bg: var(--canvas);
--r2-card: var(--surface);
--r2-shadow: var(--shadow-soft);
@@ -384,7 +451,7 @@
--warning-line: #6d5a38;
--warning-line-strong: #66502d;
--control-shadow: 0 1px 3px rgba(0, 0, 0, .3);
--dialog-backdrop: rgba(0, 0, 0, .62);
--dialog-backdrop: var(--backdrop);
--ladder-level-1: #2d2426;
--ladder-level-2: #2b2822;
--ladder-level-3: #252a2d;
+296 -31
View File
@@ -415,6 +415,15 @@ async function mockApplication(page, authSession = session(), options = {}) {
if (options.recentScreenerResults) {
payload.recent_results = options.recentScreenerResults;
}
if (options.screenerPublishedRuns) {
for (const strategy of payload.strategies) {
const publishedRun = options.screenerPublishedRuns[strategy.name];
if (publishedRun) strategy.published_run = publishedRun;
}
}
if (options.screenerSetupOverride) {
payload = { ...payload, ...options.screenerSetupOverride };
}
} else if (url.pathname === "/api/screener/run") {
const body = route.request().postDataJSON();
options.screenerRunBodies = [...(options.screenerRunBodies || []), body];
@@ -482,7 +491,12 @@ async function mockApplication(page, authSession = session(), options = {}) {
body: [
JSON.stringify({ type: "delta", content: "## 判断\n先看市场结构。\n\n" }),
JSON.stringify({ type: "delta", content: "- 等待确认\n- 控制仓位" }),
JSON.stringify({ type: "meta", data_trade_date: "20260722", notice: "" }),
JSON.stringify({
type: "meta",
data_trade_date: "20260722",
notice: "",
follow_ups: ["哪些信号代表确认?", "这个判断在什么情况下失效?", "空仓时应该先观察什么?"],
}),
JSON.stringify({ type: "done" }),
].join("\n"),
});
@@ -1781,6 +1795,7 @@ test("heart breathing prepares once then contracts on each exhale", async ({ pag
await page.goto("/index.html");
await page.locator('[data-view="heavenView"]').first().click();
await page.locator('[data-heaven-panel="heart"]').click();
await page.locator('[data-heart-question-preset="unthemed"]').click();
await page.evaluate(() => startHeartBreathing());
const timing = await page.evaluate(() => ({
@@ -1831,6 +1846,58 @@ test("heart breathing prepares once then contracts on each exhale", async ({ pag
await expect(page.locator(".heart-breath-ripple span").first()).toHaveCSS("transition-duration", "4s");
});
test("heart question presets stay editable and lock only during the ritual", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.locator('[data-view="heavenView"]').first().click();
await page.locator('[data-heaven-panel="heart"]').click();
const input = page.locator("#heartQuestionInput");
const start = page.locator("#startBreathingButton");
const trade = page.locator('[data-heart-question-preset="trade"]');
const mind = page.locator('[data-heart-question-preset="mind"]');
const unthemed = page.locator('[data-heart-question-preset="unthemed"]');
await expect(input).toHaveValue("");
await expect(start).toBeDisabled();
await expect(trade).toHaveAttribute("aria-pressed", "false");
await expect(mind).toHaveAttribute("aria-pressed", "false");
await expect(unthemed).toHaveAttribute("aria-pressed", "false");
await trade.click();
await expect(input).toHaveValue("关于我心中的这笔交易,此刻最需要看清的机会、阻碍与风险是什么?");
await expect(trade).toHaveAttribute("aria-pressed", "true");
await mind.click();
await expect(input).toHaveValue("此刻影响我交易判断的情绪、执念或盲点是什么?");
await expect(mind).toHaveAttribute("aria-pressed", "true");
await unthemed.click();
await expect(input).toHaveValue("不设具体问题,只观此刻一念。");
await expect(unthemed).toHaveAttribute("aria-pressed", "true");
await input.fill("我是否因为害怕错过而忽略了这笔交易的退出条件?");
await expect(start).toBeEnabled();
await expect(trade).toHaveAttribute("aria-pressed", "false");
await expect(mind).toHaveAttribute("aria-pressed", "false");
await expect(unthemed).toHaveAttribute("aria-pressed", "false");
expect(await page.evaluate(() => ({
question: state.heartQuestion,
preset: state.heartQuestionPreset,
}))).toEqual({
question: "我是否因为害怕错过而忽略了这笔交易的退出条件?",
preset: "custom",
});
await start.click();
await expect(page.locator("#heartBreathing")).toHaveClass(/active-heart-stage/);
await expect(input).toBeDisabled();
await expect(trade).toBeDisabled();
await page.locator("#heartBreathing [data-heart-return]").click();
await expect(page.locator("#heartIntro")).toHaveClass(/active-heart-stage/);
await expect(input).toBeEnabled();
await expect(input).toHaveValue("我是否因为害怕错过而忽略了这笔交易的退出条件?");
await expect(trade).toBeEnabled();
});
test("stylesheet layers do not repeat identical rules in the same cascade context", async ({ page }) => {
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
@@ -1869,8 +1936,19 @@ test("mobile shell stays within the viewport", async ({ page }) => {
await page.goto("/index.html");
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(1);
await expect(page.locator("#globalSearchButton")).toBeVisible();
await expect(page.locator("#globalSearchButton")).toBeHidden();
await expect(page.locator("#headerMenuButton")).toBeVisible();
await page.locator("#headerMenuButton").click();
await expect(page.locator("#headerCommandGroup")).toBeVisible();
await expect(page.locator('[data-mobile-command-target="globalSearchButton"]')).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.locator("#headerCommandGroup")).toBeHidden();
await expect(page.locator(".module-nav .mobile-primary-tab:visible")).toHaveCount(5);
await expect(page.locator('[data-view="limitPool"]')).toHaveClass(/mobile-active/);
for (const entry of await page.locator(".module-nav .mobile-primary-tab:visible").all()) {
const box = await entry.boundingBox();
expect(box.height).toBeGreaterThanOrEqual(44);
}
const mobileShell = await page.evaluate(() => {
const header = document.querySelector(".app-header").getBoundingClientRect();
const main = document.querySelector(".app-main").getBoundingClientRect();
@@ -1879,6 +1957,69 @@ test("mobile shell stays within the viewport", async ({ page }) => {
expect(mobileShell.mainTop).toBeGreaterThanOrEqual(mobileShell.headerBottom - 1);
});
test("mobile shell remains usable at supported narrow widths", async ({ page }) => {
await page.setViewportSize({ width: 430, height: 932 });
await mockApplication(page, session("user", true));
await page.goto("/index.html");
const registeredViews = await page.evaluate(() => window.XiaobaiPages.pages.map((entry) => entry.id));
for (const viewport of [
{ width: 430, height: 932 },
{ width: 390, height: 844 },
{ width: 320, height: 720 },
]) {
await page.setViewportSize(viewport);
await expect(page.locator("body")).toHaveClass(/mobile-shell/);
for (const theme of ["light", "dark"]) {
for (const view of registeredViews) {
await page.evaluate(({ targetView, targetTheme }) => {
document.documentElement.dataset.theme = targetTheme;
openView(targetView);
}, { targetView: view, targetTheme: theme });
const geometry = await page.evaluate(() => ({
overflow: document.documentElement.scrollWidth - innerWidth,
visibleEntries: Array.from(document.querySelectorAll(".module-nav .mobile-primary-tab"))
.filter((entry) => getComputedStyle(entry).display !== "none").length,
smallestTarget: Math.min(...Array.from(document.querySelectorAll(".module-nav .mobile-primary-tab"))
.filter((entry) => getComputedStyle(entry).display !== "none")
.map((entry) => entry.getBoundingClientRect().height)),
}));
expect(geometry.overflow, `${view} overflows at ${viewport.width}px in ${theme} theme`).toBeLessThanOrEqual(1);
expect(geometry.visibleEntries).toBe(5);
expect(geometry.smallestTarget).toBeGreaterThanOrEqual(44);
}
}
await page.evaluate(() => openView("ladderView"));
await expect(page.locator("#mobileMarketSelector")).toBeVisible();
await expect(page.locator("#mobileMarketViewSelect option")).toHaveCount(12);
expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
}
});
test("768px boundary uses the compact desktop shell", async ({ page }) => {
await page.setViewportSize({ width: 768, height: 900 });
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.evaluate(() => openView("sentimentCycleView"));
await expect(page.locator("body")).not.toHaveClass(/mobile-shell/);
const geometry = await page.evaluate(() => {
const nav = document.querySelector(".module-nav").getBoundingClientRect();
const main = document.querySelector(".main").getBoundingClientRect();
return {
overflow: document.documentElement.scrollWidth - innerWidth,
navWidth: nav.width,
navRight: nav.right,
mainLeft: main.left,
mainRight: main.right,
};
});
expect(geometry.overflow).toBeLessThanOrEqual(1);
expect(geometry.navWidth).toBeGreaterThanOrEqual(44);
expect(geometry.navWidth).toBeLessThan(200);
expect(Math.abs(geometry.mainLeft - geometry.navRight)).toBeLessThanOrEqual(1);
expect(geometry.mainRight).toBeLessThanOrEqual(768);
});
test("native dialogs share one lifecycle and success feedback stays content-sized", async ({ page }) => {
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
@@ -2247,6 +2388,9 @@ test("screener redesign preserves three clear workspaces across desktop and mobi
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(1);
await expect(page.locator("#screenerView .screener-mode-tabs")).toBeVisible();
await page.locator('[data-screener-mobile-view="results"]').click();
await expect(page.locator("#screenerView .screener-results-view")).toBeVisible();
await expect(page.locator("#screenerEmpty")).toBeVisible();
});
test("automatic screener results stay read-only and mode results stay isolated", async ({ page }) => {
@@ -2283,7 +2427,7 @@ test("automatic screener results stay read-only and mode results stay isolated",
await expect(page.locator("#changeStrategyButton")).toHaveCount(0);
await expect(page.locator("#editStrategyButton")).toHaveCount(0);
await page.locator('[data-screener-mode="curated"]').click();
await expect(page.locator("#screenerEmpty")).toContainText("所选策略");
await expect(page.locator("#screenerEmpty")).toContainText("尚无成功发布");
await page.evaluate(() => {
setScreenerResult("curated", {
meta: { run_id: 52, trade_date: "20260722", regime: "repair", strategy_name: "连续分红质量" },
@@ -2313,6 +2457,119 @@ test("automatic screener results stay read-only and mode results stay isolated",
await expect(page.locator("#screenerTableBody")).not.toContainText("量化结果");
});
test("screener publishes atomically and exposes latest, active, and historical candidates", async ({ page }) => {
const candidate = {
code: "600001", ts_code: "600001.SH", name: "已发布候选", sector: "电力设备", price: 12,
score_display: 82, historical_probability: 45, probability_samples: 30,
pct_chg: 1.2, return_5d: 3.4, volume_ratio_5d: 1.5, sector_strength: 78,
reason: "板块强度、相对强度", risk_flags: [],
};
const smartResult = {
meta: {
run_id: 201, trade_date: "20260803", regime: "repair",
strategy_name: "修复确认", mode: "smart",
},
candidates: [candidate],
disclaimer: "历史统计不代表未来收益",
backtest: null,
};
const activeRow = {
mode: "smart", code: candidate.code, name: candidate.name, sector: candidate.sector,
selection_date: "20260803", score_display: candidate.score_display,
hits: [{
strategy_name: "修复确认", selection_date: "20260803", active: true,
validity: { label: "阶段内有效" }, valid_until: "", remaining_trading_days: null,
score_display: candidate.score_display,
}],
};
const expiredRow = {
mode: "smart", code: "600002", name: "历史候选", sector: "银行",
selection_date: "20260727", score_display: 76,
hits: [{
strategy_name: "修复确认", selection_date: "20260727", active: false,
validity: { label: "阶段内有效" }, valid_until: "20260801", remaining_trading_days: 0,
score_display: 76,
}],
};
const options = {
latestScreenerResults: { smart: smartResult },
recentScreenerResults: [smartResult],
screenerPublishedRuns: {
修复确认: { trade_date: "20260803", status: "ready", detail: "1 只候选" },
连续分红质量: { trade_date: "20260803", status: "no_signal", detail: "数据完整,暂无符合条件个股" },
数据缺失策略: { trade_date: "20260803", status: "missing_data", detail: "缺少近5日资金流" },
},
additionalScreenerStrategies: [{
id: 4, name: "数据缺失策略", description: "验证数据不足状态", regimes: ["repair"],
builtin: true, data_ready: false, missing_data: ["近5日资金流"],
formula: {
meta: { library: "curated", category: "资金动量", quality: "A", frequency: "每日", risk: "中" },
universe: { exclude_st: true }, filters: [], score: [], limit: 20, min_score: 0.5,
},
}],
screenerSetupOverride: {
trade_date: "20260804",
requested_trade_date: "20260804",
published_batch: {
trade_date: "20260803", status: "complete", is_fallback: true,
completed_count: 28, skipped_count: 1, finished_at: "2026-08-03T15:30:00+08:00",
notice: "所选日期候选正在生成,当前保留上一成功批次",
},
automatic_status: {
trade_date: "20260804", status: "running", retaining_trade_date: "20260803",
},
active_signals: [activeRow],
candidate_history: [activeRow, expiredRow],
},
};
await page.setViewportSize({ width: 1920, height: 1080 });
await mockApplication(page, session("user", true), options);
await page.goto("/index.html?view=screenerView");
await expect(page.locator("#screenerDateLabel")).toContainText("候选批次 2026-08-03");
await expect(page.locator("#screenerBatchNotice")).toContainText("当前保留上一成功批次");
await expect(page.locator("#screenerTableBody")).toContainText(candidate.name);
await page.locator('[data-screener-archive-view="active"]').click();
await expect(page.locator("#screenerResultTitle")).toHaveText("持续有效信号");
await expect(page.locator("#screenerArchiveTableBody")).toContainText(candidate.name);
await expect(page.locator("#screenerArchiveTableBody")).not.toContainText("历史候选");
await page.locator('[data-screener-archive-view="history"]').click();
await expect(page.locator("#screenerResultTitle")).toHaveText("历史入选记录");
await expect(page.locator("#screenerArchiveTableBody")).toContainText(candidate.name);
await expect(page.locator("#screenerArchiveTableBody")).toContainText("历史候选");
await page.locator('[data-screener-mode="curated"]').click();
await expect(page.locator("#screenerEmpty")).toHaveText("数据完整,暂无符合条件个股");
await expect(page.locator("#screenerTableBody")).not.toContainText(candidate.name);
await page.locator('[data-curated-strategy="4"]').click();
await expect(page.locator("#screenerEmpty")).toContainText("缺少必需数据");
await expect(page.locator("#curatedDataStatus")).toContainText("缺少近5日资金流");
await page.locator('[data-screener-mode="smart"]').click();
await page.reload();
await expect(page.locator("#screenerTableBody")).toContainText(candidate.name);
await expect(page.locator("#screenerBatchNotice")).toContainText("当前保留上一成功批次");
expect(options.screenerSetupRequests).toBe(2);
await page.setViewportSize({ width: 390, height: 844 });
await page.locator('[data-screener-mobile-view="results"]').click();
await expect(page.locator("#screenerView .screener-results-view")).toBeVisible();
await expect(page.locator("#screenerTableBody")).toContainText(candidate.name);
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(1);
await page.locator("#headerMenuButton").click();
await page.locator('[data-mobile-command-target="themeToggle"]').click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await expect(page.locator("#screenerView .screener-results-view")).toBeVisible();
await page.locator("#headerMenuButton").click();
await page.locator('[data-mobile-command-target="themeToggle"]').click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
});
test("screener restores automatic stage and curated pools across switching and reload", async ({ page }) => {
const formula = {
meta: { library: "smart" }, universe: {}, filters: [],
@@ -2548,7 +2805,7 @@ test("mentor directory exposes evidence filters and private owner metadata", asy
await page.goto("/index.html");
await page.locator('[data-view="mentorView"]').first().click();
await expect(page.locator("#mentorView .mentor-page-header .mentor-evidence-filters")).toBeVisible();
await expect(page.locator("#mentorView .mentor-sidebar .mentor-evidence-filters")).toBeVisible();
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(22);
await expect(page.locator('#mentorList [data-mentor-id="private-owner"] .mentor-badge.private')).toContainText("仅自己");
await expect(page.locator('#mentorList [data-mentor-id="source-a"] .mentor-badge.grade-a')).toHaveText("A");
@@ -2566,22 +2823,25 @@ test("mentor directory exposes evidence filters and private owner metadata", asy
await expect(page.locator("#activeMentorEvidence")).toHaveText("公开访谈与多源材料");
const mentorLibrary = await page.locator("#mentorView .mentor-sidebar").boundingBox();
const mentorChat = await page.locator("#mentorView .mentor-chat-panel").boundingBox();
const mentorProfile = await page.locator("#mentorView .mentor-profile-panel").boundingBox();
const mentorInput = await page.locator("#mentorQuestion").boundingBox();
expect(Math.abs(mentorLibrary.width - 340)).toBeLessThanOrEqual(1);
expect(mentorChat.x - (mentorLibrary.x + mentorLibrary.width)).toBeGreaterThanOrEqual(11);
expect(mentorInput.height).toBeLessThanOrEqual(40);
expect(Math.abs(mentorLibrary.width - 280)).toBeLessThanOrEqual(1);
expect(Math.abs(mentorProfile.width - 272)).toBeLessThanOrEqual(1);
expect(Math.abs(mentorChat.x - (mentorLibrary.x + mentorLibrary.width))).toBeLessThanOrEqual(1);
expect(Math.abs(mentorProfile.x - (mentorChat.x + mentorChat.width))).toBeLessThanOrEqual(1);
expect(mentorInput.height).toBeGreaterThanOrEqual(48);
expect(await page.evaluate(() => document.documentElement.scrollHeight - window.innerHeight)).toBeLessThanOrEqual(1);
await page.setViewportSize({ width: 1920, height: 947 });
const expandedMentorLayout = await page.locator("#mentorView .mentor-layout").boundingBox();
const statusBar = await page.locator(".status-bar").boundingBox();
const lowerGap = statusBar.y - (expandedMentorLayout.y + expandedMentorLayout.height);
expect(lowerGap).toBeGreaterThanOrEqual(0);
expect(lowerGap).toBeLessThanOrEqual(8);
expect(lowerGap).toBeLessThanOrEqual(16);
await page.locator("#overviewToggle").click();
const openOverviewLayout = await page.locator("#mentorView .mentor-layout").boundingBox();
const openOverviewGap = statusBar.y - (openOverviewLayout.y + openOverviewLayout.height);
expect(openOverviewGap).toBeGreaterThanOrEqual(0);
expect(openOverviewGap).toBeLessThanOrEqual(8);
expect(openOverviewGap).toBeLessThanOrEqual(16);
expect(await page.evaluate(() => document.documentElement.scrollHeight - window.innerHeight)).toBeLessThanOrEqual(1);
});
@@ -2607,49 +2867,53 @@ test("mentor pins, custom order and streamed replies work together", async ({ pa
await expect(answer.locator(".mentor-answer-list li")).toHaveCount(2);
await expect(answer.locator("br")).toHaveCount(0);
await expect(page.locator("#mentorMessages .assistant-stream-caret")).toHaveCount(0);
const followUps = answer.locator("[data-mentor-follow-up]");
await expect(followUps).toHaveCount(3);
const messageCount = await page.locator("#mentorMessages .mentor-message").count();
await followUps.first().click();
await expect(page.locator("#mentorQuestion")).toHaveValue("哪些信号代表确认?");
await expect(page.locator("#mentorMessages .mentor-message")).toHaveCount(messageCount);
await page.locator("#themeToggle").click();
await expect(page.locator("html")).not.toHaveClass(/theme-switching/);
const userMessage = page.locator("#mentorMessages .mentor-message.user");
const darkUserMessageStyle = await userMessage.evaluate((element) => ({
background: getComputedStyle(element).backgroundColor,
border: getComputedStyle(element).borderTopColor,
contentBackground: getComputedStyle(element.querySelector(".mentor-message-content")).backgroundColor,
contentColor: getComputedStyle(element.querySelector(".mentor-message-content")).color,
}));
expect(darkUserMessageStyle.background).not.toBe("rgb(255, 255, 255)");
expect(darkUserMessageStyle.border).not.toBe("rgb(255, 255, 255)");
expect(darkUserMessageStyle.contentBackground).toBe("rgb(35, 54, 74)");
expect(darkUserMessageStyle.contentColor).toBe("rgb(232, 234, 237)");
const darkMessageStyle = await answer.evaluate((element) => {
const style = getComputedStyle(element);
const content = element.querySelector(".mentor-message-content");
const contentStyle = getComputedStyle(content);
const heading = element.querySelector(".mentor-answer-heading");
const label = element.querySelector(".mentor-message-label");
const meta = element.querySelector("small");
return {
background: style.backgroundColor,
border: style.borderTopColor,
shadow: style.boxShadow,
contentColor: getComputedStyle(content).color,
contentBackground: contentStyle.backgroundColor,
contentBorder: contentStyle.borderTopColor,
contentColor: contentStyle.color,
headingColor: getComputedStyle(heading).color,
labelColor: getComputedStyle(label).color,
metaColor: getComputedStyle(meta).color,
};
});
expect(darkMessageStyle.background).not.toBe("rgb(255, 255, 255)");
expect(darkMessageStyle.border).not.toBe("rgb(255, 255, 255)");
expect(darkMessageStyle.background).toBe("rgba(0, 0, 0, 0)");
expect(darkMessageStyle.shadow).toBe("none");
expect(darkMessageStyle.contentBackground).toBe("rgb(32, 36, 40)");
expect(darkMessageStyle.contentBorder).toBe("rgb(52, 58, 64)");
expect(darkMessageStyle.contentColor).toBe("rgb(232, 234, 237)");
expect(darkMessageStyle.headingColor).toBe("rgb(232, 234, 237)");
expect(darkMessageStyle.labelColor).toBe("rgb(127, 137, 147)");
expect(darkMessageStyle.metaColor).toBe("rgb(127, 137, 147)");
});
test("mobile mentor directory opens as a searchable selector and hides private mentors", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.locator('[data-view="mentorView"]').first().click();
await expect(page.locator("#mentorDirectoryToggle")).toBeVisible();
await page.locator("#mentorDirectoryToggle").click();
await expect(page.locator("#mentorView .mentor-sidebar")).toHaveClass(/is-open/);
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(21);
await expect(page.locator('#mentorList [data-mentor-id="private-owner"]')).toHaveCount(0);
await page.locator('#mentorList [data-mentor-id="source-c"]').click();
await expect(page.locator("#mentorView .mentor-sidebar")).not.toHaveClass(/is-open/);
await expect(page.locator("#mobileActiveMentorName")).toHaveText("推演老师");
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
});
test("global dialogs share the stage 18 geometry without changing account or admin access", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("admin", true));
@@ -2711,7 +2975,8 @@ test("global dialogs share the stage 18 geometry without changing account or adm
await page.locator("#closeAdminDialog").click();
await page.setViewportSize({ width: 375, height: 812 });
await page.locator("#alertButton").click();
await page.locator("#headerMenuButton").click();
await page.locator('[data-mobile-command-target="alertButton"]').click();
const mobileGeometry = await page.locator("#alertsDialog").evaluate((dialog) => ({
width: dialog.getBoundingClientRect().width,
documentOverflow: document.documentElement.scrollWidth - window.innerWidth,
+22 -3
View File
@@ -279,14 +279,19 @@ class FrontendContractTests(unittest.TestCase):
self.assertIn("context.fillStyle = palette.axis;", chart)
self.assertNotIn('context.fillStyle = "#6c7983";', chart)
def test_dark_mentor_tokens_and_sentiment_bottom_clearance_are_defined(self):
def test_dark_mentor_answer_bubble_and_sentiment_bottom_clearance_are_defined(self):
self.assertIn(":root[data-theme=\"dark\"] #mentorView {", self.mentor_styles)
self.assertIn("--mentor-ink: var(--text-primary);", self.mentor_styles)
self.assertIn("--mentor-sub: var(--text-secondary);", self.mentor_styles)
self.assertIn(":root[data-theme=\"dark\"] #mentorView .mentor-message {", self.mentor_styles)
self.assertIn("border-color: var(--line-soft);", self.mentor_styles)
self.assertIn("background: var(--surface-subtle);", self.mentor_styles)
self.assertIn("background: transparent;", self.mentor_styles)
self.assertIn("box-shadow: none;", self.mentor_styles)
self.assertIn(
':root[data-theme="dark"] #mentorView .mentor-message.assistant .mentor-message-content {',
self.mentor_styles,
)
self.assertIn("border-color: var(--border);", self.mentor_styles)
self.assertIn("background: var(--surface-muted);", self.mentor_styles)
self.assertIn("#sentimentCycleView .sentiment-history-frame {", self.sentiment_styles)
self.assertIn("margin-bottom: var(--card-gap);", self.sentiment_styles)
self.assertIn("padding-bottom: var(--card-gap);", self.sentiment_styles)
@@ -347,6 +352,20 @@ class FrontendContractTests(unittest.TestCase):
self.assertIn('state.heavenInterpretations.fortune = payload.daily_fortune_reading || "";', self.script)
self.assertIn('if (existing) {\n openHeavenReading(mode, { loading: false });', self.script)
def test_heart_question_presets_lock_before_casting_and_reach_backend(self):
for preset, label in (("trade", "交易"), ("mind", "心境"), ("unthemed", "无题")):
self.assertIn(f'data-heart-question-preset="{preset}"', self.html)
self.assertIn(f'>{label}</button>', self.html)
self.assertIn('id="heartQuestionInput"', self.html)
self.assertIn("Promise.all([climateSequence(), qiSequence(), personalSequence()])", self.script)
self.assertIn("function formatHeavenAnswer(content)", self.script)
self.assertIn('data-lucide="${phaseIcon(dayMasterElement)}"', self.script)
self.assertIn('function phaseIcon(element)', self.script)
self.assertIn('setHeartQuestionLocked(true);', self.script)
self.assertIn('payload.question = state.heartQuestion;', self.script)
self.assertIn('payload.question_preset = state.heartQuestionPreset;', self.script)
self.assertIn('payload.cast_at = state.heartCastAt;', self.script)
if __name__ == "__main__":
unittest.main()
+130
View File
@@ -0,0 +1,130 @@
from __future__ import annotations
import unittest
from backend.features.heaven.engine import build_five_phase_field, hexagram_from_lines
from backend.features.heaven.knowledge import prepare_heaven_context
from backend.features.heaven.six_yao import build_six_yao_chart
class HeavenKnowledgeTests(unittest.TestCase):
def test_fortune_context_excludes_weighted_summary_and_adds_bounded_industry_symbols(self):
field = build_five_phase_field("2026-08-04")
prepared = prepare_heaven_context(
"fortune",
{
"calendar_date": "20260804",
"five_phase_field": field,
"personal_profile": {},
},
)
serialized = str(prepared["calculation"])
self.assertNotIn("balance", serialized)
self.assertNotIn("weights", serialized)
self.assertNotIn("human_field", serialized)
self.assertNotIn("sector_catalog", serialized)
industry_symbols = prepared["calculation"]["industry_symbols"]
self.assertTrue(industry_symbols)
self.assertTrue(all(item["basis"] and item["industries"] for item in industry_symbols))
self.assertIn(
"fortune-industry-boundary",
{record["id"] for record in prepared["knowledge"]["records"]},
)
self.assertEqual(prepared["knowledge"]["retrieval"], "deterministic-keyed")
self.assertTrue(prepared["knowledge"]["records"])
def test_fortune_personal_context_only_exposes_deterministic_stem_relations(self):
field = build_five_phase_field("2026-08-04")
prepared = prepare_heaven_context(
"fortune",
{
"calendar_date": "20260804",
"five_phase_field": field,
"personal_profile": {
"day_master": {"stem": "", "element": "", "strength": "偏弱"},
"current": {
"pillars": {"year": "丙午", "month": "乙未", "day": "庚戌"},
"ten_gods": {
"year": {"pillar": "丙午", "stem": "劫财", "branches": ["比肩"]},
"month": {"pillar": "乙未", "stem": "偏印", "branches": ["食神"]},
"day": {"pillar": "庚戌", "stem": "正财", "branches": ["伤官"]},
},
},
},
},
)
personal = prepared["calculation"]["personal"]
self.assertEqual(personal["natal_day_master"], {"stem": "", "element": ""})
self.assertEqual(
personal["today_relative_to_natal_day_master"]["stem_relations"],
{"year": "劫财", "month": "偏印", "day": "正财"},
)
self.assertEqual(
prepared["calculation"]["day_trigger"]["summary"],
next(
item["summary"]
for item in field["framework"]["layers"]
if item["id"] == "day"
),
)
record_ids = {record["id"] for record in prepared["knowledge"]["records"]}
self.assertIn("fortune-personal-劫财", record_ids)
self.assertIn("fortune-personal-偏印", record_ids)
self.assertIn("fortune-personal-正财", record_ids)
serialized = str(personal)
self.assertNotIn("strength", serialized)
self.assertNotIn("branches", serialized)
def test_trend_retrieves_only_actual_moving_lines(self):
hexagram = hexagram_from_lines([6, 7, 8, 9, 7, 8])
prepared = prepare_heaven_context(
"trend",
{
"data_trade_date": "20260804",
"selected_focus": {
"sector": "银行",
"stock": {"code": "000001", "name": "平安银行", "change": 3.2},
},
"hexagram": hexagram,
"movement": {},
},
)
self.assertNotIn("selected_focus", prepared["calculation"])
line_records = [
record for record in prepared["knowledge"]["records"]
if str(record.get("id") or "").startswith("zhouyi-line-")
]
self.assertEqual([record["position"] for record in line_records], [1, 4])
def test_heart_context_contains_question_chart_and_sources(self):
values = [6, 7, 8, 9, 7, 8]
prepared = prepare_heaven_context(
"heart",
{
"question": "这笔交易最需要防范什么?",
"question_preset": "trade",
"hexagram": hexagram_from_lines(values),
"six_yao": build_six_yao_chart(values, "2026-08-04T10:30:00+08:00"),
"ritual": {},
},
)
self.assertEqual(prepared["calculation"]["question_preset"], "trade")
self.assertEqual(prepared["calculation"]["six_yao"]["method"], "京房纳甲·八宫世应")
self.assertEqual(
{source["id"] for source in prepared["knowledge"]["sources"]},
{"zhouyi", "jingfang", "huozhulin", "zengshan"},
)
record_ids = {record["id"] for record in prepared["knowledge"]["records"]}
self.assertIn("heart-self-response", record_ids)
self.assertIn("heart-calendar", record_ids)
self.assertIn("heart-movement", record_ids)
self.assertIn("heart-relative-妻财", record_ids)
self.assertIn("heart-timing-boundary", record_ids)
if __name__ == "__main__":
unittest.main()
+197
View File
@@ -0,0 +1,197 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from backend.features.heaven.agent import (
HeavenAgentError,
_system_prompt,
_validate_answer,
interpret_heaven,
)
class HeavenPromptTests(unittest.TestCase):
def test_trend_prompt_requires_direction_without_fixed_advice_vocabulary(self):
prompt = _system_prompt("trend")
self.assertIn("必须明确给出卦义上的当下倾向", prompt)
self.assertIn("不得用“谨慎、等待、守信、辨伪”一类泛化劝诫代替解势", prompt)
self.assertNotIn("卦理约占九成", prompt)
self.assertNotIn("全文控制在300至450", prompt)
self.assertIn("## 核心判断", prompt)
self.assertIn("## 决策映射", prompt)
def test_fortune_prompt_excludes_weighted_conclusion(self):
prompt = _system_prompt("fortune")
self.assertIn("五行权重、主导元素和预制复合断语已明确排除", prompt)
self.assertIn("相生不直接等于吉,相克不直接等于凶", prompt)
self.assertIn("不得使用藏干", prompt)
self.assertIn("只能使用knowledge中本次命中的关系释义", prompt)
self.assertNotIn("全文控制在420至600", prompt)
self.assertIn("calculation.industry_symbols", prompt)
self.assertIn("## 行业影响", prompt)
self.assertIn("不得预测行业涨跌", prompt)
def test_heart_prompt_uses_question_and_deterministic_chart(self):
prompt = _system_prompt("heart")
self.assertIn("用户在起卦前确定的问题位于calculation.question", prompt)
self.assertIn("纳甲、卦宫、世应、六亲、六神", prompt)
self.assertIn("不是商业合作、融资、借贷或寻找资金方", prompt)
self.assertIn("不得假定用户已经入场", prompt)
self.assertIn("不得制造外围资金、外围资源", prompt)
self.assertIn("不得输出使用竖线分栏的Markdown表格", prompt)
self.assertIn("不得用旬空、填实、出空或干支日推算", prompt)
self.assertNotIn("最后三句极短的问心句", prompt)
self.assertIn("## 所问之答", prompt)
self.assertIn("## 可验证之处", prompt)
def test_fortune_validator_requires_industry_section_when_symbols_are_present(self):
context = {
"calculation": {
"industry_symbols": [
{"element": "", "basis": ["中运"], "industries": ["航运"]}
]
}
}
answer = "年纲与客主关系已经说明,日辰只作当日触发,最后给出个人观察和制衡动作。" * 2
with self.assertRaises(HeavenAgentError):
_validate_answer("fortune", answer, context)
_validate_answer("fortune", answer + "行业影响只作航运等水属性行业的传统取象,不预测涨跌。", context)
def test_trend_validator_rejects_market_story_added_after_casting(self):
answer = "本卦显示当下偏守,动爻揭示由增益转入受阻,之卦说明变化后应先稳住结构。" * 2
_validate_answer("trend", answer, {})
with self.assertRaises(HeavenAgentError):
_validate_answer("trend", answer + "白酒行业政策可能带来外部压力。", {})
def test_fortune_validator_rejects_invented_natal_strength(self):
context = {
"calculation": {
"pillars": {"year": "丙午", "month": "乙未", "day": "庚戌"},
"personal": {
"today_relative_to_natal_day_master": {
"pillars": {"year": "丙午", "month": "乙未", "day": "庚戌"}
}
},
}
}
answer = "丙午年纲、乙未客主和庚戌日辰形成相互制约,个人合参只说明当日关系,不据此判断命局强弱。" * 2
_validate_answer("fortune", answer, context)
with self.assertRaises(HeavenAgentError):
_validate_answer("fortune", answer + "个人本身偏财较重。", context)
with self.assertRaises(HeavenAgentError):
_validate_answer("fortune", answer + "戊辰年进一步增加土气。", context)
with self.assertRaises(HeavenAgentError):
_validate_answer("fortune", answer + "午中藏丁火,所以内在火气被引动。", context)
with self.assertRaises(HeavenAgentError):
_validate_answer("fortune", answer + "戌为火库,内藏火之余气。", context)
with self.assertRaises(HeavenAgentError):
_validate_answer("fortune", answer + "戌属土、含太阳寒水之气。", context)
with self.assertRaises(HeavenAgentError):
_validate_answer("fortune", answer + "木生火、火生土,因此本命日主受到泄耗。", context)
with self.assertRaises(HeavenAgentError):
_validate_answer("fortune", answer + "戌月进一步加重土气。", context)
with self.assertRaises(HeavenAgentError):
_validate_answer("fortune", answer + "日柱庚戌本身就是金。", context)
with self.assertRaises(HeavenAgentError):
_validate_answer("fortune", answer + "戌的寒水属性使气机进一步收敛。", context)
def test_invalid_first_draft_is_rewritten_before_returning(self):
invalid = "本卦解释完整,但白酒行业政策和基本面正在改变外部环境,因此当下应偏守。" * 2
valid = "本卦显示当下偏守,实际动爻揭示由增益转入受阻,之卦说明变化后要先确认结构能否重新承接。" * 2
with patch(
"backend.features.heaven.agent.llm_transport.chat_completion",
side_effect=[
SimpleNamespace(content=invalid, latency_ms=1),
SimpleNamespace(content=valid, latency_ms=1),
],
) as completion:
result = interpret_heaven(
"trend", {}, "key", "https://example.invalid", "test-model"
)
self.assertEqual(result["answer"], valid)
self.assertEqual(completion.call_count, 2)
repair_messages = completion.call_args_list[1].kwargs["messages"]
self.assertIn("未通过本地一致性校验", repair_messages[-1]["content"])
def test_heart_validator_rejects_invented_financing_and_timing(self):
context = {
"calculation": {
"question": "关于我心中的这笔交易,此刻最需要看清的机会、阻碍与风险是什么?"
}
}
answer = "本卦说明条件仍有矛盾,世应提示内外预期并不一致,动爻要求先核对退出条件,再看之卦所示的收束方向。" * 2
_validate_answer("heart", answer, context)
with self.assertRaises(HeavenAgentError):
_validate_answer("heart", answer + "对方资金提供方会转为合作方。", context)
with self.assertRaises(HeavenAgentError):
_validate_answer("heart", answer + "未来三日等到旬空填实后再行动。", context)
def test_heart_validator_rejects_unstated_position_assumptions(self):
context = {
"calculation": {
"question": "关于我心中的这笔交易,此刻最需要看清的机会、阻碍与风险是什么?"
}
}
answer = "本卦说明条件尚未完全衔接,世应提示内外预期有差异,动爻要求先核对参与条件,再看变化后的风险边界。" * 2
_validate_answer("heart", answer, context)
for term in ("持仓", "仓位", "建仓", "入场", "持有", "买入", "卖出", "止损", "止盈"):
with self.subTest(term=term), self.assertRaises(HeavenAgentError):
_validate_answer("heart", answer + f"用户当前的{term}安排已经明确。", context)
def test_heart_validator_allows_position_language_when_question_states_it(self):
context = {"calculation": {"question": "我已经持仓,这笔交易的止损条件是否清楚?"}}
answer = "本卦所示的内外条件仍有差异,世应关系提醒先核对持仓依据;动爻显示原判断正在变化,止损条件应以用户已经写下的客观边界为准。" * 2
_validate_answer("heart", answer, context)
def test_heart_validator_rejects_invented_resource_paths(self):
context = {"calculation": {"question": "这笔交易最需要防范什么?"}}
answer = "本卦说明条件尚未完全衔接,世应提示内外预期有差异,动爻要求先核对参与条件,再看变化后的风险边界。" * 2
for scenario in (
"外围资金可能进入。",
"外围资源进入路径尚不清楚。",
"资金来源会在之后出现。",
"资源提供的安排已经成形。",
):
with self.subTest(scenario=scenario), self.assertRaises(HeavenAgentError):
_validate_answer("heart", answer + scenario, context)
def test_heart_validator_rejects_markdown_tables(self):
context = {"calculation": {"question": "这笔交易最需要防范什么?"}}
answer = "本卦说明条件尚未完全衔接,世应提示内外预期有差异,动爻要求先核对参与条件,再看变化后的风险边界。" * 2
tables = (
answer + "\n| 项目 | 解释 |\n| --- | --- |\n| 世爻 | 当前条件 |",
answer + "\n项目 | 解释\n---|---\n世爻 | 当前条件",
)
for table in tables:
with self.subTest(table=table), self.assertRaises(HeavenAgentError):
_validate_answer("heart", table, context)
def test_heart_invalid_first_draft_is_rewritten_before_returning(self):
context = {"calculation": {"question": "这笔交易最需要防范什么?"}}
invalid = "本卦提示外围资源进入路径尚未落实,用户应先调整仓位,再等待资金进入。" * 3
valid = "本卦说明条件尚未完全衔接,世应提示内外预期有差异,动爻要求先核对参与条件,再看变化后的风险边界。" * 2
with patch(
"backend.features.heaven.agent.llm_transport.chat_completion",
side_effect=[
SimpleNamespace(content=invalid, latency_ms=1),
SimpleNamespace(content=valid, latency_ms=1),
],
) as completion:
result = interpret_heaven(
"heart", context, "key", "https://example.invalid", "test-model"
)
self.assertEqual(result["answer"], valid)
self.assertEqual(completion.call_count, 2)
if __name__ == "__main__":
unittest.main()
+136 -1
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from unittest.mock import patch
from database import ReviewDatabase
from backend.features.heaven.agent import HEAVEN_PROMPT_VERSIONS
from server import DashboardService
@@ -28,7 +29,10 @@ class HeavenReadingTests(unittest.TestCase):
"2026-07-23 观气",
"丙午年 · 乙未月 · 己丑日",
"当日解运结果",
{"calendar_date": "20260723"},
{
"calendar_date": "20260723",
"interpretation_version": HEAVEN_PROMPT_VERSIONS["fortune"],
},
dedupe_key,
)
@@ -83,6 +87,30 @@ class HeavenReadingTests(unittest.TestCase):
self.assertEqual(result["reading"]["id"], existing["id"])
self.assertEqual(result["answer"], "当日解运结果")
def test_setup_only_exposes_current_fortune_prompt_version(self):
current = self.save(self.owner["id"])
service = DashboardService.__new__(DashboardService)
service.database = self.database
service._request_context = threading.local()
service._request_context.user_id = self.owner["id"]
self.assertEqual(
service._reusable_daily_fortune_reading("20260723")["id"],
current["id"],
)
self.database.save_heaven_reading(
self.owner["id"],
"fortune",
"20260724",
"2026-07-24 观气",
"旧版本",
"旧提示词生成的结果",
{"interpretation_version": "heaven-fortune-v2"},
"fortune:20260724",
)
self.assertIsNone(service._reusable_daily_fortune_reading("20260724"))
def test_legacy_truncated_fortune_is_regenerated_once(self):
self.database.save_heaven_reading(
self.owner["id"],
@@ -124,6 +152,54 @@ class HeavenReadingTests(unittest.TestCase):
len(self.database.list_heaven_readings(self.owner["id"], "fortune")), 1
)
def test_stale_prompt_version_is_replaced_after_successful_regeneration(self):
existing = self.database.save_heaven_reading(
self.owner["id"],
"fortune",
"20260723",
"2026-07-23 观气",
"丙午年 · 乙未月 · 己丑日",
"旧提示词生成的完整结果",
{"interpretation_version": "heaven-fortune-v2"},
"fortune:20260723",
)
service = DashboardService.__new__(DashboardService)
service.database = self.database
service._request_context = threading.local()
service._request_context.user_id = self.owner["id"]
setup = {
"calendar_date": "20260723",
"field": {
"sector_catalog": [],
"balance": [{"element": ""}],
"pillars": {"year": "丙午", "month": "乙未", "day": "己丑"},
},
}
with patch.object(service, "heaven_setup", return_value=setup), patch.object(
service, "account_personal_field", return_value={}
), patch.object(
service,
"_call_heaven_agent",
return_value=({"answer": "新版提示词生成的完整结果", "model": "test", "latency_ms": 1}, "primary"),
):
result = service.heaven_interpret(
{"mode": "fortune", "trade_date": "2026-07-23"}
)
self.assertFalse(result["reused"])
self.assertEqual(result["reading"]["id"], existing["id"] + 1)
self.assertEqual(result["answer"], "新版提示词生成的完整结果")
self.assertEqual(
self.database.heaven_reading_interpretation_version(
self.owner["id"], result["reading"]["id"]
),
HEAVEN_PROMPT_VERSIONS["fortune"],
)
self.assertEqual(
len(self.database.list_heaven_readings(self.owner["id"], "fortune")), 1
)
def test_trend_interpretation_saves_successful_agent_result(self):
service = DashboardService.__new__(DashboardService)
service.database = self.database
@@ -168,6 +244,65 @@ class HeavenReadingTests(unittest.TestCase):
"完整的解势结果",
)
def test_heart_interpretation_sends_question_and_deterministic_najia(self):
service = DashboardService.__new__(DashboardService)
service.database = self.database
service._request_context = threading.local()
service._request_context.user_id = self.owner["id"]
with patch.object(
service,
"_call_heaven_agent",
return_value=(
{"answer": "完整的观心解读,包含本卦、世应、动变与针对所问的条件化判断。", "model": "test", "latency_ms": 1},
"primary",
),
) as call_agent:
result = service.heaven_interpret(
{
"mode": "heart",
"trade_date": "2026-08-04",
"lines": [6, 7, 8, 9, 7, 8],
"question": "这笔交易最需要防范什么?",
"question_preset": "trade",
"cast_at": "2026-08-04T10:30:00+08:00",
}
)
agent_context = call_agent.call_args.args[1]
calculation = agent_context["calculation"]
self.assertEqual(calculation["question"], "这笔交易最需要防范什么?")
self.assertEqual(calculation["question_preset"], "trade")
self.assertEqual(calculation["six_yao"]["method"], "京房纳甲·八宫世应")
self.assertIn("这笔交易最需要防范什么", result["reading"]["subject_detail"])
def test_fallback_notice_describes_the_backup_service(self):
service = DashboardService.__new__(DashboardService)
service.database = self.database
service._request_context = threading.local()
service._request_context.user_id = self.owner["id"]
with patch.object(
service,
"_call_heaven_agent",
return_value=(
{"answer": "完整的观心解读,说明所问、卦象依据、动变与可验证条件。", "model": "backup", "latency_ms": 1},
"fallback",
),
):
result = service.heaven_interpret(
{
"mode": "heart",
"trade_date": "2026-08-05",
"lines": [6, 7, 8, 9, 7, 8],
"question": "这笔交易最需要防范什么?",
"question_preset": "trade",
"cast_at": "2026-08-05T10:30:00+08:00",
}
)
self.assertEqual(result["notice"], "当前智能服务繁忙,已自动切换备用服务。")
if __name__ == "__main__":
unittest.main()
+29
View File
@@ -103,6 +103,35 @@ class MentorStreamTests(unittest.TestCase):
)
self.assertEqual(chunks, ["first", " second"])
def test_follow_up_block_is_collected_without_leaking_into_answer(self):
lines = [
'data: {"choices":[{"delta":{"content":"先看承接,再等确认。\\n<XIAOBAI_FOL"}}]}\n'.encode(),
'data: {"choices":[{"delta":{"content":"LOW_UPS>\\n[\\"什么信号代表承接有效?\\",\\"这个判断何时失效?\\"]\\n</XIAOBAI_FOLLOW_UPS>"}}]}\n'.encode(),
b"data: [DONE]\n",
]
follow_ups: list[str] = []
with patch(
"backend.llm.transport.urllib.request.urlopen",
return_value=FakeStreamResponse(lines),
):
chunks = list(
stream_with_mentor(
self.skill,
{},
"question",
[],
"key",
"https://example.test/v1",
"model",
follow_ups=follow_ups,
)
)
self.assertEqual("".join(chunks), "先看承接,再等确认。\n")
self.assertEqual(
follow_ups,
["什么信号代表承接有效?", "这个判断何时失效?"],
)
if __name__ == "__main__":
unittest.main()
+237
View File
@@ -0,0 +1,237 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from backend.features.screener.catalog import BUILTIN_STRATEGIES
from backend.features.screener.publication import resolve_published_batch
from backend.features.screener.service import ScreenerServiceMixin
from backend.features.screener.signals import build_candidate_archive, signal_validity
from database import ReviewDatabase
def _result(
trade_date: str,
mode: str,
strategy_name: str,
code: str = "600000",
regime: str = "repair",
frequency: str = "每日",
run_id: int = 1,
) -> dict:
candidates = [] if not code else [{
"code": code,
"name": "浦发银行",
"sector": "银行",
"score_display": 72.5,
"pct_chg": 1.2,
"return_5d": 3.4,
}]
return {
"meta": {
"trade_date": trade_date,
"mode": mode,
"strategy_name": strategy_name,
"regime": regime,
"run_id": run_id,
"health": {"status": "normal" if candidates else "no_signal"},
},
"formula": {"meta": {"frequency": frequency}},
"candidates": candidates,
}
class ScreenerPublicationTests(unittest.TestCase):
def test_incomplete_requested_batch_retains_previous_complete_batch(self):
for status in ("running", "failed", "partial"):
markers = [
{"trade_date": "20260804", "status": status},
{"trade_date": "20260803", "status": "complete", "finished_at": "done"},
]
published, requested, descriptor = resolve_published_batch(
markers, "20260804"
)
self.assertEqual(published["trade_date"], "20260803", status)
self.assertEqual(requested["status"], status)
self.assertTrue(descriptor["is_fallback"])
def test_complete_requested_batch_switches_publication(self):
markers = [
{"trade_date": "20260804", "status": "complete"},
{"trade_date": "20260803", "status": "complete"},
]
published, requested, descriptor = resolve_published_batch(
markers, "20260804"
)
self.assertEqual(published["trade_date"], "20260804")
self.assertEqual(requested["status"], "complete")
self.assertFalse(descriptor["is_fallback"])
def test_legacy_result_is_inferred_only_when_caller_allows_it(self):
published, requested, descriptor = resolve_published_batch(
[], "20260804", "20260804"
)
self.assertTrue(published["legacy_inferred"])
self.assertEqual(requested["status"], "pending")
self.assertFalse(descriptor["is_fallback"])
def test_all_builtin_strategies_declare_signal_validity(self):
for strategy in BUILTIN_STRATEGIES:
validity = strategy["formula"]["meta"].get("signal_validity")
self.assertIsInstance(validity, dict, strategy["name"])
self.assertTrue(validity.get("label"), strategy["name"])
def test_frequency_validity_uses_trading_days(self):
dates = [f"202607{day:02d}" for day in range(1, 11)]
strategy = {
"name": "周度策略",
"formula": {"meta": {"library": "curated", "frequency": "每周"}},
}
run = _result(dates[0], "curated", "周度策略", frequency="每周")
active_day_five, _ = build_candidate_archive(
[run], [strategy], dates[:5], dates[4], "repair"
)
active_day_six, history = build_candidate_archive(
[run], [strategy], dates[:6], dates[5], "repair"
)
self.assertEqual(len(active_day_five), 1)
self.assertEqual(active_day_six, [])
self.assertEqual(history[0]["status"], "已到期")
self.assertEqual(signal_validity("curated", strategy["formula"])["days"], 5)
def test_stage_signal_expires_when_regime_changes(self):
run = _result("20260803", "smart", "修复策略", regime="repair")
active, history = build_candidate_archive(
[run], [], ["20260803", "20260804"], "20260804", "retreat"
)
self.assertEqual(active, [])
self.assertEqual(history[0]["status"], "已到期")
def test_same_stock_from_multiple_strategies_is_aggregated(self):
runs = [
_result("20260803", "curated", "策略甲", run_id=1, frequency="每周"),
_result("20260803", "curated", "策略乙", run_id=2, frequency="月度"),
]
active, history = build_candidate_archive(
runs, [], ["20260803"], "20260803", "repair"
)
self.assertEqual(len(active), 1)
self.assertEqual(len(history), 1)
self.assertEqual(set(active[0]["matched_strategies"]), {"策略甲", "策略乙"})
class ScreenerRepositoryPublicationTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.database = ReviewDatabase(Path(self.temp.name) / "review.db")
def tearDown(self) -> None:
self.temp.cleanup()
def test_marker_history_and_run_dates_are_read_in_publication_order(self):
self.database.save_data_snapshot(
"screener_auto_v1", "20260803", "system", {"status": "complete"}
)
self.database.save_data_snapshot(
"screener_auto_v1", "20260804", "system", {"status": "running"}
)
self.database.save_screener_run(
0, "20260803", "repair", "阶段策略", {"meta": {}},
_result("20260803", "smart", "阶段策略"), "smart",
)
markers = self.database.list_screener_batch_markers("20260804")
runs = self.database.screener_runs_for_dates(0, ["20260803"])
self.assertEqual([item["trade_date"] for item in markers], ["20260804", "20260803"])
self.assertEqual(runs[0]["meta"]["trade_date"], "20260803")
class ScreenerSetupFallbackTests(unittest.TestCase):
def test_setup_uses_previous_successful_automatic_batch(self):
smart = {
"id": 1,
"name": "修复策略",
"description": "修复阶段策略",
"regimes": ["repair"],
"builtin": True,
"formula": {"meta": {"library": "smart", "frequency": "每日"}},
}
curated = {
"id": 2,
"name": "周度策略",
"description": "周度策略",
"regimes": ["repair"],
"builtin": True,
"formula": {"meta": {"library": "curated", "frequency": "每周"}},
}
smart_result = _result("20260803", "smart", "修复策略", run_id=11)
curated_result = _result(
"20260803", "curated", "周度策略", code="", run_id=12, frequency="每周"
)
dates = [f"202607{day:02d}" for day in range(1, 31)] + ["20260803"]
markers = [
{"trade_date": "20260804", "status": "running"},
{
"trade_date": "20260803",
"status": "complete",
"regime": {"id": "repair"},
"completed": [
{"mode": "smart", "name": "修复策略"},
{"mode": "curated", "name": "周度策略"},
],
"skipped": [],
},
]
database = SimpleNamespace(
factor_dates=lambda *_: dates,
auction_factor_dates=lambda *_: dates,
list_screener_strategies=lambda *_: [smart, curated],
list_screener_batch_markers=lambda *_: markers,
screener_runs_for_date=lambda _user, value: (
[smart_result, curated_result] if value == "20260803" else []
),
recent_screener_runs=lambda *_: [],
screener_runs_for_dates=lambda *_: [smart_result, curated_result],
)
health = {
"market": True,
"auction": True,
"benchmark": True,
"valuation": True,
"fundamental": True,
"dividend_history": True,
"moneyflow_history": True,
"earnings_events": True,
"popularity": True,
"institutions": True,
}
screener = SimpleNamespace(
detect_regime=lambda *_: {
"id": "repair", "label": "修复", "confidence": 80,
"reason": "", "evidence": [], "history": [],
},
factor_health=lambda *_: health,
)
service = ScreenerServiceMixin()
service.database = database
service.screener = screener
service.current_user_id = 7
service._request_context = SimpleNamespace(user_id=7)
service.llm_configured = False
service.llm_primary_model = ""
service.llm_fallback_configured = False
service.llm_fallback_model = ""
payload = service.screener_setup("20260804")
self.assertEqual(payload["published_batch"]["trade_date"], "20260803")
self.assertTrue(payload["published_batch"]["is_fallback"])
self.assertEqual(payload["automatic_status"]["status"], "running")
self.assertEqual(payload["latest_results"]["smart"]["meta"]["run_id"], 11)
self.assertEqual(curated["published_run"]["status"], "no_signal")
self.assertEqual(len(payload["active_signals"]), 1)
if __name__ == "__main__":
unittest.main()
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import unittest
from backend.features.heaven.six_yao import (
_palace_map,
build_six_yao_chart,
)
class SixYaoEngineTests(unittest.TestCase):
def test_eight_palaces_cover_every_hexagram_once(self):
palaces = _palace_map()
self.assertEqual(len(palaces), 64)
self.assertEqual(
{item["trigram"] for item in palaces.values()},
{"", "", "", "", "", "", "", ""},
)
self.assertEqual(
{item["stage"] for item in palaces.values()},
{"本宫", "一世", "二世", "三世", "四世", "五世", "游魂", "归魂"},
)
def test_pure_qian_uses_standard_najia_and_self_response(self):
chart = build_six_yao_chart(
[7, 7, 7, 7, 7, 7],
"2026-08-04T10:30:00+08:00",
)
self.assertEqual(chart["palace"]["name"], "乾宫")
self.assertEqual(chart["palace"]["stage"], "本宫")
self.assertEqual(chart["palace"]["self_position"], 6)
self.assertEqual(chart["palace"]["response_position"], 3)
self.assertEqual(
[(line["stem"], line["branch"]) for line in chart["lines"]],
[("", ""), ("", ""), ("", ""), ("", ""), ("", ""), ("", "")],
)
self.assertEqual(
[line["relative"] for line in chart["lines"]],
["子孙", "妻财", "父母", "官鬼", "兄弟", "父母"],
)
self.assertEqual(chart["branch_pattern"], "六冲")
def test_pure_kun_uses_standard_najia(self):
chart = build_six_yao_chart(
[8, 8, 8, 8, 8, 8],
"2026-08-04T10:30:00+08:00",
)
self.assertEqual(chart["palace"]["name"], "坤宫")
self.assertEqual(
[(line["stem"], line["branch"]) for line in chart["lines"]],
[("", ""), ("", ""), ("", ""), ("", ""), ("", ""), ("", "")],
)
def test_cast_time_drives_day_void_and_six_spirits(self):
chart = build_six_yao_chart(
[7, 7, 7, 7, 7, 7],
"2026-08-04T02:30:00Z",
)
self.assertEqual(chart["cast_at"], "2026-08-04T10:30:00+08:00")
self.assertEqual(chart["calendar"]["day"], "庚戌")
self.assertEqual(chart["calendar"]["day_void"], "寅卯")
self.assertEqual(
[line["spirit"] for line in chart["lines"]],
["白虎", "玄武", "青龙", "朱雀", "勾陈", "螣蛇"],
)
def test_moving_line_has_deterministic_transformation(self):
chart = build_six_yao_chart(
[6, 7, 8, 9, 7, 8],
"2026-08-04T10:30:00+08:00",
)
moving = [line for line in chart["lines"] if line["moving"]]
self.assertEqual([line["position"] for line in moving], [1, 4])
self.assertTrue(all(line.get("transformation") for line in moving))
self.assertFalse(any(line.get("transformation") for line in chart["lines"] if not line["moving"]))
if __name__ == "__main__":
unittest.main()