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
+4 -1
View File
@@ -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 />
+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>
<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>
+2 -21
View File
@@ -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>
<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>
<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>
+18 -2
View File
@@ -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;
}
}
+143 -8
View File
@@ -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;