事故根因:HEL-214/221/226/233 均直接基于 main(8a5e78f) 构建部署,
绕过了 .11 正式线(HEL-183/188/190/191/192/193/199/207/208 + HEL-164
共 14 个已验收提交),导致问天工具行右对齐、侧栏等高、管理员刷新
结果、快照补档、收盘日线修复等整体丢失。
本合并以 deploy 前最后完整基线 89b8d33(镜像 official-limit-guard-d9ee725)
为底,合入 cefc869(HEL-221 情绪周期等高、HEL-226 登录门户与免密切换、
HEL-233 移动端系统管理五页),三处 CSS 缓存版本统一刷新为 20260829-hel237,
architecture-inventory 按合并后源码重新生成。
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -47,6 +47,72 @@ function session(role = "admin", subscribed = true) {
|
||||
};
|
||||
}
|
||||
|
||||
function sentimentHistoryPayload(days = 20) {
|
||||
const phases = ["冰点", "修复", "发酵", "高潮", "分化", "退潮"];
|
||||
const rows = Array.from({ length: days }, (_, index) => {
|
||||
const score = 28 + ((index * 7) % 55);
|
||||
return {
|
||||
trade_date: `2026-08-${String(index + 1).padStart(2, "0")}`,
|
||||
score,
|
||||
label: "情绪观察",
|
||||
phase: phases[index % phases.length],
|
||||
direction: index % 2 ? "升温" : "降温",
|
||||
day_change: index % 2 ? 3.2 : -2.1,
|
||||
seal_rate: 71.5,
|
||||
limit_up_count: 40 + index,
|
||||
first_board_count: 18,
|
||||
second_board_count: 8,
|
||||
three_plus_count: 4,
|
||||
max_height: 5,
|
||||
broken_count: 12,
|
||||
limit_down_count: 3,
|
||||
previous_limit_count: 38,
|
||||
previous_positive_count: 22,
|
||||
previous_positive_rate: 57.9,
|
||||
average_previous_change: 1.2,
|
||||
normalization: "固定锚点",
|
||||
components: {
|
||||
breadth: { label: "市场宽度", score: 55.8, weight: 20, summary: "红盘家数回升" },
|
||||
limit: { label: "涨停连板", score: 79.6, weight: 25, summary: "连板生态改善" },
|
||||
profit: { label: "赚钱效应", score: 67.0, weight: 30, summary: "昨日反馈尚可" },
|
||||
ladder: { label: "涨幅结构", score: 81.5, weight: 15, summary: "高度仍在扩张" },
|
||||
amount: { label: "成交活跃度", score: 46.1, weight: 10, summary: "量能略低于均值" },
|
||||
},
|
||||
};
|
||||
});
|
||||
return { available_days: days, rows };
|
||||
}
|
||||
|
||||
async function renderSentimentFixture(page, days = 20) {
|
||||
await page.evaluate((payload) => {
|
||||
state.sentimentHistory = payload;
|
||||
renderSentimentHistory();
|
||||
}, sentimentHistoryPayload(days));
|
||||
await page.locator('[data-view="sentimentCycleView"]').first().click();
|
||||
await page.waitForTimeout(900);
|
||||
}
|
||||
|
||||
function readSentimentLayout() {
|
||||
const analysis = document.querySelector(".redesigned-emotion-grid").getBoundingClientRect();
|
||||
const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect();
|
||||
const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect();
|
||||
const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect();
|
||||
const chart = document.querySelector(".sentiment-chart-shell").getBoundingClientRect();
|
||||
const detail = document.querySelector(".sentiment-detail-toolbar").getBoundingClientRect();
|
||||
const table = document.querySelector(".sentiment-history-frame").getBoundingClientRect();
|
||||
const small = document.querySelector(".sentiment-component-item small");
|
||||
return {
|
||||
topDelta: Math.abs(trend.top - summary.top),
|
||||
bottomDelta: Math.abs(trend.bottom - components.bottom),
|
||||
analysisHeight: analysis.height,
|
||||
chartHeight: chart.height,
|
||||
detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom) - 0.5,
|
||||
tableVisible: table.top < window.innerHeight && table.bottom > detail.bottom,
|
||||
smallVisible: small ? getComputedStyle(small).display !== "none" : false,
|
||||
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function waitForApplicationRuntime(page) {
|
||||
return expect(page.locator("body")).toHaveAttribute("data-runtime-ready", "true");
|
||||
}
|
||||
@@ -721,6 +787,7 @@ test("collapsed overview and sentiment layout keep a single current reading", as
|
||||
await expect(page.locator(".sentiment-stage-guide, [data-sentiment-stage]")).toHaveCount(0);
|
||||
await expect(page.locator("#sentimentPhaseAdvice")).toHaveText("情绪指标继续走弱。");
|
||||
const alignment = await page.evaluate(() => {
|
||||
const analysis = document.querySelector(".redesigned-emotion-grid").getBoundingClientRect();
|
||||
const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect();
|
||||
const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect();
|
||||
const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect();
|
||||
@@ -732,6 +799,8 @@ test("collapsed overview and sentiment layout keep a single current reading", as
|
||||
const statusStyle = getComputedStyle(document.querySelector(".sentiment-block .sentiment-text"));
|
||||
return {
|
||||
columnsAligned: Math.abs(trend.top - summary.top) < 1,
|
||||
bottomsAligned: Math.abs(trend.bottom - components.bottom) < 1,
|
||||
analysisHeight: analysis.height,
|
||||
railAligned: Math.abs(summary.x - components.x) < 1 && Math.abs(summary.width - components.width) < 1 && components.top > summary.bottom,
|
||||
detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom),
|
||||
chartHeight: chart.height,
|
||||
@@ -743,6 +812,8 @@ test("collapsed overview and sentiment layout keep a single current reading", as
|
||||
};
|
||||
});
|
||||
expect(alignment.columnsAligned).toBe(true);
|
||||
expect(alignment.bottomsAligned).toBe(true);
|
||||
expect(Math.abs(alignment.analysisHeight - 600)).toBeLessThanOrEqual(1);
|
||||
expect(alignment.railAligned).toBe(true);
|
||||
expect(alignment.detailAfterAnalysis).toBe(true);
|
||||
expect(alignment.chartHeight).toBeGreaterThanOrEqual(340);
|
||||
@@ -780,6 +851,68 @@ test("collapsed overview and sentiment layout keep a single current reading", as
|
||||
}
|
||||
});
|
||||
|
||||
test("sentiment cycle keeps 600px equal-height layout across zoom viewports", async ({ page }, testInfo) => {
|
||||
const shotDir = testInfo.outputPath("hel-221-shots");
|
||||
await mockApplication(page, session("user", true));
|
||||
const viewports = [
|
||||
{ name: "zoom-100", width: 2560, height: 1440 },
|
||||
{ name: "zoom-110", width: 2327, height: 1309 },
|
||||
{ name: "zoom-125", width: 2048, height: 1152 },
|
||||
];
|
||||
|
||||
for (const viewport of viewports) {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
await page.goto("/index.html");
|
||||
await renderSentimentFixture(page, 20);
|
||||
const layout = await page.evaluate(readSentimentLayout);
|
||||
expect(layout.topDelta, viewport.name).toBeLessThanOrEqual(1);
|
||||
expect(layout.bottomDelta, viewport.name).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(layout.analysisHeight - 600), viewport.name).toBeLessThanOrEqual(1);
|
||||
expect(layout.chartHeight, viewport.name).toBeGreaterThanOrEqual(450);
|
||||
expect(layout.detailAfterAnalysis, viewport.name).toBe(true);
|
||||
expect(layout.tableVisible, viewport.name).toBe(true);
|
||||
expect(layout.smallVisible, viewport.name).toBe(true);
|
||||
expect(layout.overflowX, viewport.name).toBe(false);
|
||||
await page.screenshot({
|
||||
path: `${shotDir}/day-${viewport.name}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 2560, height: 1440 });
|
||||
await page.locator("#themeToggle").click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
await page.waitForTimeout(200);
|
||||
const nightLayout = await page.evaluate(readSentimentLayout);
|
||||
expect(nightLayout.topDelta).toBeLessThanOrEqual(1);
|
||||
expect(nightLayout.bottomDelta).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(nightLayout.analysisHeight - 600)).toBeLessThanOrEqual(1);
|
||||
await page.locator("#sentimentTrendChart").hover({ position: { x: 280, y: 120 } });
|
||||
const tooltip = page.locator("#sentimentChartTooltip");
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip).toContainText("温度");
|
||||
const tooltipStyle = await tooltip.evaluate((node) => {
|
||||
const style = getComputedStyle(node);
|
||||
const bold = getComputedStyle(node.querySelector("b") || node);
|
||||
return { background: style.backgroundColor, color: style.color, bold: bold.color };
|
||||
});
|
||||
expect(tooltipStyle.background).toBe("rgb(38, 41, 62)");
|
||||
expect(tooltipStyle.color).toBe("rgb(232, 234, 237)");
|
||||
expect(tooltipStyle.bold).toBe("rgb(232, 234, 237)");
|
||||
await page.screenshot({ path: `${shotDir}/night-zoom-100.png`, fullPage: true });
|
||||
|
||||
await page.locator("#themeToggle").click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
|
||||
await page.locator("#sentimentTrendChart").hover({ position: { x: 280, y: 120 } });
|
||||
await expect(tooltip).toBeVisible();
|
||||
const lightTooltip = await tooltip.evaluate((node) => {
|
||||
const style = getComputedStyle(node);
|
||||
return { background: style.backgroundColor, color: style.color };
|
||||
});
|
||||
expect(lightTooltip.background).toBe("rgb(31, 35, 41)");
|
||||
expect(lightTooltip.color).toBe("rgb(255, 255, 255)");
|
||||
});
|
||||
|
||||
test("limit-up pool separates stock identity and restores the reason column", async ({ page }) => {
|
||||
await mockApplication(page, session("user", true));
|
||||
await page.goto("/index.html");
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
function loginPayload(user) {
|
||||
return {
|
||||
ok: true,
|
||||
authenticated: true,
|
||||
csrf_token: "portal-csrf",
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
async function mockLoginPortal(page, options = {}) {
|
||||
const accounts = options.accounts || [];
|
||||
let currentUserId = options.currentUserId ?? null;
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const method = route.request().method();
|
||||
if (url.pathname === "/api/auth/accounts") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true, accounts, current_user_id: currentUserId }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/auth/switch" && method === "POST") {
|
||||
const body = route.request().postDataJSON() || {};
|
||||
const account = accounts.find((item) => Number(item.user_id) === Number(body.user_id));
|
||||
if (!account || options.switchFails) {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "该账号需重新验证" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
currentUserId = account.user_id;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(loginPayload(account)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/auth/forget" && method === "POST") {
|
||||
const body = route.request().postDataJSON() || {};
|
||||
const index = accounts.findIndex((item) => Number(item.user_id) === Number(body.user_id));
|
||||
if (index >= 0) accounts.splice(index, 1);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if ((url.pathname === "/api/auth/login" || url.pathname === "/api/auth/register") && method === "POST") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(loginPayload({
|
||||
id: 9,
|
||||
username: "new_user",
|
||||
role: "user",
|
||||
membership: { active: false, subscribed: false, is_admin: false },
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/auth/me") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
authenticated: Boolean(currentUserId),
|
||||
csrf_token: "portal-csrf",
|
||||
user: accounts.find((item) => Number(item.user_id) === Number(currentUserId)) || null,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const SAVED_ACCOUNTS = [
|
||||
{
|
||||
user_id: 1,
|
||||
username: "alpha_user",
|
||||
role: "admin",
|
||||
membership: { active: true, subscribed: true, is_admin: true },
|
||||
last_used_at: "2026-08-29T01:00:00+00:00",
|
||||
},
|
||||
{
|
||||
user_id: 2,
|
||||
username: "beta_user",
|
||||
role: "user",
|
||||
membership: { active: false, subscribed: false, is_admin: false },
|
||||
last_used_at: "2026-08-28T01:00:00+00:00",
|
||||
},
|
||||
];
|
||||
|
||||
test("first-time login portal asks for a password and hides environment copy", async ({ page }) => {
|
||||
await mockLoginPortal(page, { accounts: [] });
|
||||
await page.goto("/login/");
|
||||
await expect(page.locator(".login-card-title")).toHaveText("欢迎回来");
|
||||
await expect(page.locator("#loginUsername")).toBeVisible();
|
||||
await expect(page.locator(".login-submit")).toHaveText("登录");
|
||||
await expect(page.locator("body")).not.toContainText("内网个人版");
|
||||
await expect(page.locator("body")).not.toContainText("192.168.200.11");
|
||||
});
|
||||
|
||||
test("saved accounts can switch directly and show a re-auth message on failure", async ({ page }) => {
|
||||
await mockLoginPortal(page, { accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })) });
|
||||
await page.goto("/login/");
|
||||
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
|
||||
await expect(page.locator(".login-account-row")).toHaveCount(2);
|
||||
const switched = page.waitForRequest((request) => (
|
||||
request.url().includes("/api/auth/switch") && request.method() === "POST"
|
||||
));
|
||||
await page.locator('[data-switch-id="2"]').click();
|
||||
const request = await switched;
|
||||
expect(JSON.parse(request.postData() || "{}")).toEqual({ user_id: 2 });
|
||||
});
|
||||
|
||||
test("failed account switch stays on the portal with the original copy", async ({ page }) => {
|
||||
await mockLoginPortal(page, {
|
||||
accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })),
|
||||
switchFails: true,
|
||||
});
|
||||
await page.goto("/login/");
|
||||
await page.locator('[data-switch-id="2"]').click();
|
||||
await expect(page.locator(".login-error")).toHaveText("该账号需重新验证");
|
||||
await expect(page).toHaveURL(/\/login\/?/);
|
||||
});
|
||||
|
||||
test("managing accounts removes a local record after inline confirmation", async ({ page }) => {
|
||||
await mockLoginPortal(page, { accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })) });
|
||||
await page.goto("/login/");
|
||||
await page.locator('[data-login-action="manage"]').click();
|
||||
await expect(page.locator(".login-card-title")).toHaveText("管理账号记录");
|
||||
await page.locator('[data-confirm-id="2"]').click();
|
||||
await expect(page.locator(".login-confirm-copy")).toContainText("beta_user");
|
||||
await page.locator('[data-forget-id="2"]').click();
|
||||
await expect(page.locator(".login-account-row")).toHaveCount(1);
|
||||
await expect(page.locator(".login-account-row")).toContainText("alpha_user");
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
// 手机端(/m/)全页面回归:P5 收官打磨。
|
||||
// 覆盖:登录、四个入口图标页、行情 12 页、工具 3 页、复盘 5 页、复盘助手,
|
||||
// 手机端(/m/)全页面回归:P5 收官打磨 + HEL-233 系统管理恢复。
|
||||
// 覆盖:登录、四个入口图标页、行情 12 页、工具 3 页、复盘 5 页、复盘助手、系统管理 5 页,
|
||||
// 以及日夜两套渲染、空态、错误态、横屏健壮性、深底深字对比度抽查。
|
||||
|
||||
const EMPTY_DASHBOARD = {
|
||||
@@ -138,6 +138,31 @@ async function mockMobileApi(page, options = {}) {
|
||||
payload = { items: [] };
|
||||
} else if (path === "/api/search") {
|
||||
payload = { groups: { stocks: [{ id: "002141", code: "002141", name: "贤丰控股", type: "stock", industry: "电子元件" }], sectors: [], themes: [], indices: [] } };
|
||||
} else if (path === "/api/account/status") {
|
||||
payload = {
|
||||
birth_profile_configured: true,
|
||||
birth_profile: { birth_datetime: "1990-01-15T08:30", gender: "male" },
|
||||
llm_access: {
|
||||
daily_limit: 50,
|
||||
used_today: 3,
|
||||
remaining_calls: 47,
|
||||
membership: {
|
||||
active: true,
|
||||
subscribed: true,
|
||||
is_admin: auth.user.role === "admin",
|
||||
remaining_days: 20,
|
||||
expires_at: "2026-09-18",
|
||||
plan: "会员",
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (path === "/api/admin/settings") {
|
||||
payload = {
|
||||
data: { configured: true, snapshot_dates: 12, background_refresh_enabled: true, ifind: { configured: false } },
|
||||
llm: { models: [{ id: "model-1", name: "主模型", base_url: "https://api.openai.com/v1", model: "gpt-4.1", configured: true }], primary_model_id: "model-1", fallback_model_id: "" },
|
||||
membership: { member_daily_limit: 50 },
|
||||
users: [{ id: 2, username: "normal_user", role: "user", membership_subscribed: true, membership_status: "active", membership_expires_at: "2026-09-18", used_today: 3 }],
|
||||
};
|
||||
} else if (/^\/api\/stock\/\d+\/preview$/.test(path)) {
|
||||
payload = {
|
||||
meta: { trade_date: "2026-07-22", intraday_status: "available" },
|
||||
@@ -230,6 +255,16 @@ const REVIEW_PAGES = [
|
||||
["review/alerts", "提醒中心"],
|
||||
];
|
||||
|
||||
const SYSTEM_PAGES = [
|
||||
["system/profile", "账号资料"],
|
||||
["system/password", "修改密码"],
|
||||
["system/membership", "会员状态"],
|
||||
["system/admin", "系统设置"],
|
||||
["system/members", "会员管理"],
|
||||
];
|
||||
|
||||
const PLACEHOLDER_COPY = "该功能页将在后续批次实现";
|
||||
|
||||
test("mobile login renders before authentication", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.route("**/api/**", async (route) => {
|
||||
@@ -296,6 +331,54 @@ test("review five pages render watchlist, trades, daily, notes and alerts", asyn
|
||||
}
|
||||
});
|
||||
|
||||
test("system management pages render real content instead of placeholders", async ({ page }) => {
|
||||
await mockMobileApi(page);
|
||||
await openMobile(page);
|
||||
for (const [key, label] of SYSTEM_PAGES) {
|
||||
await navigateToFeature(page, key);
|
||||
await expect(page.locator("#m-title")).toHaveText(label);
|
||||
await expect(page.locator(".m-placeholder")).toHaveCount(0);
|
||||
await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
|
||||
await expect(page.locator("[data-system-page], [data-system-admin-panel]").first()).toBeVisible();
|
||||
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
await navigateToFeature(page, "system/profile");
|
||||
await expect(page.locator("#m-sys-birth-date")).toBeVisible();
|
||||
await expect(page.locator("[data-system-switch]")).toBeVisible();
|
||||
await navigateToFeature(page, "system/password");
|
||||
await expect(page.locator("#m-sys-password-current")).toBeVisible();
|
||||
await navigateToFeature(page, "system/membership");
|
||||
await expect(page.locator(".m-sys-grid")).toBeVisible();
|
||||
await navigateToFeature(page, "system/admin");
|
||||
await expect(page.locator("#m-sys-token")).toBeVisible();
|
||||
await navigateToFeature(page, "system/members");
|
||||
await expect(page.locator("#m-sys-member-limit")).toBeVisible();
|
||||
});
|
||||
|
||||
test("empty profile save click shows a toast instead of a dead button", async ({ page }) => {
|
||||
await mockMobileApi(page);
|
||||
await openMobile(page);
|
||||
await navigateToFeature(page, "system/profile");
|
||||
await expect(page.locator("[data-system-save-birth]")).toBeVisible();
|
||||
await page.locator("#m-sys-birth-date").fill("");
|
||||
await page.locator("#m-sys-birth-time").fill("");
|
||||
await page.locator("[data-system-save-birth]").click();
|
||||
await expect(page.locator("#m-toast.is-visible")).toBeVisible();
|
||||
await expect(page.locator("#m-toast")).toContainText("请填写完整出生日期和时间");
|
||||
});
|
||||
|
||||
test("non-admin cannot open system admin pages as placeholders", async ({ page }) => {
|
||||
await mockMobileApi(page, { auth: authSession("user", true) });
|
||||
await openMobile(page);
|
||||
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
|
||||
await expect(page.locator(".m-hub-grid")).toBeVisible();
|
||||
await expect(page.locator('.m-grid-item[data-route="#/feature/system/admin"]')).toHaveCount(0);
|
||||
await expect(page.locator('.m-grid-item[data-route="#/feature/system/members"]')).toHaveCount(0);
|
||||
await navigateToFeature(page, "system/admin");
|
||||
await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
|
||||
await expect(page.locator("[data-system-page='forbidden']")).toBeVisible();
|
||||
});
|
||||
|
||||
test("assistant chat renders with presets and input", async ({ page }) => {
|
||||
await mockMobileApi(page);
|
||||
await openMobile(page);
|
||||
|
||||
Reference in New Issue
Block a user