恢复集成(HEL-237): 以完整基线 89b8d33 为底合入登录线 cefc869

事故根因: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:
总工
2026-08-29 15:51:48 +08:00
co-authored by multica-agent
37 changed files with 2637 additions and 111 deletions
+133
View File
@@ -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");
+151
View File
@@ -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");
});
+85 -2
View File
@@ -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);