feat: restore mentor page to final day/night spec

Rebuild the mentor workspace per the approved final design (day.png/night.png):
- mentorView gets a dedicated full-width immersive shell (independent 56px
  top bar with page title slot + theme mode text, hidden module-nav/market
  tape/overview/status bar) scoped to body[data-active-view=mentorView].
- Assistant messages become borderless body text with name/time above; only
  user messages use a blue bubble; keep loading/error/streaming caret states.
- 4 quick topics stay visible with history; composer restored to a framed
  ~94px card with bottom-left shortcut hint and bottom-right send button.
- Directory tools merged into one row (search + filter menu + sort); the
  filter menu still offers all/A/B/C; list selected state is an inset rounded
  fill; contact rows are borderless 75px items.
- Chat header always shows pin/note/profile/clear; pin reuses /api/mentors/
  preferences; theme toggle stays the single #themeToggle.
- Night tokens match the spec including the two distinct blues (#316FEF link,
  #5B8DEF quote/selected icon). Mobile (<768) stacks panels with no horizontal
  overflow; 1024+ follows the desktop spec.
- Update stale test baselines (300px sidebar, 94px composer, hint presence,
  night bubble color, centered disclaimer) and regenerate the architecture
  inventory.

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
MS-01-Codex
2026-08-18 11:54:57 +08:00
co-authored by multica-agent
parent 33f9db43b1
commit 8ac3adbb5d
12 changed files with 1118 additions and 465 deletions
+602 -346
View File
File diff suppressed because it is too large Load Diff
+55 -34
View File
@@ -1,35 +1,28 @@
<section id="mentorView" class="workspace-view page member-feature-view redesigned-mentor-view">
<section id="mentorView" class="workspace-view page member-feature-view redesigned-mentor-view mentor-page-header">
<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>
</header>
<div id="mentorNotice" class="inline-notice" hidden></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">
</label>
<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 class="mentor-filter-menu">
<button id="mentorFilterToggle" class="mentor-filter-toggle" type="button" aria-haspopup="menu" aria-expanded="false" title="筛选思维模型"><span id="mentorFilterLabel">全部</span><i data-lucide="chevron-down"></i></button>
<div id="mentorFilterOptions" class="mentor-filter-options" role="menu" hidden>
<button type="button" class="active" data-mentor-grade="all" role="menuitem">全部<span id="mentorCount">0 位</span></button>
<button type="button" data-mentor-grade="A" role="menuitem">A</button>
<button type="button" data-mentor-grade="B" role="menuitem">B级</button>
<button type="button" data-mentor-grade="C" role="menuitem">C级</button>
</div>
</div>
<p id="mentorSortHint" class="mentor-sort-hint" hidden>拖动联系人,或使用箭头调整顺序</p>
<button id="mentorSortToggle" class="mentor-sort-toggle" type="button" aria-pressed="false" title="整理联系人顺序"><i data-lucide="list-ordered"></i><span>整理</span></button>
</div>
<div id="mentorList" class="mentor-list"></div>
<div id="mentorListEmpty" class="mentor-list-empty" hidden>没有符合条件的思维模型</div>
<p class="mentor-evidence-legend">等级仅表示公开素材完整度</p>
<p id="mentorSortHint" class="mentor-sort-hint" hidden>拖动联系人,或使用箭头调整顺序</p>
</aside>
<section class="mentor-chat-panel" aria-label="问师对话">
@@ -41,7 +34,12 @@
<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>
<div class="mentor-chat-actions">
<button id="mentorPinButton" class="icon-button mentor-header-action" type="button" aria-label="置顶当前思维模型" title="置顶"><i data-lucide="pin"></i></button>
<button id="mentorNoteButton" class="icon-button mentor-header-action" type="button" aria-label="备注标签" title="备注标签"><i data-lucide="tag"></i></button>
<button id="mentorProfileButton" class="icon-button mentor-header-action" type="button" aria-label="游资档案" title="游资档案"><i data-lucide="id-card"></i></button>
<button id="clearMentorChatButton" class="icon-button mentor-clear-button" type="button" disabled aria-label="清空当前对话" title="清空对话"><i data-lucide="trash-2"></i></button>
</div>
</header>
<div id="mentorMessages" class="mentor-messages" aria-live="polite"></div>
<div id="mentorQuickPrompts" class="mentor-quick-prompts">
@@ -52,38 +50,61 @@
<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>
<div class="mentor-composer-main">
<label class="visually-hidden" for="mentorQuestion">向当前思维模型提问</label>
<textarea id="mentorQuestion" maxlength="2000" placeholder="输入市场、板块、个股代码或交易问题"></textarea>
<span class="mentor-composer-hint">Enter 发送 · Shift + Enter 换行</span>
</div>
<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>
</section>
<aside class="mentor-profile-panel" aria-label="当前思维模型资料">
</div>
<footer class="mentor-workspace-footer">
<span class="mentor-evidence-legend">等级仅表示公开素材完整度</span>
<p class="mentor-disclaimer">基于公开资料提炼的思维模型模拟,不代表本人观点,不构成投资建议。</p>
</footer>
<dialog id="mentorNoteDialog" class="mentor-floating-dialog mentor-note-dialog" aria-labelledby="mentorNoteDialogTitle">
<div class="mentor-dialog-head">
<div><span class="dialog-eyebrow">问师</span><h3 id="mentorNoteDialogTitle">备注标签</h3></div>
<button type="button" class="icon-button mentor-dialog-close" data-mentor-dialog-close="mentorNoteDialog" aria-label="关闭" title="关闭"><i data-lucide="x"></i></button>
</div>
<div class="mentor-dialog-body">
<label class="visually-hidden" for="mentorNoteInput">备注标签</label>
<textarea id="mentorNoteInput" maxlength="2000" placeholder="为该思维模型记录备注或标签"></textarea>
</div>
</dialog>
<dialog id="mentorProfileDialog" class="mentor-floating-dialog mentor-profile-dialog" aria-labelledby="mentorProfileDialogTitle">
<div class="mentor-dialog-head">
<div><span class="dialog-eyebrow">问师</span><h3 id="mentorProfileDialogTitle">游资档案</h3></div>
<button type="button" class="icon-button mentor-dialog-close" data-mentor-dialog-close="mentorProfileDialog" aria-label="关闭" title="关闭"><i data-lucide="x"></i></button>
</div>
<div class="mentor-profile-dialog-body">
<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>
<span id="mentorProfileDialogAvatar" class="mentor-profile-avatar" aria-hidden="true"></span>
<h3 id="mentorProfileDialogName">--</h3>
<p id="mentorProfileDialogTagline">--</p>
<div id="mentorProfileDialogBadges" class="mentor-profile-badges"></div>
</div>
<section class="mentor-profile-section">
<h4>关注维度</h4>
<div id="activeMentorFocus" class="mentor-active-focus"></div>
<div id="mentorProfileDialogFocus" class="mentor-active-focus"></div>
</section>
<section class="mentor-profile-section">
<h4>资料依据</h4>
<strong id="mentorProfileSource">--</strong>
<p id="activeMentorEvidence">--</p>
<strong id="mentorProfileDialogSource">--</strong>
<p id="mentorProfileDialogEvidence">--</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="calendar-days"></i><span id="mentorProfileDialogDataDate">--</span></p>
<p><i data-lucide="database"></i><span>仅使用网页已提供的市场数据</span></p>
</section>
</aside>
</div>
</div>
</dialog>
</section>
+172 -17
View File
@@ -35,23 +35,63 @@ function renderMentorWorkspace() {
const setup = state.mentorSetup;
if (!setup) return;
const selected = setup.mentors.find((item) => item.id === state.selectedMentorId) || null;
setText("mentorDataDate", `数据日期 ${displayCompactDate(setup.trade_date)}`);
setText("currentPageSubtitle", `与不同交易思维模型持续对话 · 数据日期 ${displayCompactDate(setup.trade_date)}`);
setText("activeMentorName", 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("");
const pinButton = document.querySelector("#mentorPinButton");
if (pinButton) {
pinButton.classList.toggle("active", Boolean(selected?.pinned));
pinButton.setAttribute("aria-label", selected?.pinned ? "取消置顶当前思维模型" : "置顶当前思维模型");
pinButton.setAttribute("aria-pressed", String(Boolean(selected?.pinned)));
}
populateMentorDialogs(selected);
renderMentorDirectory();
renderMentorMessages();
}
function populateMentorDialogs(selected) {
const noteInput = document.querySelector("#mentorNoteInput");
if (noteInput) {
noteInput.value = loadMentorNote(selected);
}
setText("mentorProfileDialogName", selected?.name || "--");
setText("mentorProfileDialogTagline", selected?.tagline || selected?.description || "--");
setText("mentorProfileDialogSource", selected?.evidence?.label || "公开资料整理");
setText("mentorProfileDialogEvidence", selected?.evidence?.note || selected?.description || "--");
setText("mentorProfileDialogDataDate", `行情数据 ${displayCompactDate(state.mentorSetup?.trade_date || elements.tradeDate.value)}`);
setText("mentorProfileDialogAvatar", mentorAvatarText(selected));
document.querySelector("#mentorProfileDialogBadges").innerHTML = selected ? renderMentorBadges(selected, true) : "";
document.querySelector("#mentorProfileDialogFocus").innerHTML = (selected?.focus || []).slice(0, 4)
.map((item) => `<span>${escapeHtml(item)}</span>`).join("");
}
function mentorNoteStorageKey(selected) {
const accountId = state.user?.id || state.user?.username || "anon";
return `xiaobai-mentor-note-${accountId}-${String(selected?.id || "")}`;
}
function loadMentorNote(selected) {
if (!selected) return "";
try {
return window.localStorage.getItem(mentorNoteStorageKey(selected)) || "";
} catch (_error) {
return "";
}
}
function saveMentorNote() {
const selected = selectedMentor();
if (!selected) return;
const input = document.querySelector("#mentorNoteInput");
if (!input) return;
try {
window.localStorage.setItem(mentorNoteStorageKey(selected), input.value);
} catch (_error) {
showToast("备注保存失败");
}
}
function renderMentorDirectory() {
const mentors = state.mentorSetup?.mentors || [];
const query = state.mentorQuery;
@@ -70,15 +110,22 @@ function renderMentorDirectory() {
return haystack.includes(query);
});
setText("mentorCount", filtered.length === mentors.length ? `${mentors.length}` : `${filtered.length} / ${mentors.length}`);
setText("mentorFilterLabel", state.mentorGrade === "all" ? "全部" : `${state.mentorGrade}`);
const sortToggle = document.querySelector("#mentorSortToggle");
sortToggle.classList.toggle("active", state.mentorSortMode);
sortToggle.setAttribute("aria-pressed", String(state.mentorSortMode));
sortToggle.querySelector("span").textContent = state.mentorSortMode ? "完成" : "整理";
document.querySelector("#mentorSortHint").hidden = !state.mentorSortMode;
document.querySelector("#mentorSearchInput").disabled = state.mentorSortMode;
document.querySelector("#mentorFilterToggle").disabled = state.mentorSortMode;
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
button.disabled = state.mentorSortMode;
});
const filterOptions = document.querySelector("#mentorFilterOptions");
if (state.mentorSortMode && filterOptions && !filterOptions.hidden) {
filterOptions.hidden = true;
document.querySelector("#mentorFilterToggle").setAttribute("aria-expanded", "false");
}
const container = document.querySelector("#mentorList");
container.classList.toggle("is-sorting", state.mentorSortMode);
container.innerHTML = filtered.map((mentor) => {
@@ -143,6 +190,36 @@ function toggleMentorSortMode() {
renderMentorDirectory();
}
function toggleMentorFilterMenu() {
if (state.mentorSortMode || state.mentorLoading) return;
const options = document.querySelector("#mentorFilterOptions");
const toggle = document.querySelector("#mentorFilterToggle");
if (!options || !toggle) return;
const open = options.hidden;
options.hidden = !open;
toggle.setAttribute("aria-expanded", String(open));
if (open) {
const active = options.querySelector("[data-mentor-grade].active");
(active || options.querySelector("[data-mentor-grade]"))?.focus({ preventScroll: true });
}
}
function closeMentorFilterMenu() {
const options = document.querySelector("#mentorFilterOptions");
const toggle = document.querySelector("#mentorFilterToggle");
if (!options || options.hidden) return;
options.hidden = true;
toggle?.setAttribute("aria-expanded", "false");
}
function selectMentorGrade(grade) {
state.mentorGrade = grade || "all";
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
button.classList.toggle("active", button.dataset.mentorGrade === state.mentorGrade);
});
renderMentorDirectory();
}
async function toggleMentorPin(mentorId) {
if (state.mentorSavingPreferences) return;
const mentors = state.mentorSetup?.mentors || [];
@@ -262,6 +339,14 @@ function mentorAvatarText(mentor) {
return Array.from(String(mentor?.name || "师").trim())[0] || "师";
}
function mentorMessageTime(message) {
const parsed = new Date(String(message?.created_at || ""));
if (Number.isNaN(parsed.getTime())) {
return new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
}
return parsed.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
}
async function selectMentor(mentorId) {
if (mentorId === state.selectedMentorId) return;
state.selectedMentorId = mentorId;
@@ -289,7 +374,7 @@ function renderMentorMessages() {
<article class="mentor-message ${message.role} ${message.error ? "is-error" : ""}">
<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-label">${message.role === "user" ? "我" : escapeHtml(selected?.name || "问师")} · ${escapeHtml(mentorMessageTime(message))}</div>
<div class="mentor-message-content">${message.role === "assistant"
? (message.content ? formatMentorAnswer(message.content) : '<p class="mentor-loading-copy">正在读取复盘数据并推演...</p>')
: escapeHtml(message.content)}</div>
@@ -300,7 +385,7 @@ function renderMentorMessages() {
</article>
`).join("");
}
document.querySelector("#mentorQuickPrompts").hidden = state.mentorMessages.length > 0 || state.mentorLoading;
document.querySelector("#mentorQuickPrompts").hidden = false;
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;
@@ -342,6 +427,7 @@ async function sendMentorQuestion(event) {
const responseMessage = { role: "assistant", content: "", streaming: true, meta: "", followUps: [] };
state.mentorMessages.push(responseMessage);
input.value = "";
syncMentorComposerHeight();
state.mentorLoading = true;
state.mentorController = new AbortController();
hideMentorNotice();
@@ -431,6 +517,7 @@ async function streamMentorRequest(body, signal, onDelta, onMeta) {
function useMentorQuickPrompt(prompt) {
const input = document.querySelector("#mentorQuestion");
input.value = prompt || "";
syncMentorComposerHeight();
input.focus();
}
@@ -529,19 +616,21 @@ function bindMentorEvents() {
document.querySelector("#stopMentorQuestion").addEventListener("click", stopMentorGeneration);
document.querySelector("#clearMentorChatButton").addEventListener("click", clearMentorConversation);
document.querySelector("#mentorSortToggle").addEventListener("click", toggleMentorSortMode);
document.querySelector("#mentorFilterToggle").addEventListener("click", toggleMentorFilterMenu);
document.querySelector("#mentorSearchInput").addEventListener("input", (event) => {
state.mentorQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
renderMentorDirectory();
});
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
button.addEventListener("click", () => {
state.mentorGrade = button.dataset.mentorGrade || "all";
document.querySelectorAll("[data-mentor-grade]").forEach((item) => {
item.classList.toggle("active", item === button);
});
renderMentorDirectory();
selectMentorGrade(button.dataset.mentorGrade);
closeMentorFilterMenu();
});
});
document.addEventListener("click", (event) => {
if (event.target.closest(".mentor-filter-menu")) return;
closeMentorFilterMenu();
});
document.querySelectorAll("[data-mentor-prompt]").forEach((button) => {
button.addEventListener("click", () => useMentorQuickPrompt(button.dataset.mentorPrompt));
});
@@ -550,4 +639,70 @@ function bindMentorEvents() {
event.preventDefault();
document.querySelector("#mentorChatForm").requestSubmit();
});
document.querySelector("#mentorQuestion").addEventListener("input", syncMentorComposerHeight);
document.querySelector("#mentorNoteButton").addEventListener("click", openMentorNoteDialog);
document.querySelector("#mentorProfileButton").addEventListener("click", openMentorProfileDialog);
document.querySelector("#mentorPinButton").addEventListener("click", () => toggleMentorPin(state.selectedMentorId));
document.querySelector("#mentorNoteInput").addEventListener("input", saveMentorNote);
document.querySelectorAll("[data-mentor-dialog-close]").forEach((button) => {
button.addEventListener("click", () => closeMentorDialog(button.dataset.mentorDialogClose));
});
window.addEventListener("resize", centerOpenMentorDialogs);
}
function selectedMentor() {
return (state.mentorSetup?.mentors || []).find((item) => item.id === state.selectedMentorId) || null;
}
function syncMentorComposerHeight() {
const input = document.querySelector("#mentorQuestion");
if (!input) return;
input.style.height = "0";
const nextHeight = Math.min(input.scrollHeight, 168);
input.style.height = `${nextHeight}px`;
input.style.overflowY = nextHeight >= 168 ? "auto" : "hidden";
}
function openMentorDialog(dialogId) {
const dialog = document.querySelector(`#${dialogId}`);
if (!(dialog instanceof HTMLDialogElement)) return;
if (!dialog.open) dialog.showModal();
positionMentorDialog(dialog);
}
function closeMentorDialog(dialogId) {
const dialog = document.querySelector(`#${dialogId}`);
if (dialog instanceof HTMLDialogElement && dialog.open) dialog.close();
}
function openMentorNoteDialog() {
const input = document.querySelector("#mentorNoteInput");
if (input) input.value = loadMentorNote(selectedMentor());
openMentorDialog("mentorNoteDialog");
const noteInput = document.querySelector("#mentorNoteInput");
if (noteInput) requestAnimationFrame(() => noteInput.focus());
}
function openMentorProfileDialog() {
populateMentorDialogs(selectedMentor());
openMentorDialog("mentorProfileDialog");
}
function positionMentorDialog(dialog) {
if (!(dialog instanceof HTMLDialogElement) || !dialog.open) return;
const viewportPadding = 12;
const rect = dialog.getBoundingClientRect();
const width = Math.min(400, window.innerWidth - viewportPadding * 2);
const height = Math.min(rect.height, window.innerHeight - viewportPadding * 2);
dialog.style.top = `${Math.max(viewportPadding, Math.round((window.innerHeight - height) / 2))}px`;
dialog.style.left = `${Math.max(viewportPadding, Math.round((window.innerWidth - width) / 2))}px`;
dialog.style.right = "auto";
dialog.style.bottom = "auto";
dialog.style.margin = "0";
}
function centerOpenMentorDialogs() {
document.querySelectorAll("#mentorNoteDialog, #mentorProfileDialog").forEach((dialog) => {
if (dialog instanceof HTMLDialogElement && dialog.open) positionMentorDialog(dialog);
});
}