fix: keep stock previews current and clarify member access
This commit is contained in:
+16
-16
@@ -4258,7 +4258,7 @@ async function showStockPreview(code, trigger) {
|
|||||||
document.body.classList.toggle("stock-preview-open", mobile);
|
document.body.classList.toggle("stock-preview-open", mobile);
|
||||||
requestAnimationFrame(repositionStockPreview);
|
requestAnimationFrame(repositionStockPreview);
|
||||||
|
|
||||||
const cacheKey = `${code}:${elements.tradeDate.value}`;
|
const cacheKey = `${code}:latest`;
|
||||||
const cached = stockPreviewCache.get(cacheKey);
|
const cached = stockPreviewCache.get(cacheKey);
|
||||||
if (cached && cached.expiresAt > Date.now()) {
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
renderStockPreview(cached.payload);
|
renderStockPreview(cached.payload);
|
||||||
@@ -4268,9 +4268,8 @@ async function showStockPreview(code, trigger) {
|
|||||||
stockPreviewAbortController?.abort();
|
stockPreviewAbortController?.abort();
|
||||||
stockPreviewAbortController = new AbortController();
|
stockPreviewAbortController = new AbortController();
|
||||||
try {
|
try {
|
||||||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
|
||||||
const payload = await apiRequest(
|
const payload = await apiRequest(
|
||||||
`/api/stock/${encodeURIComponent(code)}/preview?${query}`,
|
`/api/stock/${encodeURIComponent(code)}/preview`,
|
||||||
"GET",
|
"GET",
|
||||||
null,
|
null,
|
||||||
{ signal: stockPreviewAbortController.signal },
|
{ signal: stockPreviewAbortController.signal },
|
||||||
@@ -4291,11 +4290,10 @@ function renderStockPreviewLoading() {
|
|||||||
setText("stockPreviewCode", state.stockPreviewCode || "--");
|
setText("stockPreviewCode", state.stockPreviewCode || "--");
|
||||||
setText("stockPreviewName", fallback.name || "正在加载");
|
setText("stockPreviewName", fallback.name || "正在加载");
|
||||||
setText("stockPreviewSector", fallback.sector || "--");
|
setText("stockPreviewSector", fallback.sector || "--");
|
||||||
setText("stockPreviewPrice", meaningfulNumber(fallback.price) ? formatNumber(fallback.price, 2) : "--");
|
setText("stockPreviewPrice", "--");
|
||||||
const change = number(fallback.change);
|
setText("stockPreviewChange", "--");
|
||||||
setText("stockPreviewChange", meaningfulNumber(fallback.change) ? `${signed(change)}%` : "--");
|
document.querySelector("#stockPreviewChange").className = "";
|
||||||
document.querySelector("#stockPreviewChange").className = changeClass(change);
|
setText("stockPreviewDate", "最新行情");
|
||||||
setText("stockPreviewDate", elements.tradeDate.value);
|
|
||||||
setText("stockPreviewSource", "正在读取行情");
|
setText("stockPreviewSource", "正在读取行情");
|
||||||
setText("stockPreviewSummary", "等待行情数据");
|
setText("stockPreviewSummary", "等待行情数据");
|
||||||
document.querySelector("#stockPreviewLoading").hidden = false;
|
document.querySelector("#stockPreviewLoading").hidden = false;
|
||||||
@@ -4306,15 +4304,15 @@ function renderStockPreview(payload) {
|
|||||||
state.stockPreviewPayload = payload;
|
state.stockPreviewPayload = payload;
|
||||||
const fallback = state.stockPreviewFallback || {};
|
const fallback = state.stockPreviewFallback || {};
|
||||||
const stock = payload.stock || {};
|
const stock = payload.stock || {};
|
||||||
const price = stock.price ?? fallback.price;
|
const price = stock.price;
|
||||||
const change = stock.change ?? fallback.change;
|
const change = stock.change;
|
||||||
setText("stockPreviewCode", stock.code || state.stockPreviewCode);
|
setText("stockPreviewCode", stock.code || state.stockPreviewCode);
|
||||||
setText("stockPreviewName", stock.name && stock.name !== "--" ? stock.name : fallback.name || "--");
|
setText("stockPreviewName", stock.name && stock.name !== "--" ? stock.name : fallback.name || "--");
|
||||||
setText("stockPreviewSector", stock.industry && stock.industry !== "其他" ? stock.industry : fallback.sector || "其他");
|
setText("stockPreviewSector", stock.industry && stock.industry !== "其他" ? stock.industry : fallback.sector || "其他");
|
||||||
setText("stockPreviewPrice", meaningfulNumber(price) ? formatNumber(price, 2) : "--");
|
setText("stockPreviewPrice", meaningfulNumber(price) ? formatNumber(price, 2) : "--");
|
||||||
setText("stockPreviewChange", meaningfulNumber(change) ? `${signed(change)}%` : "--");
|
setText("stockPreviewChange", meaningfulNumber(change) ? `${signed(change)}%` : "--");
|
||||||
document.querySelector("#stockPreviewChange").className = changeClass(change);
|
document.querySelector("#stockPreviewChange").className = changeClass(change);
|
||||||
setText("stockPreviewDate", payload.meta?.trade_date || elements.tradeDate.value);
|
setText("stockPreviewDate", payload.meta?.trade_date || "最新行情");
|
||||||
document.querySelector("#stockPreviewLoading").hidden = true;
|
document.querySelector("#stockPreviewLoading").hidden = true;
|
||||||
const intradayAvailable = (payload.intraday || []).length > 0;
|
const intradayAvailable = (payload.intraday || []).length > 0;
|
||||||
const source = payload.meta?.realtime ? "盘中行情" : "日 K 行情";
|
const source = payload.meta?.realtime ? "盘中行情" : "日 K 行情";
|
||||||
@@ -4513,8 +4511,7 @@ function renderAlerts() {
|
|||||||
|
|
||||||
async function openReviewAssistant() {
|
async function openReviewAssistant() {
|
||||||
if (!hasMemberAccess()) {
|
if (!hasMemberAccess()) {
|
||||||
showToast("复盘助手仅对会员开放");
|
openSettings("membership", "复盘助手仅对会员开放");
|
||||||
openSettings("membership");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toggleHeaderCommandMenu(false);
|
toggleHeaderCommandMenu(false);
|
||||||
@@ -5191,10 +5188,13 @@ function selectAccountPanel(panel) {
|
|||||||
return selected;
|
return selected;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSettings(panel = "profile") {
|
async function openSettings(panel = "profile", accessNotice = "") {
|
||||||
selectAccountPanel(panel);
|
const selectedPanel = selectAccountPanel(panel);
|
||||||
toggleAccountDropdown(false);
|
toggleAccountDropdown(false);
|
||||||
toggleHeaderCommandMenu(false);
|
toggleHeaderCommandMenu(false);
|
||||||
|
const membershipNotice = document.querySelector("#membershipAccessNotice");
|
||||||
|
membershipNotice.textContent = accessNotice;
|
||||||
|
membershipNotice.hidden = selectedPanel !== "membership" || !accessNotice;
|
||||||
const status = document.querySelector("#connectionStatus");
|
const status = document.querySelector("#connectionStatus");
|
||||||
status.className = "connection-status";
|
status.className = "connection-status";
|
||||||
status.textContent = "正在读取账号状态";
|
status.textContent = "正在读取账号状态";
|
||||||
@@ -5220,7 +5220,7 @@ async function openSettings(panel = "profile") {
|
|||||||
? `${membership.plan || "会员"}${membership.expires_at ? ` · 有效至 ${membershipDateDisplay(membership.expires_at, true)}` : " · 长期有效"}`
|
? `${membership.plan || "会员"}${membership.expires_at ? ` · 有效至 ${membershipDateDisplay(membership.expires_at, true)}` : " · 长期有效"}`
|
||||||
: membership.is_admin
|
: membership.is_admin
|
||||||
? "管理员拥有智能功能管理权限,但不会因此显示为已开通会员。"
|
? "管理员拥有智能功能管理权限,但不会因此显示为已开通会员。"
|
||||||
: "开通会员后可使用智能选股、问师、问天等智能功能。");
|
: "开通会员后可使用智能选股、问师、问天、复盘助手等智能功能。");
|
||||||
setText("membershipQuotaHint", `会员默认每日智能分析额度 ${number(access.daily_limit)} 次,由管理员统一设置。`);
|
setText("membershipQuotaHint", `会员默认每日智能分析额度 ${number(access.daily_limit)} 次,由管理员统一设置。`);
|
||||||
setText("membershipUsage", membership.active
|
setText("membershipUsage", membership.active
|
||||||
? `今日已用 ${number(access.used_today)} 次`
|
? `今日已用 ${number(access.used_today)} 次`
|
||||||
|
|||||||
+8
-4
@@ -1179,7 +1179,8 @@
|
|||||||
<div id="connectionStatus" class="connection-status" aria-live="polite">正在读取账号状态</div>
|
<div id="connectionStatus" class="connection-status" aria-live="polite">正在读取账号状态</div>
|
||||||
<section class="settings-section membership-overview account-settings-panel" data-account-panel-content="membership" hidden>
|
<section class="settings-section membership-overview account-settings-panel" data-account-panel-content="membership" hidden>
|
||||||
<div class="settings-section-heading"><h3>会员状态</h3><span id="membershipBadge">普通用户</span></div>
|
<div class="settings-section-heading"><h3>会员状态</h3><span id="membershipBadge">普通用户</span></div>
|
||||||
<p id="membershipDetail" class="settings-lead">开通会员后可使用智能选股、问师、问天等智能功能。</p>
|
<p id="membershipAccessNotice" class="membership-access-notice" role="status" aria-live="polite" hidden></p>
|
||||||
|
<p id="membershipDetail" class="settings-lead">开通会员后可使用智能选股、问师、问天、复盘助手等智能功能。</p>
|
||||||
<p id="membershipQuotaHint" class="form-hint">会员默认每日智能分析额度 -- 次,由管理员统一设置。</p>
|
<p id="membershipQuotaHint" class="form-hint">会员默认每日智能分析额度 -- 次,由管理员统一设置。</p>
|
||||||
<div id="membershipUsage" class="membership-usage">今日智能分析 -- 次</div>
|
<div id="membershipUsage" class="membership-usage">今日智能分析 -- 次</div>
|
||||||
<div class="membership-status-grid">
|
<div class="membership-status-grid">
|
||||||
@@ -1190,9 +1191,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="membership-comparison" aria-label="会员权益对比">
|
<div class="membership-comparison" aria-label="会员权益对比">
|
||||||
<div class="membership-comparison-head"><span>功能</span><span>普通用户</span><span>会员用户</span></div>
|
<div class="membership-comparison-head"><span>功能</span><span>普通用户</span><span>会员用户</span></div>
|
||||||
<div><span>行情数据与股票预览</span><b>可用</b><b>可用</b></div>
|
<div><span>行情、搜索与个股详情</span><b>可用</b><b>可用</b></div>
|
||||||
<div><span>智能选股 / 问师 / 问天</span><b class="muted">不可用</b><b class="available">可用</b></div>
|
<div><span>自选股、复盘记录与交易日志</span><b>可用</b><b>可用</b></div>
|
||||||
<div><span>智能分析功能</span><b class="muted">不可用</b><b class="available">按额度使用</b></div>
|
<div><span>智能选股</span><b class="muted">不可用</b><b class="available">可用</b></div>
|
||||||
|
<div><span>问师与问天</span><b class="muted">不可用</b><b class="available">可用</b></div>
|
||||||
|
<div><span>复盘助手</span><b class="muted">不可用</b><b class="available">可用</b></div>
|
||||||
|
<div><span>每日智能分析额度</span><b class="muted">无</b><b class="available">按会员额度使用</b></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="membership-topup-row"><span><i data-lucide="battery-charging"></i>智能分析加油包</span><button id="membershipTopupButton" class="button" type="button" disabled>暂不可用</button></div>
|
<div class="membership-topup-row"><span><i data-lucide="battery-charging"></i>智能分析加油包</span><button id="membershipTopupButton" class="button" type="button" disabled>暂不可用</button></div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -11014,6 +11014,17 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin
|
|||||||
.membership-status-grid > div { padding: 12px 13px; border: 1px solid var(--line, #e2e8ee); border-radius: 10px; background: #fbfcfd; }
|
.membership-status-grid > div { padding: 12px 13px; border: 1px solid var(--line, #e2e8ee); border-radius: 10px; background: #fbfcfd; }
|
||||||
.membership-status-grid span { display: block; color: #74808c; font-size: 11px; }
|
.membership-status-grid span { display: block; color: #74808c; font-size: 11px; }
|
||||||
.membership-status-grid strong { display: block; margin-top: 5px; color: #26323d; font-size: 15px; font-variant-numeric: tabular-nums; }
|
.membership-status-grid strong { display: block; margin-top: 5px; color: #26323d; font-size: 15px; font-variant-numeric: tabular-nums; }
|
||||||
|
.membership-access-notice {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #ead9ac;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff9ea;
|
||||||
|
color: #775316;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
.membership-comparison { overflow: hidden; border: 1px solid var(--line, #e2e8ee); border-radius: 10px; font-size: 12px; }
|
.membership-comparison { overflow: hidden; border: 1px solid var(--line, #e2e8ee); border-radius: 10px; font-size: 12px; }
|
||||||
.membership-comparison > div { display: grid; grid-template-columns: minmax(0, 1.6fr) .7fr .7fr; gap: 8px; align-items: center; padding: 9px 11px; border-top: 1px solid var(--line, #e2e8ee); }
|
.membership-comparison > div { display: grid; grid-template-columns: minmax(0, 1.6fr) .7fr .7fr; gap: 8px; align-items: center; padding: 9px 11px; border-top: 1px solid var(--line, #e2e8ee); }
|
||||||
.membership-comparison > div:first-child { border-top: 0; }
|
.membership-comparison > div:first-child { border-top: 0; }
|
||||||
|
|||||||
@@ -53,7 +53,17 @@ async function mockApplication(page, authSession = session()) {
|
|||||||
let payload = { ok: true };
|
let payload = { ok: true };
|
||||||
if (url.pathname === "/api/auth/me") payload = authSession;
|
if (url.pathname === "/api/auth/me") payload = authSession;
|
||||||
else if (url.pathname === "/api/dashboard") payload = dashboard;
|
else if (url.pathname === "/api/dashboard") payload = dashboard;
|
||||||
else if (url.pathname === "/api/watchlist" || url.pathname === "/api/notes") payload = { items: [] };
|
else if (url.pathname === "/api/stock/002141/preview") {
|
||||||
|
payload = {
|
||||||
|
meta: { trade_date: "2026-07-23", realtime: false, intraday_notice: "" },
|
||||||
|
stock: { code: "002141", name: "Test Stock", industry: "Test Sector", price: 10.8, change: 2.4 },
|
||||||
|
prices: [
|
||||||
|
{ trade_date: "2026-07-22", open: 10, high: 10.5, low: 9.9, close: 10.2, volume: 1000 },
|
||||||
|
{ trade_date: "2026-07-23", open: 10.3, high: 10.9, low: 10.2, close: 10.8, volume: 1200 },
|
||||||
|
],
|
||||||
|
intraday: [],
|
||||||
|
};
|
||||||
|
} else if (url.pathname === "/api/watchlist" || url.pathname === "/api/notes") payload = { items: [] };
|
||||||
else if (url.pathname === "/api/alerts") {
|
else if (url.pathname === "/api/alerts") {
|
||||||
payload = {
|
payload = {
|
||||||
items: [{
|
items: [{
|
||||||
@@ -180,6 +190,23 @@ test("regular account cannot see admin controls and member features are gated",
|
|||||||
await page.locator('[data-view="screenerView"]').first().click();
|
await page.locator('[data-view="screenerView"]').first().click();
|
||||||
await expect(page.locator("#screenerView .member-gate")).toBeVisible();
|
await expect(page.locator("#screenerView .member-gate")).toBeVisible();
|
||||||
await expect(page.locator("#screenerRunButton")).toBeDisabled();
|
await expect(page.locator("#screenerRunButton")).toBeDisabled();
|
||||||
|
await page.locator("#assistantButton").click();
|
||||||
|
await expect(page.locator("#settingsDialog")).toBeVisible();
|
||||||
|
await expect(page.locator("#membershipAccessNotice")).toHaveText("复盘助手仅对会员开放");
|
||||||
|
await expect(page.locator("#membershipAccessNotice")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stock hover preview ignores the selected historical date", async ({ page }) => {
|
||||||
|
await mockApplication(page, session("user", true));
|
||||||
|
await page.goto("/index.html");
|
||||||
|
await page.locator("#tradeDate").fill("2026-07-01");
|
||||||
|
const requestPromise = page.waitForRequest((request) => request.url().includes("/api/stock/002141/preview"));
|
||||||
|
await page.evaluate(() => showStockPreview("002141", document.querySelector("#globalSearchButton")));
|
||||||
|
const request = await requestPromise;
|
||||||
|
const requestUrl = new URL(request.url());
|
||||||
|
expect(requestUrl.searchParams.has("trade_date")).toBe(false);
|
||||||
|
await expect(page.locator("#stockPreviewDate")).toHaveText("2026-07-23");
|
||||||
|
await expect(page.locator("#stockPreviewName")).toHaveText("Test Stock");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("mobile shell stays within the viewport", async ({ page }) => {
|
test("mobile shell stays within the viewport", async ({ page }) => {
|
||||||
|
|||||||
@@ -72,6 +72,21 @@ class FrontendContractTests(unittest.TestCase):
|
|||||||
for label in prohibited:
|
for label in prohibited:
|
||||||
self.assertNotIn(label, self.script)
|
self.assertNotIn(label, self.script)
|
||||||
|
|
||||||
|
def test_stock_hover_preview_always_uses_latest_market_context(self):
|
||||||
|
start = self.script.index("async function showStockPreview")
|
||||||
|
end = self.script.index("function renderStockPreviewLoading", start)
|
||||||
|
preview_loader = self.script[start:end]
|
||||||
|
self.assertIn('const cacheKey = `${code}:latest`;', preview_loader)
|
||||||
|
self.assertIn('/preview`', preview_loader)
|
||||||
|
self.assertNotIn("trade_date", preview_loader)
|
||||||
|
self.assertNotIn("elements.tradeDate.value", preview_loader)
|
||||||
|
|
||||||
|
def test_membership_copy_includes_review_assistant_access(self):
|
||||||
|
self.assertIn("复盘助手仅对会员开放", self.script)
|
||||||
|
self.assertIn("智能选股、问师、问天、复盘助手等智能功能", self.html)
|
||||||
|
self.assertIn("自选股、复盘记录与交易日志", self.html)
|
||||||
|
self.assertIn("每日智能分析额度", self.html)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user