rebuild(stage-13): redesign mobile information architecture
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# 阶段13验收记录
|
||||
|
||||
## 完成范围
|
||||
|
||||
- 移动Shell:保留行情、选股、问师、问天和复盘五个一级入口;行情下增加12个市场页面选择器。
|
||||
- 日期:桌面和移动端共用唯一交易日期组件,手机可前后切换或直接输入日期。
|
||||
- 表格:430px及以下的通用宽表重组为字段名明确的纵向记录,平板和横屏保留局部横向滚动。
|
||||
- 复盘:移动端补齐提醒中心和复盘助手入口,不因顶栏精简丢失功能。
|
||||
- 可访问性:跳到主内容、弹窗焦点陷阱与回收、排序状态、44px触控目标及减少动态效果偏好。
|
||||
|
||||
## 关键行为
|
||||
|
||||
- 股池、天梯、板块、竞价、题材、热榜和龙虎榜均通过移动行情选择器到达;底部“行情”在所有市场子页保持正确高亮。
|
||||
- PC与移动端继续消费相同路由、权限、日期、账号和业务结果,没有第二套移动API或业务状态。
|
||||
- 320px宽表至少直接显示字段名与字段值,长文本换行;页面本身不横滚。
|
||||
- 手机横屏将股池筛选、搜索和导出收为同一行,避免短视口只剩极少数据区。
|
||||
- Escape关闭弹窗后焦点回到触发按钮;跳到主内容链接可由键盘首先到达。
|
||||
|
||||
## 自动与视觉验收
|
||||
|
||||
- Ruff:通过。
|
||||
- Pytest:92项通过。
|
||||
- Vue TypeScript:通过。
|
||||
- Vitest:3个文件、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 |
@@ -8,6 +8,7 @@ import { useMarketStore } from "../../shared/stores/market";
|
||||
import DesktopSidebar from "./DesktopSidebar.vue";
|
||||
import MarketStrip from "./MarketStrip.vue";
|
||||
import MobileNav from "./MobileNav.vue";
|
||||
import MobileWorkspaceSwitch from "./MobileWorkspaceSwitch.vue";
|
||||
import StatusBar from "./StatusBar.vue";
|
||||
import TopBar from "./TopBar.vue";
|
||||
|
||||
@@ -33,11 +34,13 @@ onBeforeUnmount(() => window.removeEventListener("keydown", globalShortcut));
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
<DesktopSidebar />
|
||||
<div class="shell-main">
|
||||
<TopBar />
|
||||
<MarketStrip />
|
||||
<RouterView />
|
||||
<MobileWorkspaceSwitch />
|
||||
<div id="main-content" class="shell-content" tabindex="-1"><RouterView /></div>
|
||||
</div>
|
||||
<StatusBar />
|
||||
<MobileNav />
|
||||
|
||||
@@ -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>
|
||||
<nav class="mobile-nav" aria-label="移动端主导航">
|
||||
<RouterLink class="mobile-nav-item" to="/workspace/emotion">行情</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" to="/workspace/screener">选股</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" to="/workspace/mentor">问师</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" to="/workspace/heaven">问天</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" to="/workspace/review">复盘</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" :class="{ active: marketActive }" :aria-current="marketActive ? 'page' : undefined" to="/workspace/emotion">行情</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" :class="{ active: current === 'screener' }" to="/workspace/screener">选股</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" :class="{ active: current === 'mentor' }" to="/workspace/mentor">问师</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" :class="{ active: current === 'heaven' }" to="/workspace/heaven">问天</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" :class="{ active: current === 'review' }" to="/workspace/review">复盘</RouterLink>
|
||||
</nav>
|
||||
</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>
|
||||
@@ -8,6 +8,7 @@ import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useSessionStore } from "../../shared/stores/session";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import { findWorkspace } from "../workspaceRegistry";
|
||||
import TradingDateControl from "./TradingDateControl.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -32,22 +33,6 @@ function openAccountDialog(name: "profile" | "membership" | "password"): void {
|
||||
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> {
|
||||
menuOpen.value = false;
|
||||
try {
|
||||
@@ -84,11 +69,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", closeOnOutside))
|
||||
<header class="topbar">
|
||||
<span class="topbar-title">{{ title }}</span>
|
||||
<div class="topbar-spacer"></div>
|
||||
<div class="date-control 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>
|
||||
<TradingDateControl class="desktop-only" />
|
||||
<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 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">
|
||||
<header class="page-header review-heading">
|
||||
<div><h1>我的复盘</h1><p class="page-subtitle">数据日期 {{ data?.trade_date ?? market.selectedDate }} · 当前账号私有</p></div>
|
||||
<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>
|
||||
<div v-if="loading" class="card workspace-state">正在读取个人复盘记录</div>
|
||||
<EmptyState v-else-if="error" class="card" title="复盘记录暂不可用" :description="error" />
|
||||
|
||||
@@ -9,13 +9,19 @@ type TableColumn = {
|
||||
format?: (value: unknown, row: Record<string, unknown>) => string;
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
columns: TableColumn[];
|
||||
rows: Record<string, unknown>[];
|
||||
sortKey: string;
|
||||
sortDirection: "asc" | "desc";
|
||||
}>();
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -23,8 +29,8 @@ const emit = defineEmits<{ sort: [key: string] }>();
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="index-column">序号</th>
|
||||
<th v-for="column in columns" :key="column.key" :class="{ numeric: column.numeric, 'wide-column': column.wide, 'code-column': column.code }">
|
||||
<th class="index-column" scope="col">序号</th>
|
||||
<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)">
|
||||
{{ column.label }}<span v-if="sortKey === column.key">{{ sortDirection === "asc" ? "↑" : "↓" }}</span>
|
||||
</button>
|
||||
@@ -35,7 +41,7 @@ const emit = defineEmits<{ sort: [key: string] }>();
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in rows" :key="String(row.identifier ?? index)">
|
||||
<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] ?? "") }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -87,13 +87,29 @@ p {
|
||||
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) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: var(--s-1) !important;
|
||||
animation-duration: var(--duration-reduced) !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: var(--s-1) !important;
|
||||
transition-duration: var(--duration-reduced) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,18 @@
|
||||
.dialog,
|
||||
.dialog-wide {
|
||||
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;
|
||||
}
|
||||
|
||||
button,
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -27,7 +33,7 @@
|
||||
|
||||
.app-shell {
|
||||
padding-left: 0;
|
||||
padding-bottom: var(--shell-mobile-nav-height);
|
||||
padding-bottom: calc(var(--shell-mobile-nav-height) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
@@ -47,8 +53,7 @@
|
||||
}
|
||||
|
||||
.identity-cluster .tag,
|
||||
.account-name,
|
||||
.date-control .icon-button {
|
||||
.account-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -69,10 +74,53 @@
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
align-items: flex-start;
|
||||
gap: var(--s-4);
|
||||
@@ -85,7 +133,7 @@
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
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;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
@@ -101,7 +149,8 @@
|
||||
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);
|
||||
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) {
|
||||
.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; }
|
||||
.review-watch-table { max-height: var(--s-320); }
|
||||
.trade-scroll { max-height: var(--s-320); }
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.review-heading-actions {
|
||||
display: flex;
|
||||
gap: var(--s-8);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.review-watch-table {
|
||||
width: 100%;
|
||||
max-height: var(--s-260);
|
||||
|
||||
@@ -279,6 +279,15 @@
|
||||
padding: var(--page-padding-y) var(--page-padding-x);
|
||||
}
|
||||
|
||||
.shell-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-context-bar,
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
--shadow-float: 0 12px 32px rgba(0, 0, 0, 0.18);
|
||||
--duration-fast: 160ms;
|
||||
--duration-normal: 220ms;
|
||||
--duration-reduced: 1ms;
|
||||
--ease-standard: ease;
|
||||
--opacity-disabled: 0.52;
|
||||
--opacity-muted: 0.72;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user