rebuild(stage-13): redesign mobile information architecture

This commit is contained in:
leefer
2026-07-30 08:02:47 +08:00
parent 4125ae2740
commit bec0878494
16 changed files with 449 additions and 42 deletions
+47
View File
@@ -0,0 +1,47 @@
# 阶段13验收记录
## 完成范围
- 移动Shell:保留行情、选股、问师、问天和复盘五个一级入口;行情下增加12个市场页面选择器。
- 日期:桌面和移动端共用唯一交易日期组件,手机可前后切换或直接输入日期。
- 表格:430px及以下的通用宽表重组为字段名明确的纵向记录,平板和横屏保留局部横向滚动。
- 复盘:移动端补齐提醒中心和复盘助手入口,不因顶栏精简丢失功能。
- 可访问性:跳到主内容、弹窗焦点陷阱与回收、排序状态、44px触控目标及减少动态效果偏好。
## 关键行为
- 股池、天梯、板块、竞价、题材、热榜和龙虎榜均通过移动行情选择器到达;底部“行情”在所有市场子页保持正确高亮。
- PC与移动端继续消费相同路由、权限、日期、账号和业务结果,没有第二套移动API或业务状态。
- 320px宽表至少直接显示字段名与字段值,长文本换行;页面本身不横滚。
- 手机横屏将股池筛选、搜索和导出收为同一行,避免短视口只剩极少数据区。
- Escape关闭弹窗后焦点回到触发按钮;跳到主内容链接可由键盘首先到达。
## 自动与视觉验收
- Ruff:通过。
- Pytest92项通过。
- Vue TypeScript:通过。
- Vitest3个文件、7项通过。
- Vite生产构建:通过。
- Playwright:阶段4至13共17项通过;阶段13专属3项通过。
- 视口:320、375、390、430、768、844×390横屏、1024、1280、1440、1536、1920×1080和3840×2160均无页面横向溢出。
- 16个工作区逐页在320px验证可达、日期控制可用和主内容可见。
- 浏览器视觉检查:320px纵向记录、844×390夜间横屏工具栏和固定底部导航无文字遮挡。
- 密钥扫描和Git差异检查:通过。
## 视觉证据
- `pool-light-320x568.jpg`
- `pool-dark-landscape-844x390.jpg`
## 减法审计
- 没有为16个页面复制移动版;移动信息架构集中在一个选择器、一个底部导航和一个`mobile.css`
- 桌面顶栏的日期校验被抽到27行的共享组件,移动端直接复用,没有第二套日期逻辑。
- 通用`DataTable`只增加字段标签与排序语义,同一DOM由CSS在窄屏重组,不维护两份数据渲染。
- `mobile.css`为537行,低于600行门禁;Shell新增组件分别31行和27行。
## 残余边界
- 系统管理和高密度自定义因子在手机上保持简化单列,PC仍是完整维护端。
- NAS生产容器保持不变,最终切换仍需人工确认。
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+4 -1
View File
@@ -8,6 +8,7 @@ import { useMarketStore } from "../../shared/stores/market";
import DesktopSidebar from "./DesktopSidebar.vue"; import DesktopSidebar from "./DesktopSidebar.vue";
import MarketStrip from "./MarketStrip.vue"; import MarketStrip from "./MarketStrip.vue";
import MobileNav from "./MobileNav.vue"; import MobileNav from "./MobileNav.vue";
import MobileWorkspaceSwitch from "./MobileWorkspaceSwitch.vue";
import StatusBar from "./StatusBar.vue"; import StatusBar from "./StatusBar.vue";
import TopBar from "./TopBar.vue"; import TopBar from "./TopBar.vue";
@@ -33,11 +34,13 @@ onBeforeUnmount(() => window.removeEventListener("keydown", globalShortcut));
<template> <template>
<div class="app-shell"> <div class="app-shell">
<a class="skip-link" href="#main-content">跳到主要内容</a>
<DesktopSidebar /> <DesktopSidebar />
<div class="shell-main"> <div class="shell-main">
<TopBar /> <TopBar />
<MarketStrip /> <MarketStrip />
<RouterView /> <MobileWorkspaceSwitch />
<div id="main-content" class="shell-content" tabindex="-1"><RouterView /></div>
</div> </div>
<StatusBar /> <StatusBar />
<MobileNav /> <MobileNav />
+16 -5
View File
@@ -1,9 +1,20 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute } from "vue-router";
import { findWorkspace } from "../workspaceRegistry";
const route = useRoute();
const current = computed(() => String(route.params.workspace ?? ""));
const marketActive = computed(() => findWorkspace(current.value)?.group === "市场复盘");
</script>
<template> <template>
<nav class="mobile-nav" aria-label="移动端主导航"> <nav class="mobile-nav" aria-label="移动端主导航">
<RouterLink class="mobile-nav-item" to="/workspace/emotion">行情</RouterLink> <RouterLink class="mobile-nav-item" :class="{ active: marketActive }" :aria-current="marketActive ? 'page' : undefined" to="/workspace/emotion">行情</RouterLink>
<RouterLink class="mobile-nav-item" to="/workspace/screener">选股</RouterLink> <RouterLink class="mobile-nav-item" :class="{ active: current === 'screener' }" to="/workspace/screener">选股</RouterLink>
<RouterLink class="mobile-nav-item" to="/workspace/mentor">问师</RouterLink> <RouterLink class="mobile-nav-item" :class="{ active: current === 'mentor' }" to="/workspace/mentor">问师</RouterLink>
<RouterLink class="mobile-nav-item" to="/workspace/heaven">问天</RouterLink> <RouterLink class="mobile-nav-item" :class="{ active: current === 'heaven' }" to="/workspace/heaven">问天</RouterLink>
<RouterLink class="mobile-nav-item" to="/workspace/review">复盘</RouterLink> <RouterLink class="mobile-nav-item" :class="{ active: current === 'review' }" to="/workspace/review">复盘</RouterLink>
</nav> </nav>
</template> </template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { findWorkspace, workspaces } from "../workspaceRegistry";
import TradingDateControl from "./TradingDateControl.vue";
const route = useRoute();
const router = useRouter();
const current = computed(() => String(route.params.workspace ?? ""));
const marketWorkspaces = workspaces.filter((item) => item.group === "市场复盘");
const visible = computed(() => findWorkspace(current.value)?.group === "市场复盘");
function change(event: Event): void {
void router.push(`/workspace/${(event.target as HTMLSelectElement).value}`);
}
</script>
<template>
<div class="mobile-context-bar">
<label v-if="visible" class="mobile-workspace-switch">
<span>行情页面</span>
<select :value="current" aria-label="切换行情页面" @change="change">
<option v-for="workspace in marketWorkspaces" :key="workspace.key" :value="workspace.key">
{{ workspace.title }} · {{ workspace.description }}
</option>
</select>
</label>
<TradingDateControl class="mobile-date-control" />
</div>
</template>
+2 -21
View File
@@ -8,6 +8,7 @@ import { useMarketStore } from "../../shared/stores/market";
import { useSessionStore } from "../../shared/stores/session"; import { useSessionStore } from "../../shared/stores/session";
import { useUiStore } from "../../shared/stores/ui"; import { useUiStore } from "../../shared/stores/ui";
import { findWorkspace } from "../workspaceRegistry"; import { findWorkspace } from "../workspaceRegistry";
import TradingDateControl from "./TradingDateControl.vue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -32,22 +33,6 @@ function openAccountDialog(name: "profile" | "membership" | "password"): void {
ui.openDialog(name); ui.openDialog(name);
} }
function changeDate(event: Event): void {
const input = event.target as HTMLInputElement;
const value = input.value.trim();
const parsed = new Date(`${value}T12:00:00+08:00`);
if (
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
!Number.isNaN(parsed.valueOf()) &&
parsed.toISOString().slice(0, 10) === value
) {
void market.selectDate(value);
return;
}
input.value = market.selectedDate;
ui.showToast("请输入 YYYY-MM-DD 格式的日期");
}
async function endSession(action: "logout" | "switch-account"): Promise<void> { async function endSession(action: "logout" | "switch-account"): Promise<void> {
menuOpen.value = false; menuOpen.value = false;
try { try {
@@ -84,11 +69,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", closeOnOutside))
<header class="topbar"> <header class="topbar">
<span class="topbar-title">{{ title }}</span> <span class="topbar-title">{{ title }}</span>
<div class="topbar-spacer"></div> <div class="topbar-spacer"></div>
<div class="date-control desktop-only"> <TradingDateControl class="desktop-only" />
<button class="icon-button" type="button" aria-label="前一日" title="前一日" @click="market.moveDate(-1)"></button>
<input :value="market.selectedDate" class="date-input" type="text" inputmode="numeric" pattern="\d{4}-\d{2}-\d{2}" maxlength="10" aria-label="交易日期" @change="changeDate" />
<button class="icon-button" type="button" aria-label="后一日" title="后一日" @click="market.moveDate(1)"></button>
</div>
<button class="icon-button" type="button" aria-label="全局搜索" title="全局搜索(Ctrl+K" @click="ui.openDialog('search')"></button> <button class="icon-button" type="button" aria-label="全局搜索" title="全局搜索(Ctrl+K" @click="ui.openDialog('search')"></button>
<button class="icon-button topbar-alert desktop-only" type="button" aria-label="提醒中心" title="提醒中心" @click="ui.openDialog('alerts')">!<span v-if="ui.alertUnread" class="alert-badge">{{ ui.alertUnread > 99 ? '99+' : ui.alertUnread }}</span></button> <button class="icon-button topbar-alert desktop-only" type="button" aria-label="提醒中心" title="提醒中心" @click="ui.openDialog('alerts')">!<span v-if="ui.alertUnread" class="alert-badge">{{ ui.alertUnread > 99 ? '99+' : ui.alertUnread }}</span></button>
<button class="icon-button desktop-only" type="button" aria-label="复盘助手" title="复盘助手" @click="ui.openDialog('assistant')"></button> <button class="icon-button desktop-only" type="button" aria-label="复盘助手" title="复盘助手" @click="ui.openDialog('assistant')"></button>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { useMarketStore } from "../../shared/stores/market";
import { useUiStore } from "../../shared/stores/ui";
const market = useMarketStore();
const ui = useUiStore();
function change(event: Event): void {
const input = event.target as HTMLInputElement;
const value = input.value.trim();
const parsed = new Date(`${value}T12:00:00+08:00`);
if (/^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(parsed.valueOf()) && parsed.toISOString().slice(0, 10) === value) {
void market.selectDate(value);
return;
}
input.value = market.selectedDate;
ui.showToast("请输入 YYYY-MM-DD 格式的日期");
}
</script>
<template>
<div class="date-control" aria-label="交易日期控制">
<button class="icon-button" type="button" aria-label="前一日" title="前一日" @click="market.moveDate(-1)"></button>
<input :value="market.selectedDate" class="date-input" type="text" inputmode="numeric" pattern="\d{4}-\d{2}-\d{2}" maxlength="10" aria-label="交易日期" @change="change" />
<button class="icon-button" type="button" aria-label="后一日" title="后一日" @click="market.moveDate(1)"></button>
</div>
</template>
@@ -100,7 +100,11 @@ onMounted(load);
<main class="page-frame review-page"> <main class="page-frame review-page">
<header class="page-header review-heading"> <header class="page-header review-heading">
<div><h1>我的复盘</h1><p class="page-subtitle">数据日期 {{ data?.trade_date ?? market.selectedDate }} · 当前账号私有</p></div> <div><h1>我的复盘</h1><p class="page-subtitle">数据日期 {{ data?.trade_date ?? market.selectedDate }} · 当前账号私有</p></div>
<button class="btn btn-primary" type="button" @click="watchDialog = true">添加自选</button> <div class="review-heading-actions">
<button class="btn mobile-only" type="button" @click="ui.openDialog('alerts')">提醒中心</button>
<button class="btn mobile-only" type="button" @click="ui.openDialog('assistant')">复盘助手</button>
<button class="btn btn-primary" type="button" @click="watchDialog = true">添加自选</button>
</div>
</header> </header>
<div v-if="loading" class="card workspace-state">正在读取个人复盘记录</div> <div v-if="loading" class="card workspace-state">正在读取个人复盘记录</div>
<EmptyState v-else-if="error" class="card" title="复盘记录暂不可用" :description="error" /> <EmptyState v-else-if="error" class="card" title="复盘记录暂不可用" :description="error" />
@@ -9,13 +9,19 @@ type TableColumn = {
format?: (value: unknown, row: Record<string, unknown>) => string; format?: (value: unknown, row: Record<string, unknown>) => string;
}; };
defineProps<{ const props = defineProps<{
columns: TableColumn[]; columns: TableColumn[];
rows: Record<string, unknown>[]; rows: Record<string, unknown>[];
sortKey: string; sortKey: string;
sortDirection: "asc" | "desc"; sortDirection: "asc" | "desc";
}>(); }>();
const emit = defineEmits<{ sort: [key: string] }>(); const emit = defineEmits<{ sort: [key: string] }>();
function ariaSort(key: string, sortable?: boolean): "ascending" | "descending" | "none" | undefined {
if (!sortable) return undefined;
if (key !== props.sortKey) return "none";
return props.sortDirection === "asc" ? "ascending" : "descending";
}
</script> </script>
<template> <template>
@@ -23,8 +29,8 @@ const emit = defineEmits<{ sort: [key: string] }>();
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th class="index-column">序号</th> <th class="index-column" scope="col">序号</th>
<th v-for="column in columns" :key="column.key" :class="{ numeric: column.numeric, 'wide-column': column.wide, 'code-column': column.code }"> <th v-for="column in columns" :key="column.key" scope="col" :aria-sort="ariaSort(column.key, column.sortable)" :class="{ numeric: column.numeric, 'wide-column': column.wide, 'code-column': column.code }">
<button v-if="column.sortable" type="button" @click="emit('sort', column.key)"> <button v-if="column.sortable" type="button" @click="emit('sort', column.key)">
{{ column.label }}<span v-if="sortKey === column.key">{{ sortDirection === "asc" ? "↑" : "↓" }}</span> {{ column.label }}<span v-if="sortKey === column.key">{{ sortDirection === "asc" ? "↑" : "↓" }}</span>
</button> </button>
@@ -35,7 +41,7 @@ const emit = defineEmits<{ sort: [key: string] }>();
<tbody> <tbody>
<tr v-for="(row, index) in rows" :key="String(row.identifier ?? index)"> <tr v-for="(row, index) in rows" :key="String(row.identifier ?? index)">
<td class="index-column numeric">{{ index + 1 }}</td> <td class="index-column numeric">{{ index + 1 }}</td>
<td v-for="column in columns" :key="column.key" :class="{ numeric: column.numeric, 'wide-column': column.wide, 'code-column': column.code, up: column.key.includes('change') && Number(row[column.key]) > 0, down: column.key.includes('change') && Number(row[column.key]) < 0 }"> <td v-for="column in columns" :key="column.key" :data-label="column.label" :class="{ numeric: column.numeric, 'wide-column': column.wide, 'code-column': column.code, up: column.key.includes('change') && Number(row[column.key]) > 0, down: column.key.includes('change') && Number(row[column.key]) < 0 }">
{{ column.format ? column.format(row[column.key], row) : (row[column.key] ?? "") }} {{ column.format ? column.format(row[column.key], row) : (row[column.key] ?? "") }}
</td> </td>
</tr> </tr>
+18 -2
View File
@@ -87,13 +87,29 @@ p {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.skip-link {
position: fixed;
top: var(--s-8);
left: var(--s-8);
z-index: var(--z-toast);
padding: var(--s-8) var(--s-12);
border-radius: var(--control-radius);
color: var(--c-gray-050);
background: var(--color-primary);
transform: translateY(calc(-100% - var(--s-16)));
}
.skip-link:focus {
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
*::after { *::after {
scroll-behavior: auto !important; scroll-behavior: auto !important;
animation-duration: var(--s-1) !important; animation-duration: var(--duration-reduced) !important;
animation-iteration-count: 1 !important; animation-iteration-count: 1 !important;
transition-duration: var(--s-1) !important; transition-duration: var(--duration-reduced) !important;
} }
} }
+143 -8
View File
@@ -7,12 +7,18 @@
.dialog, .dialog,
.dialog-wide { .dialog-wide {
width: 100%; width: 100%;
max-height: calc(100vh - var(--s-44)); max-height: calc(100dvh - var(--s-44));
border-radius: var(--card-radius) var(--card-radius) 0 0; border-radius: var(--card-radius) var(--card-radius) 0 0;
} }
button,
.btn, .btn,
.icon-button { .icon-button,
.seg-control button,
.segmented button,
.mobile-nav-item,
.mobile-workspace-switch select {
min-width: var(--touch-height);
min-height: var(--touch-height); min-height: var(--touch-height);
} }
@@ -27,7 +33,7 @@
.app-shell { .app-shell {
padding-left: 0; padding-left: 0;
padding-bottom: var(--shell-mobile-nav-height); padding-bottom: calc(var(--shell-mobile-nav-height) + env(safe-area-inset-bottom));
} }
.sidebar, .sidebar,
@@ -47,8 +53,7 @@
} }
.identity-cluster .tag, .identity-cluster .tag,
.account-name, .account-name {
.date-control .icon-button {
display: none; display: none;
} }
@@ -69,10 +74,53 @@
} }
.page-frame { .page-frame {
min-height: calc(100vh - var(--shell-topbar-height) - var(--shell-summary-height) - var(--shell-mobile-nav-height)); min-height: calc(100dvh - var(--shell-topbar-height) - var(--shell-summary-height) - var(--shell-mobile-nav-height));
padding: var(--s-12) var(--s-10); padding: var(--s-12) var(--s-10);
} }
.mobile-context-bar {
display: grid;
gap: var(--s-10);
padding: var(--s-6) var(--s-10);
border-bottom: var(--s-1) solid var(--color-border);
background: var(--color-surface);
}
.mobile-workspace-switch {
min-height: var(--touch-height);
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: var(--s-10);
color: var(--color-text-secondary);
font-size: var(--font-12);
font-weight: var(--weight-600);
}
.mobile-workspace-switch select {
min-width: 0;
padding: 0 var(--s-10);
border: var(--s-1) solid var(--color-border);
border-radius: var(--control-radius);
color: var(--color-text);
background: var(--color-surface-muted);
}
.mobile-date-control {
display: grid;
grid-template-columns: var(--touch-height) minmax(0, 1fr) var(--touch-height);
}
.mobile-date-control .date-input {
width: 100%;
height: var(--touch-height);
text-align: center;
}
.mobile-only {
display: inline-flex;
}
.page-header { .page-header {
align-items: flex-start; align-items: flex-start;
gap: var(--s-4); gap: var(--s-4);
@@ -85,7 +133,7 @@
bottom: 0; bottom: 0;
left: 0; left: 0;
z-index: var(--z-mobile-nav); z-index: var(--z-mobile-nav);
height: var(--shell-mobile-nav-height); min-height: calc(var(--shell-mobile-nav-height) + env(safe-area-inset-bottom));
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(5, 1fr);
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
@@ -101,7 +149,8 @@
font-size: var(--font-11); font-size: var(--font-11);
} }
.mobile-nav-item.router-link-active { .mobile-nav-item.router-link-active,
.mobile-nav-item.active {
color: var(--color-primary); color: var(--color-primary);
font-weight: var(--weight-600); font-weight: var(--weight-600);
} }
@@ -246,8 +295,94 @@
} }
} }
@media (max-width: 430px) {
.data-table-wrap {
overflow: visible;
}
.data-table {
display: block;
}
.data-table thead {
position: absolute;
width: var(--s-1);
height: var(--s-1);
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
.data-table tbody {
display: grid;
gap: var(--s-8);
padding: var(--s-8);
}
.data-table tbody tr {
display: grid;
padding: var(--s-8) var(--s-10);
border: var(--s-1) solid var(--color-border);
border-radius: var(--control-radius);
background: var(--color-surface-muted);
}
.data-table tbody td,
.data-table tbody td.numeric,
.data-table tbody td.code-column,
.data-table tbody td.wide-column {
width: auto;
min-width: 0;
display: grid;
grid-template-columns: minmax(var(--s-96), 1fr) minmax(0, 2fr);
gap: var(--s-8);
padding: var(--s-6) 0;
border-bottom: var(--s-1) solid var(--color-divider);
text-align: right;
white-space: normal;
}
.data-table tbody td:last-child {
border-bottom: 0;
}
.data-table tbody td::before {
content: attr(data-label);
color: var(--color-text-secondary);
font-size: var(--font-11);
font-weight: var(--weight-600);
text-align: left;
}
.data-table tbody td.index-column {
display: none;
}
}
@media (max-width: 1023px) and (orientation: landscape) and (max-height: 430px) {
.page-header {
align-items: baseline;
flex-direction: row;
}
.pool-toolbar {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
flex-wrap: nowrap;
}
.pool-filters {
width: auto;
}
.pool-search {
width: 100%;
}
}
@media (max-width: 1023px) { @media (max-width: 1023px) {
.review-main-grid { grid-template-columns: minmax(0, 1fr); } .review-main-grid { grid-template-columns: minmax(0, 1fr); }
.review-heading-actions { width: 100%; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin-left: 0; }
.trade-card, .daily-review-card { min-height: auto; } .trade-card, .daily-review-card { min-height: auto; }
.review-watch-table { max-height: var(--s-320); } .review-watch-table { max-height: var(--s-320); }
.trade-scroll { max-height: var(--s-320); } .trade-scroll { max-height: var(--s-320); }
@@ -17,6 +17,12 @@
flex: 1; flex: 1;
} }
.review-heading-actions {
display: flex;
gap: var(--s-8);
margin-left: auto;
}
.review-watch-table { .review-watch-table {
width: 100%; width: 100%;
max-height: var(--s-260); max-height: var(--s-260);
@@ -279,6 +279,15 @@
padding: var(--page-padding-y) var(--page-padding-x); padding: var(--page-padding-y) var(--page-padding-x);
} }
.shell-content {
min-width: 0;
}
.mobile-context-bar,
.mobile-only {
display: none;
}
.page-header { .page-header {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -125,6 +125,7 @@
--shadow-float: 0 12px 32px rgba(0, 0, 0, 0.18); --shadow-float: 0 12px 32px rgba(0, 0, 0, 0.18);
--duration-fast: 160ms; --duration-fast: 160ms;
--duration-normal: 220ms; --duration-normal: 220ms;
--duration-reduced: 1ms;
--ease-standard: ease; --ease-standard: ease;
--opacity-disabled: 0.52; --opacity-disabled: 0.52;
--opacity-muted: 0.72; --opacity-muted: 0.72;
+130
View File
@@ -0,0 +1,130 @@
const fs = require("node:fs");
const path = require("node:path");
const { expect, test } = require("@playwright/test");
const evidence = path.resolve(__dirname, "../../docs/evidence/stage-13");
test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true }));
async function authenticate(page) {
await page.goto("/");
await page.getByLabel("账号名").fill("stage13admin");
await page.getByLabel("密码").fill("Stage13-pass-123!");
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page.locator(".sidebar, .field-error")).toBeVisible();
if (!(await page.locator(".sidebar").isVisible())) {
await page.getByRole("tab", { name: "注册" }).click();
await page.getByRole("button", { name: "注册并登录" }).click();
}
}
function summary() {
return {
context: { requested_date: "2026-07-30", actual_date: "2026-07-30", previous_date: "2026-07-29", observed_at: "2026-07-30T15:00:00+08:00", state: "final", carried_forward: false, message: "" },
values: { temperature: 42, limit_up: 68, limit_down: 4, broken: 24, seal_rate: 73.9, amount: 1628000000000 },
};
}
function pool() {
const items = Array.from({ length: 4 }, (_, index) => ({
identifier: `00000${index + 1}.SZ`, code: `00000${index + 1}`, name: `移动样本${index + 1}`,
streak: index + 1, change: 9.9, price: 10 + index, sector: "机器人",
first_time: "09:35", last_time: "14:20", open_times: index, turnover_rate: 8 + index,
amount: 300000000, seal_amount: 50000000, reason: "产业链催化与资金承接",
}));
return { trade_date: "2026-07-30", observed_at: "2026-07-30T15:00:00+08:00", carried_forward: false, message: "", overview: { up_count: 2960, down_count: 1980, flat_count: 112, limit_up: 68, limit_down: 4, broken: 24, seal_rate: 73.9, amount: 1628000000000 }, items };
}
async function mockMarket(page) {
await page.route("**/api/market/summary", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(summary()) }));
await page.route("**/api/review/alerts?*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ items: [], unread_count: 0, as_of: "2026-07-30" }) }));
await page.route("**/api/market/workspaces/*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(pool()) }));
}
async function assertViewport(page, width, height) {
await page.setViewportSize({ width, height });
await expect(page.getByLabel("切换行情页面")).toBeVisible();
const metrics = await page.evaluate(() => {
const visible = (element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
};
const undersized = [...document.querySelectorAll("button")]
.filter(visible)
.map((element) => ({ label: element.getAttribute("aria-label") || element.textContent.trim(), rect: element.getBoundingClientRect() }))
.filter((item) => item.rect.width < 43.5 || item.rect.height < 43.5)
.map((item) => item.label);
return { overflow: document.documentElement.scrollWidth - innerWidth, undersized };
});
expect(metrics.overflow).toBeLessThanOrEqual(0);
expect(metrics.undersized).toEqual([]);
}
test("mobile information architecture, touch targets and responsive tables", async ({ page }) => {
const consoleErrors = [];
page.on("console", (message) => { if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) consoleErrors.push(message.text()); });
await mockMarket(page);
await authenticate(page);
await page.setViewportSize({ width: 320, height: 568 });
await page.getByLabel("切换行情页面").selectOption("pool");
await expect(page.getByRole("heading", { name: "涨停池" })).toBeVisible();
await expect(page.locator(".mobile-date-control .date-input")).toHaveValue("2026-07-30");
await expect(page.getByRole("navigation", { name: "移动端主导航" }).getByText("行情", { exact: true })).toHaveAttribute("aria-current", "page");
await expect(page.locator(".data-table tbody tr")).toHaveCount(4);
await expect(page.locator(".data-table tbody td").filter({ hasText: "移动样本1" })).toHaveAttribute("data-label", "股票");
expect(await page.locator(".data-table tbody tr").first().evaluate((row) => row.scrollWidth <= row.clientWidth)).toBe(true);
for (const viewport of [[320, 568], [375, 667], [390, 844], [430, 932], [768, 1024], [844, 390]]) {
await assertViewport(page, viewport[0], viewport[1]);
}
for (const viewport of [[1024, 768], [1280, 720], [1440, 900], [1536, 864], [1920, 1080], [3840, 2160]]) {
await page.setViewportSize({ width: viewport[0], height: viewport[1] });
await expect(page.getByLabel("切换行情页面")).toBeHidden();
expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(0);
}
await page.setViewportSize({ width: 320, height: 568 });
await page.screenshot({ path: path.join(evidence, "pool-light-320x568.jpg"), type: "jpeg", quality: 82 });
await page.getByRole("button", { name: "夜间" }).click();
await page.setViewportSize({ width: 844, height: 390 });
await page.screenshot({ path: path.join(evidence, "pool-dark-landscape-844x390.jpg"), type: "jpeg", quality: 82 });
expect(consoleErrors).toEqual([]);
});
test("keyboard focus, dialog return and reduced motion remain available", async ({ page }) => {
await mockMarket(page);
await page.emulateMedia({ reducedMotion: "reduce" });
await authenticate(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.keyboard.press("Tab");
await expect(page.getByRole("link", { name: "跳到主要内容" })).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.locator("#main-content")).toBeFocused();
const search = page.getByRole("button", { name: "全局搜索" });
await search.focus();
await search.click();
await expect(page.getByRole("dialog", { name: "全局搜索" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(search).toBeFocused();
await expect(page.locator(".emotion-dot")).toHaveCSS("animation-duration", "0.001s");
});
test("all sixteen workspaces remain reachable without page overflow", async ({ page }) => {
await page.route("**/api/market/summary", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(summary()) }));
await page.route("**/api/review/alerts?*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ items: [], unread_count: 0, as_of: "2026-07-30" }) }));
await page.route("**/api/market/workspaces/*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ trade_date: "", message: "移动端空态验收" }) }));
await page.route("**/api/market/insights/*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ trade_date: "", message: "移动端空态验收" }) }));
await authenticate(page);
await page.setViewportSize({ width: 320, height: 568 });
const keys = ["emotion", "pool", "broken", "limit-down", "yesterday", "performance", "ladder", "rotation", "auction", "themes", "popularity", "dragon-list", "screener", "mentor", "heaven", "review"];
for (const key of keys) {
await page.goto(`/workspace/${key}`);
await expect(page.locator(".page-frame")).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth), key).toBeLessThanOrEqual(0);
await expect(page.locator(".mobile-date-control .date-input")).toBeVisible();
if (key === "review") {
await expect(page.getByRole("button", { name: "提醒中心" })).toBeVisible();
await expect(page.getByRole("button", { name: "复盘助手" })).toBeVisible();
}
}
});